The trendline snap indicator was 66 pixels out, and so was every other overlay. Lightweight Charts reports coordinates from the plot area's origin. The chart element also contains the price scales, so enabling the left scale for the daily labels moved the plot 66px into the element — and each overlay positioned with left: against the element inherited that error twice over. The cursor's element-x was read as a plot-x, resolving a bar about 66px right of the pointer; the indicator was then drawn at that bar's plot-x interpreted as element-x, landing 66px left of where the bar is painted. Neither near the cursor nor near the bar, and scaling with zoom — two bars at 30m, a dozen at 1m — which is why it read as random rather than as an offset. Every diagnostic number agreed with itself the whole time, because dot_y, expected_y and bar_low_y all come from the same API and shared the same wrong origin. Instrumentation cannot see a systematic error in its own frame of reference; what found this was comparing canvas.width to element.clientWidth and getting 0.894. Overlays now live in one container positioned over the plot canvas and inherit plot coordinates untranslated — snap dot and label, comments, anchor handles, preview line, tooltip, price tag, context menu — and eventPoint subtracts the same offset so a pointer position and a chart coordinate mean the same thing. The container follows the plot on resize. Verified in page pixels rather than through the coordinate API: with the cursor placed 30px below a known bar's low, the dot lands 30px above the cursor, on that low, labelled 7789.00 L against a bar low of 7789. Device pixel ratio, viewport size, resize desynchronisation and the chart scrolling under the gesture were each measured and ruled out before this. The e2e helper computed expected times from element-relative x, the same mistake in the tests, and is corrected here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
229 lines
10 KiB
JavaScript
229 lines
10 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 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;
|
|
// 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);
|
|
});
|
|
});
|