1/** 2 * backtest-live.js condition-color diff-cache tests. 3 * 4 * INCIDENT: release v1.2.14.182 (commit 2bc2501f7) fixed a browser freeze in the admin 5 * backtest dialog. applyConditionColors looped over every loaded candle calling 6 * candleSeries.update() one bar at a time, and the dialog invoked it through 7 * setBacktestSignalMarkers on every 2-second poll tick with the full signal array. On a 8 * 30-day 1-minute run (~43k candles) that fired ~43k individual series mutations every 9 * 2 seconds and saturated the browser rendering pipeline. 10 * 11 * That commit's branch was never merged to master, so its batch-setData rewrite and its 12 * appendBacktestTradeMarkers / appendBacktestSignalMarkers incremental functions are gone. 13 * The property is instead held by the diff-only color cache _appliedColorState, added 14 * independently in commit 23baddf03: the loop still calls per-bar update(), but skips any 15 * candle whose computed color state matches the cached one, so a tick costs the delta 16 * rather than the whole series. 17 * 18 * Nothing asserted that skip, which meant a refactor of the recolor loop could reintroduce 19 * the freeze silently. These tests pin it. The contract: 20 * - the first pass paints each non-warmup candle exactly once 21 * - a repeat pass over an unchanged signal set paints nothing 22 * - a pass that adds one signal paints one bar, not the whole series 23 * - warmup candles are never recolored, on any pass 24 * - turning the highlight flag off repaints the met bars back to default 25 * - either call that replaces the series data drops the cache, so the next pass 26 * repaints from scratch 27 * 28 * That last one is the mirror of the other five. The cache is only safe to consult while 29 * it describes what is actually on the series: setData wipes the colors off, so a cache 30 * that survived it would skip every candle and leave the chart uncolored while claiming 31 * otherwise. Both callers that replace the series, setAllCandles and setSimStartMarker, 32 * clear it for that reason, and both are pinned here. 33 * 34 * The rebuild-from-scratch path and its MAX_APPEND_REBUILDS bound are covered in 35 * backtest-live-selfheal.spec.js and deliberately not duplicated here. 36 */ 37 38import { describe, it, expect, beforeEach, vi } from 'vitest'; 39 40vi.mock('./chart-instance.js', () => ({ 41 getChartInstance: vi.fn(), 42 getCandleSeries: vi.fn(), 43})); 44 45vi.mock('./markers-plugin.js', () => ({ 46 createMarkersPlugin: vi.fn(), 47 updateMarkers: vi.fn(), 48 clearMarkers: vi.fn(), 49})); 50 51vi.mock('./backtest-timeline.js', () => ({ 52 setSuppressChartSync: vi.fn(), 53 notifyChartDataChanged: vi.fn(), 54})); 55 56// Must match the module's own constants (backtest-live.js, top of file). 57const DEFAULT_UP_COLOR = '#84B03C'; 58const CONDITION_UP_COLOR = '#B8F84E'; 59 60// close >= open, so every fixture candle is an "up" bar and the expected colors above 61// are the up-variants. 62function candle(time, extra = {}) { 63 return { time, open: 1, high: 2, low: 0.5, close: 1.5, ...extra }; 64} 65 66// Recolor passes go through update(bar, true) -- the historicalUpdate flag. Append 67// writes use the one-argument form, so this is how a repaint is told from an append. 68function recoloredBars(series) { 69 return series.update.mock.calls.filter(c => c[1] === true).map(c => c[0]); 70} 71 72function recoloredTimes(series) { 73 return recoloredBars(series).map(b => b.time); 74} 75 76describe('backtest-live condition-color diff cache', () => { 77 let bt; 78 let chartInstance; 79 let series; 80 let timeScale;
81 let chart; 82 83 beforeEach(async () => { 84 vi.resetModules(); 85 chartInstance = await import('./chart-instance.js'); 86 bt = await import('./backtest-live.js'); 87 88 timeScale = { 89 scrollToPosition: vi.fn(), 90 scrollPosition: vi.fn(() => 0), 91 getVisibleRange: vi.fn(() => ({ from: 100, to: 200 })), 92 setVisibleRange: vi.fn(), 93 subscribeVisibleLogicalRangeChange: vi.fn(), 94 unsubscribeVisibleLogicalRangeChange: vi.fn(), 95 }; 96 chart = { 97 timeScale: vi.fn(() => timeScale), 98 chartElement: vi.fn(() => null), 99 subscribeCrosshairMove: vi.fn(), 100 unsubscribeCrosshairMove: vi.fn(), 101 subscribeClick: vi.fn(), 102 unsubscribeClick: vi.fn(), 103 }; 104 series = { 105 setData: vi.fn(), 106 update: vi.fn(), 107 attachPrimitive: vi.fn(), 108 detachPrimitive: vi.fn(), 109 }; 110 chartInstance.getChartInstance.mockReturnValue(chart); 111 chartInstance.getCandleSeries.mockReturnValue(series); 112 113 // 60-second interval: signal times must sit on 60-second boundaries to align 114 // to candle open-times (alignToCandle floors to the interval bucket). 115 bt.initBacktestLive(60); 116 }); 117 118 it('paints a condition-met candle once on the first pass', () => { 119 bt.appendCandles([candle(60), candle(120), candle(180)]); 120 expect(series.update).not.toHaveBeenCalled(); // bulk load went through setData 121 122 bt.setBacktestSignalMarkers([{ time: 120, conditionMet: true }], true); 123 124 // From-scratch pass: every candle painted, each exactly once 125 expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([60, 120, 180]); 126 127 const byTime = new Map(recoloredBars(series).map(b => [b.time, b])); 128 expect(byTime.get(120).color).toBe(CONDITION_UP_COLOR); 129 expect(byTime.get(60).color).toBe(DEFAULT_UP_COLOR); 130 expect(byTime.get(180).color).toBe(DEFAULT_UP_COLOR); 131 }); 132 133 it('repeating the same signal set does not repaint anything', () => { 134 bt.appendCandles([candle(60), candle(120), candle(180)]); 135 const signals = [{ time: 120, conditionMet: true }]; 136 bt.setBacktestSignalMarkers(signals, true); 137 expect(recoloredBars(series)).toHaveLength(3); 138 139 series.update.mockClear(); 140 141 // The poll loop re-sends the full signal array every tick. Nothing changed, so 142 // nothing may be repainted. This is the assertion that fails if the diff cache 143 // is removed: without it every candle is rewritten on every tick. 144 bt.setBacktestSignalMarkers(signals, true); 145 expect(series.update).not.toHaveBeenCalled(); 146 147 // Still true on a third identical tick 148 bt.setBacktestSignalMarkers(signals, true); 149 expect(series.update).not.toHaveBeenCalled(); 150 }); 151 152 it('a tick that adds one signal repaints one bar, not the whole series', () => { 153 // Large enough that an O(series) repaint is unmistakable against an O(delta) one. 154 const BAR_COUNT = 2000; 155 const candles = []; 156 for (let i = 1; i <= BAR_COUNT; i++) candles.push(candle(i * 60)); 157 bt.appendCandles(candles); 158 159 const signals = [{ time: 60, conditionMet: true }]; 160 bt.setBacktestSignalMarkers(signals, true); 161 expect(recoloredBars(series)).toHaveLength(BAR_COUNT); // from-scratch pass 162 163 series.update.mockClear(); 164 165 // Next poll tick: the full array again, plus one newly condition-met candle. 166 const newSignalTime = 900 * 60; 167 bt.setBacktestSignalMarkers([...signals, { time: newSignalTime, conditionMet: true }], true); 168 169 const repainted = recoloredBars(series); 170 expect(repainted).toHaveLength(1); 171 expect(repainted[0].time).toBe(newSignalTime); 172 expect(repainted[0].color).toBe(CONDITION_UP_COLOR); 173 }); 174 175 it('never recolors warmup candles', () => { 176 bt.appendCandles([ 177 candle(60, { isWarmup: true }), 178 candle(120, { isWarmup: true }), 179 candle(180), 180 candle(240), 181 ]); 182 183 // A signal landing on a warmup bar must not overwrite its muted tint, and the 184 // warmup bars must not be rewritten on any later tick either (they are skipped 185 // before the cache, so they never enter it). 186 const signals = [
187 { time: 60, conditionMet: true }, 188 { time: 180, conditionMet: true }, 189 ]; 190 bt.setBacktestSignalMarkers(signals, true); 191 192 expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([180, 240]); 193 194 series.update.mockClear(); 195 bt.setBacktestSignalMarkers(signals, true); 196 expect(series.update).not.toHaveBeenCalled(); 197 }); 198 199 it('turning the highlight flag off repaints the met bar back to default', () => { 200 bt.appendCandles([candle(60), candle(120), candle(180)]); 201 const signals = [{ time: 120, conditionMet: true }]; 202 bt.setBacktestSignalMarkers(signals, true); 203 expect(recoloredBars(series)).toHaveLength(3); 204 205 series.update.mockClear(); 206 207 // The dialog's "show all signals" checkbox re-sends the same signals with 208 // highlight off (BacktestLiveChart.razor, OnShowAllSignalsChanged). The cache 209 // must treat that as a color change on the met bar and only on the met bar: 210 // short-circuiting the loop when highlight is off would leave it lime forever. 211 bt.setBacktestSignalMarkers(signals, false); 212 213 const off = recoloredBars(series); 214 expect(off).toHaveLength(1); 215 expect(off[0].time).toBe(120); 216 expect(off[0].color).toBe(DEFAULT_UP_COLOR); 217 218 series.update.mockClear(); 219 220 // And back on again, still one bar. 221 bt.setBacktestSignalMarkers(signals, true); 222 const on = recoloredBars(series); 223 expect(on).toHaveLength(1); 224 expect(on[0].time).toBe(120); 225 expect(on[0].color).toBe(CONDITION_UP_COLOR); 226 }); 227 228 // Both of these write the untinted candles back over the series with setData, so 229 // every condition color is gone afterwards. The cache has to go with them: one that 230 // survived would skip all three bars and leave them default-colored on screen while 231 // reporting them as already painted. Table-driven so a third setData caller cannot 232 // be added without a row here. simStart is the first candle's own time, which leaves 233 // nothing before the boundary and so tints no candle as warmup. 234 it.each([ 235 ['setAllCandles', (mod, candles) => mod.setAllCandles(candles)], 236 ['setSimStartMarker', (mod, candles) => mod.setSimStartMarker(candles[0].time, 60)], 237 ])('repaints from scratch after %s replaces the series data', (_name, replaceSeriesData) => { 238 const candles = [candle(60), candle(120), candle(180)]; 239 bt.appendCandles(candles); 240 const signals = [{ time: 120, conditionMet: true }]; 241 bt.setBacktestSignalMarkers(signals, true); 242 expect(recoloredBars(series)).toHaveLength(3); 243 244 replaceSeriesData(bt, candles); 245 series.update.mockClear(); 246 247 bt.setBacktestSignalMarkers(signals, true); 248 expect(recoloredTimes(series).sort((a, b) => a - b)).toEqual([60, 120, 180]); 249 }); 250});
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.