Snapping took the bar sharing the cursor's time and then its nearer extreme, which ignored how far away that extreme was. Pointing below a candle snapped to that candle's low however distant, while the extreme genuinely under the cursor was never considered. Zoomed out to some 360 bars at three pixels apart, that made hitting the bar you meant a matter of several tries. snapPoint now scans six bars either side and takes the extreme nearest in pixels. Proven by probe: with the cursor sitting exactly on one bar's low but nudged two pixels so coordinateToTime resolves to its neighbour, the snap takes the extreme under the cursor rather than the neighbour's. A report of the snap dot appearing "way above the bar" turned out to be the dot landing correctly on the low while the cursor was 151 points below it: the right price scale keeps a bottom margin of 0.1 and the volume overlay is drawn in it, so the lower fifth of the pane sits below every candle. bin/e2e runs tests/e2e against the dev stack inside the playwright service — Node's own test runner, no dependency added here, since Playwright is global in that container. Eleven cases, each one a bug that shipped: the viewport parked ten hours in the past, hourly candles drawn as slivers, stale bar events throwing, comments drifting across a timeframe switch, and three ways a trendline anchor could disagree with its preview. Not one was reachable from pytest. Tests delete any drawing they create, because the dev store is shared with whoever is looking at the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
164 lines
7.1 KiB
JavaScript
164 lines
7.1 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('snapping picks the extreme nearest on screen, not the bar under the cursor time',
|
|
{ timeout: 180000 }, async () => {
|
|
await withChart(async page => {
|
|
await page.click('.timeframes button:text-is("1h")');
|
|
await page.waitForTimeout(3500);
|
|
// Zoomed out, bars are a few pixels apart and the intended one is hard to
|
|
// hit. Snapping used to take whichever bar shared the cursor's *time* and
|
|
// then its nearer extreme, ignoring how far away that was.
|
|
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);
|
|
|
|
const result = await page.evaluate(() => {
|
|
const c = window.__chart, ts = c.chart.timeScale();
|
|
const target = c.bars[c.bars.length - 150];
|
|
const x = ts.timeToCoordinate(target.t);
|
|
const y = c.candles.priceToCoordinate(target.l);
|
|
// Sit exactly on the target's low but nudged so the cursor's time
|
|
// resolves to its neighbour.
|
|
const point = { x: x - 2, y, p: c.candles.coordinateToPrice(y), t: Number(ts.coordinateToTime(x - 2)) };
|
|
const snapped = c.snapPoint(point);
|
|
return {
|
|
resolvedToNeighbour: point.t !== target.t,
|
|
snappedToTarget: snapped.t === target.t && snapped.p === target.l,
|
|
};
|
|
});
|
|
assert.equal(result.resolvedToNeighbour, true, 'the probe did not straddle two bars');
|
|
assert.equal(result.snappedToTarget, true,
|
|
'snapped to the bar sharing the cursor time rather than the extreme under the cursor');
|
|
assertNoPageErrors(page, assert);
|
|
});
|
|
});
|