chart/tests/e2e/trendline.test.mjs
Chris Amow ab372832db Snap by x for the bar and y for the extreme, and stop the crosshair magnetting
Sweeping the cursor along the bottom of the chart rarely landed on a bar's low,
and often nowhere near the bar at all. The cause was a nearest-in-2D search I
added an hour earlier to make bars easier to hit when zoomed out: whichever bar
nearby had the lowest low won on total distance, so the dot skipped off the bar
under the cursor instead of tracing each low in turn. Reverted.

The rule is now written down rather than adjusted per complaint — x picks the
bar, y picks which of its extremes — and an e2e test asserts it by sweeping a
zoomed-out 1h chart and requiring every position to land on the low of the bar
beneath it. 115 of 115.

The crosshair is the other half of why this felt broken. Lightweight Charts
defaults to Magnet, which snaps it to the bar's close, so hovering beside a low
displayed a price several ticks from the one an anchor would use. Arming a tool
already switched to Normal, but the chart is read before a tool is armed, which
is when the misleading reading was being taken. Normal everywhere now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 21:35:52 -05:00

180 lines
7.8 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 x, for comparing against where a line actually anchored. */
const timeAt = (page, box, point) =>
page.evaluate(x => Number(window.__chart.chart.timeScale().coordinateToTime(x)), point.x - box.x);
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('the snapped extreme decides the side, overriding the dropdown', { timeout: 180000 }, 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('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('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;
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');
});
});