Diagnostic mode found in one hover what four local hypotheses could not.
coordinateToTime answers null over the right-hand price axis, over the
whitespace past the last bar, and anywhere outside the chart. snapPoint read
that as "the newest bar":
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
so the indicator jumped to the live edge from wherever the cursor actually was —
hundreds of points away, on a bar the user was nowhere near. It matches the
screenshot exactly: the crosshair read 22:42 while the snap label read 22:58,
which was the last bar.
Two faults behind it. The tool's pointer listener is on window, so it processes
moves over the sidebar — a real sample reported x=1409 on a chart 1280 wide. And
a null time meant a default instead of no answer.
A pointer outside the plot now hides the indicator, and a null time resolves to
the bar nearest in pixels rather than the newest. An e2e test hovers a bar, the
price axis, the sidebar and back, asserting the indicator never sits at the live
edge from mid-chart and disappears once the cursor leaves.
Worth recording that device pixel ratio, viewport size, resize desynchronisation
and the chart scrolling under the gesture were each measured and ruled out
before this, none of which was the cause. With the browser on another machine,
instrumenting it should have come first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
9.8 KiB
JavaScript
219 lines
9.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');
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|