PageSourceSearch

https://degenai.dev/js/plugins/chart-plugins/shared/time-mapping.spec.os1w6mzdan.js

js degenai.dev collected 2026-10-06 14:18:44 UTC 16,422 bytes, 333 lines download raw bytes

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.