chart/tests/e2e/trendline.test.mjs
2026-08-26 02:27:32 -05:00

1342 lines
60 KiB
JavaScript

/**
* 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('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);
});
});