/** * Trendline placement: the line must start where the cursor said it would. * * Both idioms are supported — press-drag-release in one gesture, or click to * set an anchor and click again to finish — and each has shipped a bug where * the committed line disagreed with its own preview. */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { withChart, chartBox, at, armTool, newestTrendline, assertNoPageErrors, } from './helpers.mjs'; /** * Time under a given page x. * * Coordinates go through the plot's origin, not the element's — a visible left * price scale sits between the two, and reading the scale with an * element-relative x is exactly the bug these tests exist to catch. */ const timeAt = (page, box, point) => page.evaluate(x => { const c = window.__chart; return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX())); }, point.x - box.x); test('a 30m line keeps source geometry when the 1m window starts after its anchors', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const halfHour = 30 * 60; const weekend = 49 * 3600; const sourceTimes = [ 0, halfHour, halfHour + weekend, 2 * halfHour + weekend, 3 * halfHour + weekend, 4 * halfHour + weekend, ]; const displayed = []; for (let t = sourceTimes[4]; t <= sourceTimes[5]; t += 60) { displayed.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } const line = { id: 'synthetic_30m', kind: 'manual', tf: '30m', anchor_t: sourceTimes[0], anchor_p: 100, last_t: sourceTimes[3], slope: 3 / (sourceTimes[3] - sourceTimes[0]), cutoff_t: null, }; const coordinateProbe = { bars: [{ t: 0 }, { t: halfHour * 2 }], chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) }, indexAt: () => 0.5, coordinateAtIndex: () => 123, timeScaleSlots: () => [], }; const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call( coordinateProbe, halfHour, ); c.bars = displayed; c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: halfHour } }, }); const sourcePrice = c.linePriceAtIndex(line, displayed.length - 1); const sampled = c.seriesLineData(line); const shifted = c.shiftLineTime(line, line.anchor_t, 1); c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]); const trimmedTimes = c.trendlineSourceSeries.get('30m').times; const gapAccepted = c.updateTrendlineTime( '30m', sourceTimes[5] + halfHour, halfHour, sourceTimes[2], sourceTimes[3], ); const timesAfterGap = c.trendlineSourceSeries.get('30m').times; c.setTrendlineGeometry({ mode: 'legacy', series: {} }); const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1); return { sourcePrice, legacyPrice, shifted, sourceSecondBar: sourceTimes[1], sampledTimes: sampled.map(point => point.time), displayedTimes: displayed.map(bar => bar.t), trimmedTimes, expectedTrimmedTimes: sourceTimes.slice(2), gapAccepted, timesAfterGap, fractionalCoordinate, }; }); assert.equal(result.sourcePrice, 105, 'the 30m line was repriced in the truncated 1m window'); assert.notEqual(result.legacyPrice, result.sourcePrice, 'the rollback path no longer restores displayed-grid geometry'); assert.equal(result.shifted, result.sourceSecondBar, 'a source-timeframe nudge did not move by one 30m bar'); const historical = result.sampledTimes.filter(time => time <= result.displayedTimes.at(-1)); assert.deepEqual(historical, [result.displayedTimes[0], result.displayedTimes.at(-1)], 'a 30m line on 1m should sample source-bar times, not every 1m candle'); assert.ok(result.sampledTimes.slice(historical.length) .every(time => time > result.displayedTimes.at(-1)), 'manual-line extension mixed future points into candle history'); assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, 'source timestamps evicted by the server remained in the browser'); assert.equal(result.gapAccepted, false, 'a dropped source timestamp was accepted without requesting a full replacement'); assert.deepEqual(result.timesAfterGap, result.expectedTrimmedTimes, 'a lossy source delta mutated geometry before resynchronization'); assert.equal(result.fractionalCoordinate, 123, 'a non-candle source anchor trusted a foreign shared-scale coordinate'); assert.equal(await page.locator('.chart-line-hit').evaluate(node => getComputedStyle(node).fill), 'none', 'the invisible polyline hit region filled the chart as a black polygon'); assertNoPageErrors(page, assert); }); }); test('parallel duplicates keep the same source-space hourly slope across a gap', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const halfHour = 30 * 60; const weekend = 49 * 3600; const times = [ 0, halfHour, halfHour + weekend, 2 * halfHour + weekend, 3 * halfHour + weekend, 4 * halfHour + weekend, ]; c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times, duration: halfHour } } }); const line = (anchor_t, last_t) => ({ kind: 'manual', tf: '30m', anchor_t, anchor_p: 100, last_t, slope: 2 / (last_t - anchor_t), }); const acrossGap = line(times[0], times[2]); const contiguous = line(times[2], times[4]); return { raw: [acrossGap.slope * 3600, contiguous.slope * 3600], canonical: [c.slopePerHour(acrossGap), c.slopePerHour(contiguous)], labels: [c.slopeLabel(acrossGap), c.slopeLabel(contiguous)], }; }); assert.notEqual(result.raw[0], result.raw[1], 'fixture did not cross a wall-clock gap'); assert.deepEqual(result.canonical, [2, 2]); assert.deepEqual(result.labels, ['+2.00 /h', '+2.00 /h']); assertNoPageErrors(page, assert); }); }); test('future 5m projections keep their slope after the settlement break', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const fiveMinutes = 5 * 60; const thirtyMinutes = 30 * 60; // The final 65-minute gap is the 17:00-18:00 CME settlement break. const displayed = Array.from({ length: 30 }, (_, index) => ({ tf: '5m', t: index * fiveMinutes, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); displayed.push({ tf: '5m', t: displayed.at(-1).t + 13 * fiveMinutes, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, }); const line = { id: 'settlement-gap', kind: 'manual', tf: '30m', anchor_t: 0, anchor_p: 100, last_t: thirtyMinutes, slope: 6 / thirtyMinutes, cutoff_t: null, }; c.bars = displayed; c.setDisplayFutureTimes(Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => displayed.at(-1).t + (index + 1) * fiveMinutes, )); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: [0, thirtyMinutes, 2 * thirtyMinutes, 3 * thirtyMinutes, 4 * thirtyMinutes, 7 * thirtyMinutes], duration: thirtyMinutes, future_times: [8 * thirtyMinutes, 9 * thirtyMinutes], }, }, }); const last = displayed.length - 1; const before = c.linePriceAtIndex(line, last - 1); const current = c.linePriceAtIndex(line, last); const future = c.linePriceAtIndex(line, last + 1); return { historicalChange: current - before, futureChange: future - current, futureTime: c.timeAtIndex(last + 1), expectedFutureTime: displayed[last].t + fiveMinutes, }; }); assert.equal(result.futureTime, result.expectedFutureTime, 'one future 5m slot inherited the 65-minute settlement gap'); assert.ok(Math.abs(result.futureChange - result.historicalChange) < 1e-9, `future slope changed from ${result.historicalChange} to ${result.futureChange}`); assertNoPageErrors(page, assert); }); }); test('daily future slots and live duration follow the server session calendar', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const day = 24 * 3600; const thursday = 1786658400; const sunday = 1786917600; const monday = sunday + day; const bars = [thursday - 3 * day, thursday - 2 * day, thursday - day, thursday] .map(t => ({ tf: '1d', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true })); c.setDisplayFutureTimes([sunday, monday]); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '1d': { times: bars.map(bar => bar.t), durations: bars.map(() => 23 * 3600), future_times: [sunday, monday], future_durations: [23 * 3600, 23 * 3600] } }, }); c.updateTrendlineTime( '1d', sunday, 23 * 3600, bars[0].t, thursday, [monday], [23 * 3600], ); const source = c.trendlineSourceSeries.get('1d'); const horizonLine = { kind: 'manual', tf: '1d', anchor_t: sunday, anchor_p: 100, last_t: monday, slope: 1 / day, }; return { displayed: c.futureTimes(c.bars).map(point => point.time), duration: source.durations.at(-1), beyondHorizon: ConfluenceChart.timeframeTimeAt(source, source.allTimes.length + 1), shifted: c.shiftDisplayedTime(thursday, 1), shiftBeyondHorizon: c.shiftLineTime(horizonLine, monday, 1), }; }); assert.deepEqual(result.displayed, [1786917600, 1787004000]); assert.equal(result.duration, 23 * 3600); assert.equal(result.beyondHorizon, null); assert.equal(result.shifted, 1786917600); assert.equal(result.shiftBeyondHorizon, null); assertNoPageErrors(page, assert); }); }); test('ending a trendline in future whitespace keeps the future timestamp', { timeout: 180000 }, async () => { await withChart(async page => { const targetTime = await page.evaluate(() => { const c = window.__chart; const step = 300; const bars = Array.from({ length: 30 }, (_, index) => ({ tf: '5m', t: 400000 + index * step, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); const future = Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step); const line = { id: 'future-cutoff', kind: 'manual', tf: '5m', side: 'support', label: 'cutoff', anchor_t: bars[5].t, anchor_p: 99.5, last_t: bars[15].t, slope: 0.25 / 3000, cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; c.setDisplayFutureTimes(future); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step, future_times: future, future_durations: future.map(() => step) } }, }); c.syncLevels([line]); c.setSelectedLine(line.id); return future[2]; }); await page.waitForTimeout(100); const result = await page.evaluate(time => { const c = window.__chart; const line = c.levels.find(level => level.id === 'future-cutoff'); const plot = c.plotCanvas().getBoundingClientRect(); const event = { clientX: plot.left + c.chart.timeScale().timeToCoordinate(time), clientY: plot.top + c.candles.priceToCoordinate(c.sourceLinePriceAt(line, time)), preventDefault() {}, }; const point = c.eventPoint(event); c.showContextMenu(event); return { time, cutoff: c.contextCutoff, point, within: point && c.withinPlot(point), selected: c.selectedLineId, y: event.clientY - plot.top }; }, targetTime); assert.equal(result.cutoff, result.time, `future cutoff was clamped to the final candle: ${JSON.stringify(result)}`); assertNoPageErrors(page, assert); }); }); test('a short final data gap does not kink a future trendline', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const minute = 60; const bars = Array.from({ length: 20 }, (_, index) => ({ tf: '1m', t: 100000 + index * minute, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); bars.push({ ...bars.at(-1), t: bars.at(-1).t + 9 * minute }); const line = { id: 'sparse-tail', kind: 'manual', tf: '30m', side: 'support', label: 'probe', anchor_t: bars[0].t, anchor_p: 100, last_t: bars[0].t + 30 * minute, slope: 3 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; const sourceTimes = Array.from({ length: 4 }, (_, index) => bars[0].t + index * 30 * minute); c.setDisplayFutureTimes(Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 30 * minute } }, }); c.syncLevels([line]); const previousTime = bars.at(-2).t; const currentTime = bars.at(-1).t; const futureTime = currentTime + minute; const point = time => ({ x: c.chart.timeScale().timeToCoordinate(time), y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))), }); const previous = point(previousTime); const current = point(currentTime); const future = point(futureTime); return { historicalSlope: (current.y - previous.y) / (current.x - previous.x), futureSlope: (future.y - current.y) / (future.x - current.x), historicalSlots: (current.x - previous.x) / (future.x - current.x), missingSlotOwned: c.futureSpace.data() .some(point => Number(point.time) === previousTime + minute), overlayDelta: Math.abs( c.coordinateAtTime(futureTime) - c.chart.timeScale().timeToCoordinate(futureTime), ), }; }); assert.ok(Math.abs(result.historicalSlots - 9) < 0.01, `a nine-minute data gap occupied ${result.historicalSlots} slots`); assert.equal(result.missingSlotOwned, true, 'the invisible scale series discarded the missing intraday timestamp'); assert.ok(result.overlayDelta < 0.01, `future overlay missed the chart coordinate by ${result.overlayDelta}px`); assert.ok(Math.abs(result.historicalSlope - result.futureSlope) < 1e-9, `line kinked from ${result.historicalSlope} to ${result.futureSlope}`); assertNoPageErrors(page, assert); }); }); test('a 30m line stays straight on 1m when the bucket is missing its first minutes', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const minute = 60; const half = 30 * minute; const open = 400000; const bars = []; for (let t = open - 2 * half; t < open; t += minute) { bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } for (let t = open + 9 * minute; t < open + 2 * half; t += minute) { bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } const line = { id: 'late-open', kind: 'manual', tf: '30m', side: 'support', label: 'late', anchor_t: open - 2 * half, anchor_p: 100, last_t: open, slope: 3 / half, cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; c.setDisplayFutureTimes(Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: [open - 2 * half, open - half, open, open + half], duration: half } }, }); c.syncLevels([line]); const data = c.levelSeries.get(line.id).series.data(); const point = time => { const row = data.find(value => Number(value.time) === time); return { x: c.chart.timeScale().timeToCoordinate(time), y: row ? c.candles.priceToCoordinate(row.value) : null, }; }; const a = point(open - half); const b = point(open + 9 * minute); const d = point(open + half); return { snappedOpen: data.some(value => Number(value.time) === open + 9 * minute), injectedOpen: data.some(value => Number(value.time) === open), lateSlope: (b.y - a.y) / (b.x - a.x), fullSlope: (d.y - b.y) / (d.x - b.x), }; }); assert.equal(result.injectedOpen, false, '30m open that is not a 1m bar was placed on the scale'); assert.equal(result.snappedOpen, true, 'missing 30m open was not snapped to the first 1m bar'); assert.ok(Math.abs(result.lateSlope - result.fullSlope) < 1e-6, `late-open 30m segment kinked from ${result.lateSlope} to ${result.fullSlope}`); assertNoPageErrors(page, assert); }); }); test('a 1m line stays straight across a slotted mid-session hole', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const minute = 60; const start = 500000; const bars = []; for (let t = start; t < start + 20 * minute; t += minute) { bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } for (let t = start + 29 * minute; t < start + 50 * minute; t += minute) { bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } const line = { id: '1m-hole', kind: 'manual', tf: '1m', side: 'support', label: 'hole', anchor_t: start, anchor_p: 100, last_t: start + 10 * minute, slope: 1 / minute, cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; c.setDisplayFutureTimes(Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '1m': { times: bars.map(bar => bar.t), duration: minute } }, }); c.syncLevels([line]); const data = c.levelSeries.get(line.id).series.data(); const point = time => { const row = data.find(value => Number(value.time) === time); return { x: c.chart.timeScale().timeToCoordinate(time), y: row ? c.candles.priceToCoordinate(row.value) : null, }; }; const a = point(start + 18 * minute); const b = point(start + 19 * minute); const d = point(start + 29 * minute); const e = point(start + 30 * minute); return { before: (b.y - a.y) / (b.x - a.x), across: (d.y - b.y) / (d.x - b.x), after: (e.y - d.y) / (e.x - d.x), filled: ConfluenceChart.sourceTimes(c.trendlineSourceSeries.get('1m')) .includes(start + 20 * minute), }; }); assert.equal(result.filled, true, 'short 1m hole was not inserted into source times'); assert.ok(Math.abs(result.across - result.before) < 1e-6, `1m line kinked across hole from ${result.before} to ${result.across}`); assert.ok(Math.abs(result.after - result.before) < 1e-6, `1m line kinked after hole from ${result.before} to ${result.after}`); assertNoPageErrors(page, assert); }); }); test('painted canvas pixels keep the same slope through the live edge', { timeout: 180000 }, async () => { await withChart(async page => { await page.evaluate(() => { const c = window.__chart; const minute = 60; const bars = Array.from({ length: 20 }, (_, index) => ({ tf: '1m', t: 200000 + index * minute, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); bars.push({ ...bars.at(-1), t: bars.at(-1).t + 9 * minute }); const line = { id: 'painted-tail', kind: 'manual', tf: '30m', side: 'support', label: 'paint', anchor_t: bars[0].t, anchor_p: 99, last_t: bars[0].t + 30 * minute, slope: 2 / (30 * minute), cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 3, provisional: false, }; c.setDisplayFutureTimes(Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute, )); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: Array.from({ length: 4 }, (_, i) => bars[0].t + i * 30 * minute), duration: 30 * minute } }, }); c.syncLevels([line]); }); await page.waitForTimeout(200); const result = await page.evaluate(() => { const c = window.__chart; const line = c.levels[0]; const last = c.bars.length - 1; const futureTime = c.bars[last].t + 60; const point = time => ({ x: c.chart.timeScale().timeToCoordinate(time), y: c.candles.priceToCoordinate(c.linePriceAtIndex(line, c.indexAt(time))), }); const previous = point(c.bars[last - 1].t); const current = point(c.bars[last].t); const future = point(futureTime); return { historical: c.paintedSlopeBetween(previous, current, line.color), future: c.paintedSlopeBetween(current, future, line.color), }; }); assert.notEqual(result.historical, null, 'historical canvas line pixels were not found'); assert.notEqual(result.future, null, 'future canvas line pixels were not found'); assert.ok(Math.abs(result.historical - result.future) < 0.08, `painted line kinked from ${result.historical} to ${result.future}`); assertNoPageErrors(page, assert); }); }); test('a manual line series owns its future 5m slots without an SVG projection', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const step = 5 * 60; const bars = Array.from({ length: 20 }, (_, index) => ({ tf: '5m', t: 100000 + index * step, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); const line = { id: 'handoff-probe', kind: 'manual', tf: '5m', side: 'support', label: 'probe', anchor_t: bars[0].t, anchor_p: 100, last_t: bars[4].t, slope: 1 / (4 * step), cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 2, provisional: false, }; const initialFuture = Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * step, ); c.setDisplayFutureTimes(initialFuture); c.setBars(bars); c.setTrendlineGeometry({ mode: 'source_tf', series: { '5m': { times: bars.map(bar => bar.t), duration: step } }, }); const futureTime = bars.at(-1).t + step; const futureSlotBeforeLine = c.chart.timeScale().timeToCoordinate(futureTime) != null; c.syncLevels([line]); const before = c.levelSeries.get(line.id).series.data(); const futurePoint = before.find(point => Number(point.time) === futureTime); const next = { ...bars.at(-1), t: bars.at(-1).t + step }; const nextFuture = Array.from( { length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => next.t + (index + 1) * step, ); c.setDisplayFutureTimes(nextFuture); c.updateBar(next); const after = c.levelSeries.get(line.id).series.data(); return { futurePoint, expectedFuturePrice: c.linePriceAtIndex(line, c.indexAt(futureTime)), futureSlotBeforeLine, tailTime: Number(after.at(-1).time), expectedTailTime: nextFuture.at(-1), visibleProjection: Boolean(document.querySelector('.chart-projections')), }; }); assert.ok(result.futurePoint, 'the canvas line stopped at the final real candle'); assert.equal(result.futurePoint.value, result.expectedFuturePrice, 'the first canvas future point has the wrong source-space price'); assert.equal(result.futureSlotBeforeLine, true, 'the manual line added a timestamp the future axis does not already own'); assert.equal(result.tailTime, result.expectedTailTime, 'a new bar did not extend the canvas future tail'); assert.equal(result.visibleProjection, false, 'the old SVG projection renderer is still visible'); assertNoPageErrors(page, assert); }); }); test('hidden trendlines cannot steal hits and Fibonacci uses its visible rail', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const step = 300; const bars = Array.from({ length: 30 }, (_, index) => ({ tf: '5m', t: 500000 + index * step, o: 100, h: 102, l: 98, c: 100, v: 1, closed: true, })); c.setDisplayFutureTimes(Array.from({ length: 20 }, (_, index) => bars.at(-1).t + (index + 1) * step)); c.setBars(bars); const hidden = { id: 'hidden-line', kind: 'manual', tf: '5m', side: 'support', label: 'hidden', anchor_t: bars[5].t, anchor_p: 99, last_t: bars[15].t, slope: 1 / 3000, cutoff_t: null, hidden: true, color: '#65b7cf', line_width: 2, provisional: false, }; c.syncLevels([hidden]); const hiddenX = c.coordinateAtTime(bars[20].t); const hiddenP = c.linePriceAtIndex(hidden, c.indexAt(bars[20].t)); const hiddenHit = c.hitTest({ point: { x: hiddenX, y: c.candles.priceToCoordinate(hiddenP) }, time: bars[20].t, }); const fib = { id: 'fib-hit', kind: 'fibonacci', tf: '5m', anchor_t: bars[12].t, anchor_p: 99, last_t: bars[20].t, slope: 2 / (bars[20].t - bars[12].t), hidden: false, color: '#c8992f', line_width: 2, }; c.setFibs([fib]); c.chart.timeScale().setVisibleRange({ from: bars[0].t, to: bars.at(-1).t }); const originX = c.coordinateAtTime(fib.anchor_t); const levelY = c.candles.priceToCoordinate(ConfluenceChart.fibPrices(fib)[3].price); const historicalHit = c.hitTest({ point: { x: originX - 20, y: levelY }, time: bars[10].t }); const visibleHit = c.hitTest({ point: { x: originX + 20, y: levelY }, time: bars[14].t }); c.setSelectedLine(fib.id); const rail = { x1: Number(c.lineHitTarget.getAttribute('x1')), x2: Number(c.lineHitTarget.getAttribute('x2')), y1: Number(c.lineHitTarget.getAttribute('y1')), y2: Number(c.lineHitTarget.getAttribute('y2')), }; const plot = c.plotCanvas().getBoundingClientRect(); const event = { button: 0, clientX: plot.left + Math.min(rail.x1, plot.width - 20), clientY: plot.top + (rail.y1 + rail.y2) / 2, preventDefault() {}, stopPropagation() {}, currentTarget: { setPointerCapture() {} }, pointerId: 1, }; const originalFib = { anchor_t: fib.anchor_t, anchor_p: fib.anchor_p }; c.startLineDrag(event); const dragging = c.draggingLine?.id; c.moveLine({ ...event, clientX: event.clientX - 30, clientY: event.clientY + 20, }); const fibMoved = fib.anchor_t !== originalFib.anchor_t || fib.anchor_p !== originalFib.anchor_p; const movedFlag = c.draggingLine?.moved; c.draggingLine = null; c.detachPointers('line'); return { hiddenHit, historicalHit, visibleHit, rail, dragging, fibMoved, movedFlag }; }); assert.equal(result.hiddenHit, null, 'a hidden trendline stole chart selection'); assert.equal(result.historicalHit, null, 'Fibonacci selected left of its visible origin'); assert.equal(result.visibleHit, 'fib-hit'); assert.equal(result.rail.x1, result.rail.x2, 'Fibonacci body target is not its vertical rail'); assert.equal(result.dragging, 'fib-hit', 'Fibonacci rail did not start a body drag'); assert.equal(result.fibMoved, true, `Fibonacci body drag did not move its anchors: ${JSON.stringify(result)}`); assertNoPageErrors(page, assert); }); }); test('higher-timeframe trendlines are dashed and twice their stored width', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const bars = tf => Array.from({ length: 20 }, (_, index) => ({ tf, t: 300000 + index * (tf === '5m' ? 300 : 1800), o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); const line = { id: 'higher-style', kind: 'manual', tf: '30m', side: 'support', label: 'style', anchor_t: 300000, anchor_p: 99, last_t: 301800, slope: 1 / 1800, cutoff_t: null, hidden: false, color: '#65b7cf', line_width: 3, provisional: false, }; const sourceTimes = bars('30m').map(bar => bar.t); const optionsAt = displayed => { c.setBars(bars(displayed)); c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: 1800 } }, }); c.syncLevels([line]); const options = c.levelSeries.get(line.id).series.options(); return { width: options.lineWidth, style: options.lineStyle }; }; const lower = optionsAt('5m'); const native = optionsAt('30m'); return { lower, native, dashed: LightweightCharts.LineStyle.Dashed, solid: LightweightCharts.LineStyle.Solid, }; }); assert.deepEqual(result.lower, { width: 6, style: result.dashed }); assert.deepEqual(result.native, { width: 3, style: result.solid }); assertNoPageErrors(page, assert); }); }); test('a lower-timeframe trendline is hidden from the daily chart', { timeout: 180000 }, async () => { await withChart(async page => { const created = await page.evaluate(async () => { const bars = (await (await fetch('/api/bars?tf=30m&limit=2')).json()).bars; const first = bars[0]; const second = bars[1]; const response = await fetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tf: '30m', side: 'support', anchor_t: first.t, anchor_p: first.l, end_t: second.t, end_p: first.l + 1, note: `daily filter ${Date.now()}`, }), }); return response.json(); }); await page.waitForFunction(id => window.__chart.levelSeries.has(id), created.id); await page.click('.timeframes button:text-is("1d")'); await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d'); await page.waitForFunction(id => !window.__chart.levelSeries.has(id), created.id); const state = await page.evaluate(async id => ({ drawingStillStored: (await (await fetch('/api/drawings')).json()).drawings .some(drawing => drawing.id === id), flatLevelVisible: ConfluenceChart.drawingVisibleOn( { kind: 'manual', slope: 0, tf: '30m' }, '1d', ), }), created.id); assert.equal(state.drawingStillStored, true, 'daily filtering deleted the drawing state'); assert.equal(state.flatLevelVisible, false, 'a lower-TF flat level bypassed the general rule'); await page.locator('.config-section summary').click(); await page.locator('.config-section label:has-text("Hide lower-TF drawings") input').uncheck(); await page.waitForFunction(id => window.__chart.levelSeries.has(id), created.id); assertNoPageErrors(page, assert); }); }); test('a click, a move, then a click starts at the first click', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.30, 0.50); const second = at(box, 0.55, 0.35); await page.mouse.move(first.x, first.y); await page.mouse.down(); await page.mouse.up(); await page.waitForTimeout(400); await page.mouse.move(second.x, second.y); // A few pixels of travel while pressing: this is a click, not a drag. At a // 5px threshold it counted as one, which threw the placed anchor away and // restarted the line under the cursor. await page.mouse.down(); await page.mouse.move(second.x + 4, second.y + 3); await page.mouse.up(); await page.waitForTimeout(1500); const line = await newestTrendline(page); assert.ok(line, 'no trendline was created'); const expected = await timeAt(page, box, first); assert.ok( Math.abs(line.anchor_t - expected) < 400, `anchored at ${line.anchor_t}, expected the first click near ${expected}`, ); assert.equal(line.onExtreme, true, `anchor ${line.anchor_p} is not a bar extreme`); assertNoPageErrors(page, assert); }); }); test('a real drag ignores an anchor left over from an abandoned click', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const abandoned = at(box, 0.20, 0.70); const from = at(box, 0.62, 0.30); const to = at(box, 0.80, 0.55); await page.mouse.move(abandoned.x, abandoned.y); await page.mouse.down(); await page.mouse.up(); await page.waitForTimeout(400); await page.mouse.move(from.x, from.y); await page.mouse.down(); await page.mouse.move(to.x, to.y, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(1500); const line = await newestTrendline(page); assert.ok(line, 'no trendline was created'); const expected = await timeAt(page, box, from); const stale = await timeAt(page, box, abandoned); // The regression this guards: making a pending anchor always win meant a // genuine drag was hijacked by the click before it, starting the line far // from where the drag began. It shipped to production. assert.ok( Math.abs(line.anchor_t - stale) > 400, 'the line started at the abandoned click instead of the drag', ); assert.ok( Math.abs(line.anchor_t - expected) < 600, `anchored at ${line.anchor_t}, expected the drag start near ${expected}`, ); assertNoPageErrors(page, assert); }); }); test('Escape cancels a pending trendline anchor and disarms the tool', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); const before = await page.evaluate(() => window.__chart.levels.length); await armTool(page, 'Trendline'); const remembered = await page.evaluate(() => { const latest = window.__chart.levels .filter(level => level.kind === 'manual' && level.slope !== 0) .sort((a, b) => (b.number || 0) - (a.number || 0))[0]; const expected = latest?.color || '#80D8D8'; const probe = document.createElement('span'); probe.style.color = expected; document.body.appendChild(probe); const expectedRgb = getComputedStyle(probe).color; probe.remove(); return { expectedRgb, actualRgb: getComputedStyle(document.querySelector('.tool.armed .color-picker>summary')).backgroundColor, }; }); assert.equal(remembered.actualRgb, remembered.expectedRgb, 'the Trendline tool did not remember the newest trendline color'); assert.ok((await page.locator('.tool.armed .color-picker>summary').boundingBox()).width >= 55, 'the Trendline color preview is still the tiny drawing-list size'); assert.equal(await page.locator('.tool.armed').evaluate(node => getComputedStyle(node).overflow), 'visible', 'the Trendline tool clips its color palette'); assert.equal(await page.locator('.tool.armed .color-family-swatches>button').count(), 24, 'the Trendline tool does not use the shared color palette'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForFunction(() => window.__chart.pendingAnchor != null); await page.locator('.tool.armed .color-picker>summary').click(); assert.equal(await page.locator('.tool.armed .color-picker').getAttribute('open'), ''); await page.keyboard.press('Escape'); const state = await page.evaluate(() => ({ armed: window.__chart.armedTool, pending: window.__chart.pendingAnchor, previewHidden: document.querySelector('.chart-preview line').hasAttribute('hidden'), paletteOpen: document.querySelector('.tool .color-picker[open]') != null, })); assert.equal(state.armed, null); assert.equal(state.pending, null); assert.equal(state.previewHidden, true); assert.equal(state.paletteOpen, false); await page.mouse.click(second.x, second.y); await page.waitForTimeout(500); assert.equal(await page.evaluate(() => window.__chart.levels.length), before, 'a click after cancellation completed the abandoned line'); assertNoPageErrors(page, assert); }); }); test('Escape cancels every armed drawing tool', { timeout: 180000 }, async () => { await withChart(async page => { for (const name of ['Price level', 'Comment', 'Mark']) { await armTool(page, name); assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`); await page.keyboard.press('Escape'); assert.equal(await page.evaluate(() => window.__chart.armedTool), null, `${name} stayed armed after Escape`); assert.equal(await page.locator('.tool.armed').count(), 0, `${name} kept an armed panel after Escape`); } assertNoPageErrors(page, assert); }); }); test('the snapped extreme decides the side, overriding the dropdown', { timeout: 180000, skip: 'quarantined: fixed viewport fractions are unstable against the moving live feed', }, async () => { await withChart(async page => { const box = await chartBox(page); const dropdownDefault = await page.evaluate(() => { const app = document.querySelector('.tool-head'); return app ? 'support' : 'support'; }); assert.equal(dropdownDefault, 'support'); // Drawn above the candles, so the nearest extreme is a bar high, so the // side must come out as resistance despite the dropdown saying support. await armTool(page, 'Trendline'); const high1 = at(box, 0.40, 0.15); const high2 = at(box, 0.60, 0.15); await page.mouse.move(high1.x, high1.y); await page.mouse.down(); await page.mouse.up(); await page.waitForTimeout(400); await page.mouse.move(high2.x, high2.y); await page.mouse.down(); await page.mouse.up(); await page.waitForTimeout(1500); const line = await newestTrendline(page); assert.ok(line, 'no trendline was created'); assert.equal(line.snappedTo, 'high', `snapped to ${line.snappedTo}, not the bar high`); assert.equal(line.side, 'resistance', 'a high must infer resistance'); assertNoPageErrors(page, assert); }); }); test('dragging an anchor uses the same bar-extreme snapping as placement', { timeout: 180000, skip: 'quarantined: fixed bar selection races the moving live feed during the drag', }, async () => { await withChart(async page => { const beforeNumber = await page.evaluate(() => Math.max(0, ...window.__chart.levels .filter(level => level.kind === 'manual').map(level => level.number))); const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.30, 0.55); const second = at(box, 0.48, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForFunction(number => { const c = window.__chart; const selected = c.levels.find(level => level.id === c.selectedLineId); return selected?.number > number && document.querySelectorAll('.chart-anchor:not([hidden])').length === 2; }, beforeNumber); const target = await page.evaluate(() => { const c = window.__chart; const bar = c.bars[c.bars.length - 35]; const plot = c.plotCanvas().getBoundingClientRect(); return { id: c.selectedLineId, t: bar.t, p: bar.l, x: plot.left + c.chart.timeScale().timeToCoordinate(bar.t), y: plot.top + c.candles.priceToCoordinate(bar.l), }; }); const handle = await page.locator('.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox(); assert.ok(handle, 'the selected trendline has no end handle'); await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2); await page.mouse.down(); await page.mouse.move(target.x, target.y, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(id => { const line = window.__chart.levels.find(level => level.id === id); return line && { t: line.last_t, p: line.anchor_p + line.slope * (line.last_t - line.anchor_t), }; }, target.id); assert.equal(moved.t, target.t, 'the handle moved to a different bar'); assert.ok(Math.abs(moved.p - target.p) < 1e-6, `the handle landed at ${moved.p}, not the bar low ${target.p}`); assertNoPageErrors(page, assert); }); }); test('future whitespace accepts endpoint and whole-line movement without entering the time scale', { timeout: 180000, skip: 'quarantined: selected line can disappear from shared levelSeries during the test', }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); await page.evaluate(() => { const scale = window.__chart.chart.timeScale(); const range = scale.getVisibleLogicalRange(); scale.setVisibleLogicalRange({ from: range.from + 20, to: range.to + 20 }); }); await page.waitForTimeout(400); const future = await page.evaluate(() => { const c = window.__chart; const last = c.bars.length - 1; const index = last + 10; const line = c.levels.find(level => level.id === c.selectedLineId); const plot = c.plotCanvas().getBoundingClientRect(); return { id: line.id, x: plot.left + c.coordinateAtIndex(index), y: plot.top + plot.height * 0.45, }; }); const endHandle = await page.locator( '.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox(); await page.mouse.move(endHandle.x + endHandle.width / 2, endHandle.y + endHandle.height / 2); await page.mouse.down(); await page.mouse.move(future.x, future.y, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const projected = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); const data = c.levelSeries.get(id).series.data(); return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), lastBarIndex: c.bars.length - 1, endpointInSeries: data.some(point => Number(point.time) === line.last_t), endpointCoordinate: c.chart.timeScale().timeToCoordinate(line.last_t), }; }, future.id); assert.ok(projected.endIndex > projected.lastBarIndex, 'the endpoint was clamped to the newest candle'); assert.equal(projected.endpointInSeries, true, 'the active line omitted its future endpoint from the canvas series'); assert.notEqual(projected.endpointCoordinate, null, 'the future endpoint is absent from the shared future time scale'); const drag = await page.evaluate(() => { const c = window.__chart; const hit = c.lineHitTarget; const plot = c.plotCanvas().getBoundingClientRect(); const x1 = Number(hit.getAttribute('x1')); const y1 = Number(hit.getAttribute('y1')); const x2 = Number(hit.getAttribute('x2')); const y2 = Number(hit.getAttribute('y2')); const spacing = c.coordinateAtIndex(c.bars.length - 1) - c.coordinateAtIndex(c.bars.length - 2); return { x: plot.left + x1 + (x2 - x1) * 0.65, y: plot.top + y1 + (y2 - y1) * 0.65, dx: spacing * 3, }; }); await page.mouse.move(drag.x, drag.y); await page.mouse.down(); await page.mouse.move(drag.x + drag.dx, drag.y + 20, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t) }; }, future.id); assert.equal( Math.round(moved.endIndex - projected.endIndex), Math.round(moved.anchorIndex - projected.anchorIndex), 'the future endpoint did not translate with the rest of the line', ); assertNoPageErrors(page, assert); }); }); test('the line context menu duplicates by ten bars and deletes through the normal flow', { timeout: 180000, skip: 'quarantined: shared-state selection can leave the context menu unbound', }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); const original = await page.evaluate(() => { const lines = window.__chart.levels.filter(level => level.kind === 'manual' && level.slope !== 0); return lines.sort((a, b) => b.number - a.number)[0]; }); assert.ok(original, 'no trendline was available to duplicate'); await page.mouse.click(box.x + box.w * 0.60, box.y + box.h * 0.45, { button: 'right' }); await page.keyboard.press('Escape'); assert.equal(await page.locator('.chart-context-menu').isVisible(), false, 'Escape did not close the trendline context menu'); await page.mouse.click(box.x + box.w * 0.60, box.y + box.h * 0.45, { button: 'right' }); await page.locator('.chart-context-menu [data-action="duplicate"]').click(); await page.waitForFunction(number => { const c = window.__chart; const lines = c.levels.filter(level => level.kind === 'manual' && level.slope !== 0); const duplicate = lines.find(level => level.number > number); return duplicate && c.selectedLineId === duplicate.id; }, original.number); const duplicated = await page.evaluate(originalLine => { const c = window.__chart; const line = c.levels.filter(level => level.kind === 'manual' && level.number > originalLine.number) .sort((a, b) => b.number - a.number)[0]; const original = c.levels.find(level => level.id === originalLine.id); // Socket snapshots and style edits resync every series. That resync // must not restore full names to unselected price-axis labels. c.syncLevels(c.levels); const nearestIndex = time => c.bars.reduce((nearest, bar, index) => Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0); return { line, anchorShift: nearestIndex(line.anchor_t) - nearestIndex(original.anchor_t), endShift: nearestIndex(line.last_t) - nearestIndex(original.last_t), originalTitle: c.levelSeries.get(original.id).series.options().title, duplicateTitle: c.levelSeries.get(line.id).series.options().title, }; }, original); assert.equal(duplicated.anchorShift, 10); assert.equal(duplicated.endShift, 10); const expectedName = `${original.side === 'support' ? 'up' : 'down'}${original.tf}`; assert.equal(original.label, expectedName); assert.equal(duplicated.line.label, `${expectedName} 2`); assert.equal(duplicated.originalTitle, `#${original.number}`); assert.equal(duplicated.duplicateTitle, duplicated.line.label); assert.equal(duplicated.line.color, original.color); assert.equal(duplicated.line.line_width, original.line_width); await page.mouse.click(box.x + box.w * 0.65, box.y + box.h * 0.45, { button: 'right' }); await page.locator('.chart-context-menu [data-action="delete"]').click(); await page.waitForFunction(id => !window.__chart.levels.some(level => level.id === id), duplicated.line.id); assertNoPageErrors(page, assert); }); }); test('dragging the selected line body translates both endpoints together', { timeout: 180000, skip: 'quarantined: live chart geometry can lose the body-drag displacement', }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); const original = await page.evaluate(() => { const c = window.__chart; const line = c.levels.find(level => level.id === c.selectedLineId); const hit = c.lineHitTarget; const plot = c.plotCanvas().getBoundingClientRect(); return { line: { ...line }, endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t), anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), drag: { x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2, y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2, }, }; }); await page.mouse.move(original.drag.x, original.drag.y); await page.mouse.down(); await page.mouse.move(original.drag.x + 55, original.drag.y + 35, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(originalLine => { const c = window.__chart; const line = c.levels.find(level => level.id === originalLine.id); return { line, endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t), anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), }; }, original.line); const anchorShift = Math.round(moved.anchorIndex - original.anchorIndex); const endShift = Math.round(moved.endIndex - original.endIndex); const anchorPriceShift = moved.line.anchor_p - original.line.anchor_p; const endPriceShift = moved.endP - original.endP; assert.notEqual(anchorShift, 0, 'the line did not move horizontally'); assert.equal(endShift, anchorShift, 'the endpoints moved by different bar counts'); assert.ok(Math.abs(anchorPriceShift) >= 0.25, 'the line did not move vertically'); assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6, 'the endpoints moved by different price amounts'); assert.equal(await page.evaluate(() => window.__chart.selectedLineId), moved.line.id, 'body-drag reconciliation cleared the selected line'); await page.keyboard.press('ArrowUp'); await page.waitForTimeout(1200); const afterVertical = await page.evaluate(id => { const c = window.__chart; return c.levels.find(level => level.id === id)?.anchor_p; }, moved.line.id); assert.equal(afterVertical, moved.line.anchor_p + 0.25, 'ArrowUp did not nudge the selected trendline'); const beforeHorizontal = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { id, p: line.anchor_p, index: c.indexAt(line.anchor_t) }; }, moved.line.id); await page.keyboard.press('Shift+ArrowRight'); await page.waitForFunction(expected => { const c = window.__chart; const line = c.levels.find(level => level.id === expected.id); return line?.anchor_p === expected.p && Math.round(c.indexAt(line.anchor_t) - expected.index) === 4; }, beforeHorizontal); const nudged = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { anchor_t: line.anchor_t, anchor_p: line.anchor_p }; }, moved.line.id); await page.waitForFunction(async expected => { const response = await fetch('/api/drawings'); const drawing = (await response.json()).drawings.find(item => item.id === expected.id); return drawing?.anchor_t === expected.anchor_t && drawing?.anchor_p === expected.anchor_p; }, { id: moved.line.id, ...nudged }); assertNoPageErrors(page, assert); }); }); test('hovering shows where the anchor will land', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const point = at(box, 0.45, 0.40); await page.mouse.move(point.x, point.y); await page.waitForTimeout(500); // Without this the only feedback was the crosshair, which magnets to the // bar's close and so pointed somewhere the anchor would never go. const dot = await page.evaluate(() => { const node = document.querySelector('.chart-snap-dot'); return node ? { shown: !node.hidden, side: node.dataset.side || null } : null; }); assert.ok(dot && dot.shown, 'no snap indicator while a tool is armed'); assert.ok(['support', 'resistance'].includes(dot.side), `snap dot side was ${dot?.side}`); assertNoPageErrors(page, assert); }); }); test('the snap dot is painted under the cursor in page coordinates', { timeout: 180000 }, async () => { await withChart(async page => { await armTool(page, 'Trendline'); const probe = async () => { const target = await page.evaluate(() => { const c = window.__chart; const bar = c.bars[Math.max(0, c.bars.length - 80)]; const chartRect = c.chartEl.getBoundingClientRect(); const plotX = c.chart.timeScale().timeToCoordinate(bar.t); return { x: chartRect.left + c.plotOffsetX() + plotX, y: chartRect.top + c.candles.priceToCoordinate(bar.l) - 6, offset: c.plotOffsetX(), }; }); await page.mouse.move(target.x, target.y); await page.waitForTimeout(350); const dotX = await page.evaluate(() => { const dot = document.querySelector('.chart-snap-dot'); if (!dot || dot.hidden) return null; const rect = dot.getBoundingClientRect(); return rect.left + rect.width / 2; }); assert.notEqual(dotX, null, 'the snap dot was not painted'); assert.ok(Math.abs(dotX - target.x) <= 2, `snap dot was ${(dotX - target.x).toFixed(1)}px from the cursor`); return target.offset; }; await probe(); assertNoPageErrors(page, assert); }); }); test('sweeping the bottom traces the low of each bar under the cursor', { timeout: 180000 }, async () => { await withChart(async page => { await page.click('.timeframes button:text-is("1h")'); await page.waitForTimeout(3500); // Zoomed out to a few pixels per bar, which is where this fell apart. await page.evaluate(() => { const c = window.__chart, ts = c.chart.timeScale(); ts.setVisibleRange({ from: c.bars[Math.max(0, c.bars.length - 360)].t, to: c.bars[c.bars.length - 1].t, }); }); await page.waitForTimeout(1500); // The rule, stated once: x picks the bar, y picks which extreme. Running // the cursor along the bottom must therefore trace each bar's low. A // nearest-in-2D search was tried instead and broke exactly this — the // lowest low nearby won on total distance and the dot skipped off the bar // under the cursor. const result = await page.evaluate(() => { const c = window.__chart, ts = c.chart.timeScale(); const y = document.querySelector('#chart').clientHeight * 0.93; // x values below are plot-relative, matching what snapPoint expects. let hits = 0, total = 0; const misses = []; for (let x = 200; x < 1000; x += 7) { const t = ts.coordinateToTime(x); if (t == null) continue; const under = c.bars.reduce( (best, bar) => (Math.abs(bar.t - Number(t)) < Math.abs(best.t - Number(t)) ? bar : best)); const snapped = c.snapPoint({ x, y, p: c.candles.coordinateToPrice(y), t: Number(t) }); total += 1; if (snapped.t === under.t && snapped.p === under.l) hits += 1; else if (misses.length < 3) misses.push({ x, wanted: under.l, got: snapped.p }); } return { hits, total, misses }; }); assert.ok(result.total > 50, 'the sweep did not probe enough positions'); assert.equal(result.hits, result.total, `only ${result.hits}/${result.total} landed on the low of the bar under the cursor: ` + JSON.stringify(result.misses)); assertNoPageErrors(page, assert); }); }); test('the crosshair never magnets to the close', { timeout: 180000 }, async () => { await withChart(async page => { // Magnet mode snaps the crosshair to the bar's close, so hovering beside a // low reads a price several ticks from the one that would be used. const mode = await page.evaluate(() => window.__chart.chart.options().crosshair.mode); assert.equal(mode, 0, 'crosshair is not in Normal mode'); }); }); test('the snap never leaps to the live edge when the cursor leaves the plot', { timeout: 180000 }, async () => { await withChart(async page => { await armTool(page, 'Trendline'); const box = await chartBox(page); const dotState = async () => page.evaluate(() => { const dot = document.querySelector('.chart-snap-dot'); if (!dot || dot.hidden) return { hidden: true }; const chart = document.querySelector('#chart'); const left = dot.getBoundingClientRect().left - chart.getBoundingClientRect().left; const c = window.__chart; const lastX = c.chart.timeScale().timeToCoordinate(c.bars[c.bars.length - 1].t); return { hidden: false, atLiveEdge: Math.abs(left - lastX) < 6 }; }); // Reported from a real session: coordinateToTime answers null over the // price axis, the whitespace past the last bar, and anywhere outside the // chart — and the fallback was "the newest bar", which threw the anchor // to the live edge from wherever the cursor actually was. await page.mouse.move(box.x + box.w * 0.5, box.y + box.h * 0.4); await page.waitForTimeout(400); const onBar = await dotState(); assert.equal(onBar.hidden, false, 'no snap indicator over a bar'); assert.equal(onBar.atLiveEdge, false, 'snapped to the live edge from mid-chart'); // The listener is on window, so it sees the sidebar too. await page.mouse.move(box.x + box.w + 120, box.y + box.h * 0.4); await page.waitForTimeout(400); assert.equal((await dotState()).hidden, true, 'the dot survived leaving the chart'); await page.mouse.move(box.x + box.w * 0.35, box.y + box.h * 0.5); await page.waitForTimeout(400); const back = await dotState(); assert.equal(back.hidden, false, 'the dot did not come back'); assert.equal(back.atLiveEdge, false, 'snapped to the live edge after returning'); assertNoPageErrors(page, assert); }); }); test('the right price axis is outside the snapping area', { timeout: 180000 }, async () => { await withChart(async page => { await armTool(page, 'Trendline'); const regions = await page.evaluate(() => { const c = window.__chart; const chart = c.chartEl.getBoundingClientRect(); const plot = c.plotCanvas().getBoundingClientRect(); return { plot: { x: plot.left + plot.width * 0.65, y: plot.top + plot.height * 0.45 }, axis: { x: plot.right + (chart.right - plot.right) / 2, y: plot.top + plot.height * 0.45 }, axisWidth: chart.right - plot.right, }; }); assert.ok(regions.axisWidth > 10, `right price axis is only ${regions.axisWidth}px wide`); const dotHidden = () => page.evaluate(() => document.querySelector('.chart-snap-dot')?.hidden); await page.mouse.move(regions.plot.x, regions.plot.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), false, 'snap dot did not appear over the plot'); await page.mouse.move(regions.axis.x, regions.axis.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), true, 'snap dot remained visible over the right price axis'); await page.mouse.move(regions.plot.x, regions.plot.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), false, 'snap dot did not return after leaving the price axis'); assertNoPageErrors(page, assert); }); });