1/** 2 * Shared bar-anchored time -> x mapping unit tests. 3 * 4 * timeToFractionalBar + medianCandleIntervalSec were moved here verbatim from the orderbook 5 * heatmap's own spec when they were promoted to shared/ -- the assertions are unchanged, only the 6 * import path moved. makeTimeToX is the new factory that wraps them against a chart time scale. 7 */ 8 9import { describe, it, expect } from 'vitest'; 10import { timeToFractionalBar, medianCandleIntervalSec, makeTimeToX, visibleTimeWindow, getSeriesBars } from './time-mapping.js'; 11 12describe('timeToFractionalBar (bar-anchored time->logical mapping)', () => { 13 // 15m grid, open-second times per CANDLE-TIME-CONTRACT. 14 const grid = (times) => times.map(time => ({ time })); 15 16 it('maps aligned times to frac 0 and mid-bar times to the elapsed fraction (uniform grid)', () => { 17 const candles = grid([0, 900, 1800, 2700]); 18 expect(timeToFractionalBar(candles, 900, 900)).toEqual({ idx: 1, frac: 0 }); 19 expect(timeToFractionalBar(candles, 900 + 300, 900)).toEqual({ idx: 1, frac: 300 / 900 }); 20 // Same shape on other intervals -- only intervalSec changes the fraction. 21 const oneMin = grid([0, 60, 120]); 22 expect(timeToFractionalBar(oneMin, 90, 60)).toEqual({ idx: 1, frac: 0.5 }); 23 const fourH = grid([0, 14400, 28800]); 24 expect(timeToFractionalBar(fourH, 14400 + 3600, 14400)).toEqual({ idx: 1, frac: 0.25 }); 25 }); 26 27 it('clamps a time inside a DATA GAP to the gap boundary (frac 1, no smearing)', () => { 28 // Bars ..., 1800, then a gap (2700..8100 missing), then 9000. The chart draws the gap at 29 // zero width, so a snapshot at 5000 must pin to the boundary after bar 1800 -- frac exactly 30 // 1 -- not extrapolate into logical slots that don't exist. 31 const candles = grid([0, 900, 1800, 9000, 9900]); 32 expect(timeToFractionalBar(candles, 5000, 900)).toEqual({ idx: 2, frac: 1 }); 33 // Just inside the covering bar's own slot is still a normal fraction. 34 expect(timeToFractionalBar(candles, 1800 + 450, 900)).toEqual({ idx: 2, frac: 0.5 }); 35 }); 36 37 it('extrapolates uncapped past the LAST bar (live edge)', () => { 38 const candles = grid([0, 900, 1800]); 39 expect(timeToFractionalBar(candles, 1800 + 1800, 900)).toEqual({ idx: 2, frac: 2 }); 40 }); 41 42 it('counts fractionally negative before the FIRST bar', () => { 43 const candles = grid([900, 1800]); 44 expect(timeToFractionalBar(candles, 450, 900)).toEqual({ idx: -1, frac: -0.5 }); 45 }); 46 47 it('handles empty and single-candle inputs', () => { 48 expect(timeToFractionalBar([], 100, 900)).toBeNull(); 49 expect(timeToFractionalBar(null, 100, 900)).toBeNull(); 50 // A single candle is also the last bar: uncapped extrapolation both ways. 51 const one = grid([900]); 52 expect(timeToFractionalBar(one, 900 + 2700, 900)).toEqual({ idx: 0, frac: 3 }); 53 expect(timeToFractionalBar(one, 0, 900)).toEqual({ idx: -1, frac: -1 }); 54 }); 55}); 56 57describe('medianCandleIntervalSec', () => { 58 it('returns the median neighbor gap, robust to data gaps', () => { 59 // Four gaps [900, 900, 7200, 900] -- the single 2h hole must not shift the interval. 60 expect(medianCandleIntervalSec([0, 900, 1800, 9000, 9900].map(time => ({ time })))).toBe(900); 61 }); 62 63 it('returns 0 for fewer than two candles', () => { 64 expect(medianCandleIntervalSec([])).toBe(0); 65 expect(medianCandleIntervalSec([{ time: 0 }])).toBe(0); 66 }); 67 68 it('memoizes per candle array so repeat callers in a frame do not re-sort', () => { 69 // Every element read goes through the bar objects, so counting property reads counts 70 // whether the scan actually ran again. A layer that attaches N primitives calls this N 71 // times per frame with the SAME array -- the scan must happen once. 72 let reads = 0; 73 const bars = [0, 900, 1800, 2700].map(t => ({ get time() { reads++; return t; } })); 74 75 expect(medianCandleIntervalSec(bars)).toBe(900); 76 const afterFirst = reads; 77 expect(afterFirst).toBeGreaterThan(0); 78 79 for (let i = 0; i < 20; i++) expect(medianCandleIntervalSec(bars)).toBe(900); 80 expect(reads).toBe(afterFirst); 81 }); 82 83 it('re-measures a different array -- identity is the invalidation signal', () => { 84 // series.data() allocates a fresh array whenever the data changes, so a new grid is always 85 // a new object and can never be served the previous grid's interval. 86 const fifteenMin = [0, 900, 1800].map(time => ({ time })); 87 const oneHour = [0, 3600, 7200].map(time => ({ time })); 88 89 expect(medianCandleIntervalSec(fifteenMin)).toBe(900); 90 expect(medianCandleIntervalSec(oneHour)).toBe(3600); 91 expect(medianCandleIntervalSec(fifteenMin)).toBe(900); 92 }); 93 94 it('memoizes the degenerate no-positive-gap case instead of rescanning it every frame', () => { 95 let reads = 0; 96 const flat = [0, 0, 0].map(t => ({ get time() { reads++; return t; } })); 97 98 expect(medianCandleIntervalSec(flat)).toBe(0); 99 const afterFirst = reads; 100 expect(medianCandleIntervalSec(flat)).toBe(0); 101 expect(reads).toBe(afterFirst); 102 }); 103}); 104 105describe('getSeriesBars (series.data() cache)', () => { 106 // series.data() allocates a fresh object per bar on every call, and a layer like the Ichimoku 107 // cloud attaches one primitive per segment -- so the cache is what keeps the shared helper from 108 // being a regression. But it may only cache when it can actually be invalidated. 109 function fakeSeries({ subscribe = true, throwOnSubscribe = false } = {}) { 110 const s = { 111 reads: 0, 112 bars: [{ time: 0 }, { time: 900 }], 113 data() { s.reads++; return s.bars; }, 114 }; 115 if (subscribe) { 116 s.subscribeDataChanged = (cb) => { 117 if (throwOnSubscribe) throw new Error('no subscription for you'); 118 s.fire = cb; 119 }; 120 } 121 return s; 122 } 123 124 it('reads once and reuses the array until the series says its data changed', () => { 125 const s = fakeSeries(); 126 expect(getSeriesBars(s)).toBe(s.bars); 127 getSeriesBars(s); 128 getSeriesBars(s); 129 expect(s.reads).toBe(1); 130 131 s.bars = [{ time: 0 }, { time: 900 }, { time: 1800 }]; 132 s.fire(); // setData()/update() fires this in the real library 133 expect(getSeriesBars(s)).toBe(s.bars); 134 expect(s.reads).toBe(2); 135 }); 136 137 it('reads through every call when the series exposes no invalidation hook', () => { 138 const s = fakeSeries({ subscribe: false }); 139 getSeriesBars(s); 140 getSeriesBars(s); 141 expect(s.reads).toBe(2); 142 }); 143 144 it('reads through every call when subscribing THROWS (never serves a stale grid)', () => { 145 // The regression: caching on a failed subscription pinned every layer to a candle grid
146 // that no longer existed, with no signal that could ever clear it. 147 const s = fakeSeries({ throwOnSubscribe: true }); 148 expect(getSeriesBars(s)).toBe(s.bars); 149 const grown = [{ time: 0 }, { time: 900 }, { time: 1800 }]; 150 s.bars = grown; 151 expect(getSeriesBars(s)).toBe(grown); 152 expect(s.reads).toBe(2); 153 }); 154 155 it('retries after a failed read instead of pinning null', () => { 156 const s = fakeSeries(); 157 let boom = true; 158 s.data = () => { s.reads++; if (boom) throw new Error('detached'); return s.bars; }; 159 expect(getSeriesBars(s)).toBeNull(); 160 boom = false; 161 expect(getSeriesBars(s)).toBe(s.bars); 162 }); 163 164 it('returns null for a missing or dataless series', () => { 165 expect(getSeriesBars(null)).toBeNull(); 166 expect(getSeriesBars({})).toBeNull(); 167 }); 168}); 169 170describe('visibleTimeWindow (viewport gate bounds)', () => { 171 const candles = [0, 900, 1800, 2700].map(time => ({ time })); 172 const series = { data: () => candles }; 173 const chartWith = (vr) => ({ timeScale: () => ({ getVisibleRange: () => vr }) }); 174 175 it('extends the upper bound one bar past vr.to so the forming candle is not gated out', () => { 176 // vr.to is the last bar's OPEN second. Everything between that open and "now" -- the newest 177 // trade prints, an order placed inside the current candle -- would be dropped without this. 178 const win = visibleTimeWindow(chartWith({ from: 900, to: 2700 }), series); 179 expect(win.from).toBe(900); 180 expect(win.to).toBe(2700 + 900); 181 }); 182 183 it('honours an explicit interval over the derived one', () => { 184 expect(visibleTimeWindow(chartWith({ from: 0, to: 2700 }), series, 3600).to).toBe(2700 + 3600); 185 }); 186 187 it('goes unbounded when the range is missing, non-finite, or the chart throws', () => { 188 const unbounded = { from: -Infinity, to: Infinity }; 189 expect(visibleTimeWindow(chartWith(null), series)).toEqual(unbounded); 190 expect(visibleTimeWindow(chartWith({ from: 0, to: NaN }), series)).toEqual(unbounded); 191 expect(visibleTimeWindow(null, series)).toEqual(unbounded); 192 const boom = { timeScale: () => { throw new Error('detached'); } }; 193 expect(visibleTimeWindow(boom, series)).toEqual(unbounded); 194 }); 195 196 it('leaves the bound unpadded when the interval cannot be derived', () => { 197 // One candle -> no neighbor gap -> no interval. Better an unpadded bound than a guessed one. 198 expect(visibleTimeWindow(chartWith({ from: 0, to: 900 }), { data: () => [{ time: 0 }] }).to).toBe(900); 199 }); 200}); 201 202describe('makeTimeToX (chart-bound bar-anchored mapper)', () => { 203 const BAR_SPACING = 10; 204 // 15m bars with a 2h hole: logical index == array index, x(logical) = logical * BAR_SPACING. 205 const candleTimes = [0, 900, 1800, 9000, 9900]; 206 const candles = candleTimes.map(time => ({ time, open: 250, high: 300, low: 200, close: 250 })); 207 208 function makeChart(overrides = {}) { 209 const timeToIndexMap = new Map(candleTimes.map((t, i) => [t, i])); 210 const timeScale = { 211 // Exact-match only -- this is the real Lightweight Charts behavior the mapper works around. 212 timeToCoordinate: (t) => timeToIndexMap.has(t) ? timeToIndexMap.get(t) * BAR_SPACING : null, 213 timeToIndex: (t) => timeToIndexMap.has(t) ? timeToIndexMap.get(t) : null, 214 // Faithful to the vendored Lightweight Charts build: its indexToCoordinate opens with 215 // `if (isEmpty() || !isInteger(index)) return 0`, so a FRACTIONAL logical yields 0 -- 216 // a finite coordinate at the far LEFT of the pane, NOT null. Modelling this as a clean 217 // linear function of any real number is what let the August 2026 bug (every Fill 218 // Bubbles marker stacked on the left edge) through this suite unnoticed. 219 logicalToCoordinate: (l) => (Number.isInteger(l) ? l * BAR_SPACING : 0), 220 ...overrides, 221 }; 222 return { timeScale: () => timeScale }; 223 } 224 225 const series = { data: () => candles }; 226 227 it('maps an exact bar open to that bar coordinate', () => { 228 const timeToX = makeTimeToX(makeChart(), series); 229 expect(timeToX(1800)).toBeCloseTo(2 * BAR_SPACING, 6); 230 expect(timeToX(0)).toBeCloseTo(0, 6); 231 }); 232 233 it('maps a MID-BAR time to the fractional coordinate (where timeToCoordinate returns null)', () => { 234 const chart = makeChart(); 235 expect(chart.timeScale().timeToCoordinate(1800 + 450)).toBeNull(); 236 const timeToX = makeTimeToX(chart, series); 237 expect(timeToX(1800 + 450)).toBeCloseTo(2.5 * BAR_SPACING, 6); 238 }); 239 240 it('pins a time inside a candle GAP to the gap boundary instead of smearing', () => { 241 const timeToX = makeTimeToX(makeChart(), series); 242 // 5000 sits in the 1800..9000 hole -> clamped to bar 2 + 1 slot = logical 3. 243 expect(timeToX(5000)).toBeCloseTo(3 * BAR_SPACING, 6); 244 }); 245 246 it('EXTRAPOLATES past the last candle rather than returning null (forward-projection layers)', () => { 247 const timeToX = makeTimeToX(makeChart(), series); 248 // 26 bars past the last open -- an Ichimoku-style forward cloud point. Must be a real 249 // coordinate; the old timeToCoordinate path returned null and callers pinned it to -1000. 250 const x = timeToX(9900 + 26 * 900); 251 expect(x).not.toBeNull(); 252 expect(x).toBeCloseTo((4 + 26) * BAR_SPACING, 6); 253 }); 254 255 it('extrapolates backwards before the first candle', () => { 256 const timeToX = makeTimeToX(makeChart(), series); 257 expect(timeToX(-450)).toBeCloseTo(-0.5 * BAR_SPACING, 6); 258 }); 259 260 it('never hands a FRACTIONAL logical to logicalToCoordinate (vendor returns 0 for those)', () => { 261 // The bug this pins: passing logical+frac straight through silently produced x = 0 for 262 // every mid-bar time, stacking a whole layer on the pane's left edge. Only integers may 263 // reach the vendor call; the fraction is applied by interpolating between two of them. 264 const seen = []; 265 const chart = makeChart({ 266 logicalToCoordinate: (l) => { seen.push(l); return Number.isInteger(l) ? l * BAR_SPACING : 0; }, 267 }); 268 const timeToX = makeTimeToX(chart, series); 269 for (const t of [1800 + 450, 900 + 1, 5000, -450, 9900 + 26 * 900]) timeToX(t); 270 expect(seen.length).toBeGreaterThan(0); 271 expect(seen.every(Number.isInteger)).toBe(true); 272 }); 273 274 it('places every second of a bar strictly between that bar and the next', () => { 275 const timeToX = makeTimeToX(makeChart(), series); 276 const barStartX = timeToX(900); 277 const nextBarX = timeToX(1800); 278 expect(barStartX).toBeCloseTo(1 * BAR_SPACING, 6); 279 for (let offset = 1; offset < 900; offset += 37) { 280 const x = timeToX(900 + offset); 281 expect(x).toBeGreaterThan(barStartX); 282 expect(x).toBeLessThan(nextBarX); 283 } 284 }); 285 286 it('is monotonic across a mid-bar sweep (no collapse to a single coordinate)', () => { 287 const timeToX = makeTimeToX(makeChart(), series); 288 let prev = -Infinity; 289 for (let t = 0; t <= 1800; t += 60) { 290 const x = timeToX(t); 291 expect(x).toBeGreaterThan(prev); 292 prev = x; 293 } 294 }); 295 296 it('returns null rather than 0 when the bracketing integer coordinates are unavailable', () => { 297 // An unusable coordinate function must not degrade into "far left of pane" -- callers 298 // treat null as "skip this element", which is the only safe reading. 299 const chart = makeChart({ logicalToCoordinate: () => null }); 300 expect(makeTimeToX(chart, series)(1800 + 450)).toBeNull(); 301 }); 302 303 it('falls back to exact timeToCoordinate when the time scale has no logical API', () => { 304 const chart = makeChart({ timeToIndex: undefined, logicalToCoordinate: undefined }); 305 const timeToX = makeTimeToX(chart, series); 306 expect(timeToX(1800)).toBeCloseTo(2 * BAR_SPACING, 6); 307 expect(timeToX(1800 + 450)).toBeNull(); // no logical space to interpolate through 308 }); 309 310 it('returns null (never throws) with no chart, no candles, or a non-finite time', () => { 311 expect(makeTimeToX(null, series)(900)).toBeNull(); 312 expect(makeTimeToX(makeChart(), { data: () => [] })(900)).toBeNull(); 313 expect(makeTimeToX(makeChart(), series)(NaN)).toBeNull(); 314 expect(makeTimeToX(makeChart(), series)(undefined)).toBeNull(); 315 }); 316 317 it('accepts explicit candles + interval without touching the series', () => { 318 const series404 = { data: () => { throw new Error('should not be called'); } }; 319 const timeToX = makeTimeToX(makeChart(), series404, candles, 900); 320 expect(timeToX(900 + 450)).toBeCloseTo(1.5 * BAR_SPACING, 6); 321 }); 322 323 it('memoizes bar->logical lookups within one frame', () => { 324 let calls = 0; 325 const timeToIndexMap = new Map(candleTimes.map((t, i) => [t, i])); 326 const chart = makeChart({ 327 timeToIndex: (t) => { calls++; return timeToIndexMap.has(t) ? timeToIndexMap.get(t) : null; }, 328 }); 329 const timeToX = makeTimeToX(chart, series); 330 for (let t = 1800; t < 1800 + 900; t += 60) timeToX(t); // 15 times, all in bar 2 331 expect(calls).toBe(1); 332 }); 333});
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.