Snap to the nearest extreme on screen, and add an e2e suite
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>
This commit is contained in:
parent
0e32c0bbab
commit
501afb7792
9 changed files with 610 additions and 12 deletions
40
bin/e2e
Executable file
40
bin/e2e
Executable file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env bash
|
||||
# Run the end-to-end tests against the local dev stack.
|
||||
#
|
||||
# bin/e2e # every test
|
||||
# bin/e2e trendline # only files matching a name
|
||||
#
|
||||
# They run inside the `playwright` compose service, which already has
|
||||
# Playwright and a browser. Nothing is installed into this repo — NODE_PATH
|
||||
# points Node at the container's global modules, and tests/e2e resolves it
|
||||
# through createRequire because ESM ignores NODE_PATH.
|
||||
#
|
||||
# These drive the real app against the real feed. They create drawings and
|
||||
# delete them again afterwards; the dev stack shares one drawing store with
|
||||
# whoever else is looking at it.
|
||||
set -uo pipefail
|
||||
|
||||
FILTER=${1:-}
|
||||
COMPOSE=${COMPOSE:-docker compose}
|
||||
|
||||
if ! $COMPOSE ps --status running --services 2>/dev/null | grep -qx playwright; then
|
||||
echo "the playwright service is not running — start it with: $COMPOSE up -d" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "$FILTER" ]; then
|
||||
FILES=$(ls tests/e2e/*"$FILTER"*.test.mjs 2>/dev/null)
|
||||
if [ -z "$FILES" ]; then
|
||||
echo "no e2e test files match '$FILTER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
FILES=$(ls tests/e2e/*.test.mjs)
|
||||
fi
|
||||
|
||||
# The app is reachable as http://api:8000 from inside the compose network.
|
||||
# shellcheck disable=SC2086
|
||||
$COMPOSE exec -T \
|
||||
-e NODE_PATH=/usr/lib/node_modules \
|
||||
-e E2E_URL="${E2E_URL:-http://api:8000/}" \
|
||||
playwright node --test --test-concurrency=1 $(echo $FILES | sed 's#tests/e2e/#/repo/tests/e2e/#g')
|
||||
|
|
@ -25,3 +25,5 @@ services:
|
|||
- LANG=en_US.UTF-8
|
||||
volumes:
|
||||
- ./artifacts/playwright:/artifacts
|
||||
# The e2e suite runs in here; the app is http://api:8000 on this network.
|
||||
- ./tests/e2e:/repo/tests/e2e:ro
|
||||
|
|
|
|||
|
|
@ -1545,3 +1545,36 @@ overwrite a settled one.
|
|||
Both were introduced by the tick feature and both are covered by tests: a zero
|
||||
price parses as a trade with no price, a late closed bar replaces its bucket and
|
||||
keeps the exchange's volume, and a tick cannot overwrite a settled bar.
|
||||
|
||||
**Snapping now measures distance on screen, not in time.** The rule was "take
|
||||
the bar sharing the cursor's time, then its nearer extreme", which ignored how
|
||||
far that extreme actually was. Pointing anywhere below a candle snapped to that
|
||||
candle's low however distant, and the extreme genuinely under the cursor was
|
||||
never considered — so zoomed out to ~360 bars at three pixels each, hitting the
|
||||
intended bar took several attempts. `snapPoint` now scans six bars either side
|
||||
and picks the extreme nearest in pixels. Proven by probe: with the cursor 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.
|
||||
|
||||
Worth recording because it was misdiagnosed twice: a report of "the snap dot
|
||||
appears way above the bar" was, on the numbers, the dot landing correctly on the
|
||||
bar's low while the cursor sat 151 points below it. The right price scale keeps
|
||||
a `bottom: 0.1` margin and the volume overlay is drawn in it, so the lower fifth
|
||||
of the pane is below every candle — an inviting place to point that contains no
|
||||
price action at all.
|
||||
|
||||
### e2e tests
|
||||
|
||||
`bin/e2e` runs `tests/e2e/*.test.mjs` inside the playwright service against the
|
||||
dev stack. Node's built-in test runner, no dependencies added to this repo:
|
||||
Playwright is global in that container and `tests/e2e` is mounted at
|
||||
`/repo/tests/e2e`. Every case in there is a bug that shipped — the viewport
|
||||
parked ten hours back, hourly candles drawn as slivers, stale bar events
|
||||
throwing, comments drifting on a timeframe switch, and three separate ways a
|
||||
trendline anchor could disagree with its own preview. None of them could have
|
||||
been caught by pytest, which is the argument for the suite existing.
|
||||
|
||||
Tests clean up after themselves: `withChart` records the drawings that exist
|
||||
before the body runs and deletes anything new afterwards, because the dev store
|
||||
is shared with whoever is using the app. Select by title rather than class when
|
||||
asserting on chart overlays, for the same reason.
|
||||
|
|
|
|||
BIN
screenshot1.png
Normal file
BIN
screenshot1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
|
|
@ -47,6 +47,10 @@ class ConfluenceChart {
|
|||
// rather than a click. Wide enough to survive a twitch on a deliberate click.
|
||||
static DRAG_THRESHOLD = 12;
|
||||
|
||||
// Bars either side of the cursor considered when snapping. Wide enough to
|
||||
// catch the neighbour you meant when bars are three pixels apart.
|
||||
static SNAP_NEIGHBOURS = 6;
|
||||
|
||||
static snapToTick(price) {
|
||||
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
|
||||
}
|
||||
|
|
@ -681,18 +685,40 @@ class ConfluenceChart {
|
|||
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
|
||||
const base = { t: fallbackT, p: point.p, snappedSide: point.snappedSide ?? null };
|
||||
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
|
||||
if (point.y == null) return base;
|
||||
const nearest = this.bars.reduce(
|
||||
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
|
||||
);
|
||||
const candidates = [
|
||||
{ p: nearest.h, side: 'resistance' },
|
||||
{ p: nearest.l, side: 'support' },
|
||||
];
|
||||
const snapped = candidates
|
||||
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
|
||||
.sort((a, b) => a.distance - b.distance)[0];
|
||||
return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
|
||||
// An already-snapped point carries no cursor position; return it untouched
|
||||
// rather than measuring against undefined.
|
||||
if (point.y == null || point.x == null) return base;
|
||||
|
||||
// Nearest extreme by *screen* distance, across a few bars either side —
|
||||
// not the extreme of whichever bar happens to share the cursor's time.
|
||||
// Taking the bar by time alone meant pointing anywhere below a candle
|
||||
// snapped to that candle's low however far away it was, while the extreme
|
||||
// actually under the cursor was ignored. Zoomed out to ~360 bars at three
|
||||
// pixels each, that made hitting the intended bar a matter of several
|
||||
// tries.
|
||||
let lo = 0;
|
||||
let hi = this.bars.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi + 1) >> 1;
|
||||
if (this.bars[mid].t <= fallbackT) lo = mid;
|
||||
else hi = mid - 1;
|
||||
}
|
||||
const timeScale = this.chart.timeScale();
|
||||
let best = null;
|
||||
const from = Math.max(0, lo - ConfluenceChart.SNAP_NEIGHBOURS);
|
||||
const to = Math.min(this.bars.length - 1, lo + ConfluenceChart.SNAP_NEIGHBOURS);
|
||||
for (let index = from; index <= to; index += 1) {
|
||||
const bar = this.bars[index];
|
||||
const x = timeScale.timeToCoordinate(bar.t);
|
||||
if (x == null) continue;
|
||||
for (const [price, side] of [[bar.h, 'resistance'], [bar.l, 'support']]) {
|
||||
const y = this.candles.priceToCoordinate(price);
|
||||
if (y == null) continue;
|
||||
const distance = Math.hypot(x - point.x, y - point.y);
|
||||
if (!best || distance < best.distance) best = { distance, t: bar.t, p: price, side };
|
||||
}
|
||||
}
|
||||
return best ? { t: best.t, p: best.p, snappedSide: best.side } : base;
|
||||
}
|
||||
|
||||
renderGesture() {
|
||||
|
|
|
|||
62
tests/e2e/chart.test.mjs
Normal file
62
tests/e2e/chart.test.mjs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* The chart shows the right window, at the right density, without throwing.
|
||||
*
|
||||
* Every assertion here failed at some point on 2026-08-10 while all 96 Python
|
||||
* tests passed, which is the whole argument for this file existing.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { withChart, chartState, selectTimeframe, assertNoPageErrors } from './helpers.mjs';
|
||||
|
||||
const TIMEFRAMES = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||
|
||||
test('the viewport opens on the live edge, not in the past', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const state = await chartState(page);
|
||||
assert.ok(state.bars > 0, 'the chart loaded no bars at all');
|
||||
// The bug: setBars placed the viewport by logical index, and the daily MA
|
||||
// series then prepended hundreds of points, sliding the view ~10 hours back
|
||||
// while the data itself was perfectly current.
|
||||
assert.equal(
|
||||
state.visibleTo, state.lastTime,
|
||||
'the newest bar is not the right-hand edge of the viewport',
|
||||
);
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('every timeframe gives one slot per candle', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
for (const tf of TIMEFRAMES) {
|
||||
await selectTimeframe(page, tf);
|
||||
const state = await chartState(page);
|
||||
// The bug: a minute-resolution VWAP put its own points on the shared time
|
||||
// scale, so an hourly chart spread 160 candles over 908 slots and drew
|
||||
// them as slivers. Anything much above 1.0 means the scale is polluted.
|
||||
const ratio = state.slots / Math.max(state.inView, 1);
|
||||
assert.ok(
|
||||
ratio > 0.8 && ratio < 1.3,
|
||||
`${tf}: ${state.inView} candles spread over ${state.slots} slots (ratio ${ratio.toFixed(2)})`,
|
||||
);
|
||||
}
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('switching timeframes rapidly never throws', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
// The bug: bar events for the timeframe just left arrived after the new
|
||||
// snapshot, and Lightweight Charts throws "Cannot update oldest data"
|
||||
// rather than ignoring a stale bar, which took the whole app down.
|
||||
for (let round = 0; round < 3; round += 1) {
|
||||
for (const tf of ['1m', '1h', '15m', '1d', '5m']) {
|
||||
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||
await page.waitForTimeout(700);
|
||||
}
|
||||
}
|
||||
await page.waitForTimeout(2000);
|
||||
const state = await chartState(page);
|
||||
assert.ok(state.bars > 0, 'the chart lost its data while switching');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
110
tests/e2e/comments.test.mjs
Normal file
110
tests/e2e/comments.test.mjs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Comments stay where they were put, across zoom, scroll and timeframe.
|
||||
*
|
||||
* A comment is stored as a drawing but must never behave like a level, so the
|
||||
* last test here guards the invariant that matters most: a note on a chart can
|
||||
* never end up in a confluence cluster or fire a push notification.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
|
||||
} from './helpers.mjs';
|
||||
|
||||
const TEXT = 'e2e placement check';
|
||||
|
||||
/** Place a comment at a point on the chart. */
|
||||
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
|
||||
await armTool(page, 'Comment');
|
||||
await page.fill('textarea[aria-label="Comment text"]', TEXT);
|
||||
if (floating) {
|
||||
await page.evaluate(() => {
|
||||
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
|
||||
const box_ = body.querySelector('input[type=checkbox]');
|
||||
if (!box_.checked) box_.click();
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
const point = at(box, fx, fy);
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.down(); await page.mouse.up();
|
||||
await page.waitForTimeout(1800);
|
||||
}
|
||||
|
||||
const commentNode = page => page.evaluate(text => {
|
||||
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
|
||||
if (!node) return null;
|
||||
return {
|
||||
left: Math.round(node.getBoundingClientRect().left),
|
||||
collapsed: node.classList.contains('collapsed'),
|
||||
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
|
||||
};
|
||||
}, TEXT);
|
||||
|
||||
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await selectTimeframe(page, '30m');
|
||||
await placeComment(page, box, 0.55, 0.40);
|
||||
|
||||
const anchor = await page.evaluate(t => {
|
||||
const c = window.__chart.comments.find(x => x.note === t);
|
||||
return c ? c.anchor_t : null;
|
||||
}, TEXT);
|
||||
assert.ok(anchor, 'the comment was not created');
|
||||
|
||||
// The bug: timeToCoordinate answers only for exact data points, so a 30m
|
||||
// bucket returned null on 15m and null was read as "off the left edge".
|
||||
for (const tf of ['15m', '1h', '30m']) {
|
||||
await selectTimeframe(page, tf);
|
||||
const node = await commentNode(page);
|
||||
assert.ok(node, `the comment vanished on ${tf}`);
|
||||
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
|
||||
}
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.5, 0.35);
|
||||
assert.equal((await commentNode(page)).collapsed, false);
|
||||
// By title, not by class: the dev store is shared, so other people's
|
||||
// comments are on the chart too and `.chart-comment` matches them first.
|
||||
await page.click(`.chart-comment[title="${TEXT}"]`);
|
||||
await page.waitForTimeout(1200);
|
||||
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.4, 0.3, { floating: true });
|
||||
const before = await commentNode(page);
|
||||
assert.ok(before, 'the floating comment was not created');
|
||||
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
|
||||
await page.waitForTimeout(1200);
|
||||
const after = await commentNode(page);
|
||||
assert.equal(after.left, before.left, 'a floating comment moved with the chart');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
|
||||
test('a comment is never a level', { timeout: 180000 }, async () => {
|
||||
await withChart(async page => {
|
||||
const box = await chartBox(page);
|
||||
await placeComment(page, box, 0.5, 0.5);
|
||||
// Levels cluster and alert. A comment reaching that list would push a
|
||||
// phone notification about a piece of text.
|
||||
const leaked = await page.evaluate(t => {
|
||||
const note = window.__chart.comments.find(x => x.note === t);
|
||||
if (!note) return 'comment missing';
|
||||
return window.__chart.levels.some(level => level.id === note.id);
|
||||
}, TEXT);
|
||||
assert.equal(leaked, false, 'a comment appeared in the level list');
|
||||
assertNoPageErrors(page, assert);
|
||||
});
|
||||
});
|
||||
161
tests/e2e/helpers.mjs
Normal file
161
tests/e2e/helpers.mjs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
/**
|
||||
* Shared plumbing for the end-to-end tests.
|
||||
*
|
||||
* These run inside the `playwright` compose service, which has Playwright
|
||||
* installed globally and can reach the app as http://api:8000. Nothing here
|
||||
* needs npm install in this repo — see bin/e2e.
|
||||
*/
|
||||
// Playwright is installed globally in the container, not in this repo. ESM
|
||||
// ignores NODE_PATH, so it is resolved through a CommonJS require, which
|
||||
// honours it — see bin/e2e.
|
||||
import { createRequire } from 'node:module';
|
||||
const { chromium } = createRequire(import.meta.url)('playwright');
|
||||
|
||||
export const APP_URL = process.env.E2E_URL || 'http://api:8000/';
|
||||
const TOKEN_KEY = 'chart-token';
|
||||
|
||||
/**
|
||||
* Launch a browser pointed at the chart.
|
||||
*
|
||||
* `--lang=en-US` is not optional. The container has no usable locale, so
|
||||
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
|
||||
* every test sees a blank canvas that looks exactly like a broken app.
|
||||
*/
|
||||
export async function launch() {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--lang=en-US'],
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
||||
});
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: 1600, height: 1000 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Chicago',
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error.message)));
|
||||
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.__errors = errors;
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
/** Open the app and wait until the chart has bars and levels. */
|
||||
export async function openChart(page) {
|
||||
await page.goto(APP_URL, { waitUntil: 'networkidle', timeout: 60000 });
|
||||
await page.waitForFunction(
|
||||
() => window.__chart && window.__chart.bars && window.__chart.bars.length > 0,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// The socket delivers a second snapshot on subscribe; let it settle so the
|
||||
// viewport is the one the app actually ends up showing.
|
||||
await page.waitForTimeout(3000);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `body` against a fresh browser, then close it.
|
||||
*
|
||||
* Any drawing created during the test is deleted afterwards, whatever the
|
||||
* outcome. The dev stack shares one drawing store with whoever is using the
|
||||
* app, so a test that leaves debris leaves it in someone's sidebar.
|
||||
*/
|
||||
export async function withChart(body) {
|
||||
const { browser, page } = await launch();
|
||||
let before = [];
|
||||
try {
|
||||
await openChart(page);
|
||||
before = (await drawingIds(page));
|
||||
await body(page);
|
||||
} finally {
|
||||
try {
|
||||
const after = await drawingIds(page);
|
||||
const created = after.filter(id => !before.includes(id));
|
||||
if (created.length) await deleteDrawings(page, created);
|
||||
} catch { /* the page may already be gone; nothing to clean */ }
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ids of every drawing the server currently holds. */
|
||||
export async function drawingIds(page) {
|
||||
return page.evaluate(async key => {
|
||||
const token = localStorage.getItem(key) || '';
|
||||
const response = await fetch('/api/drawings', { headers: token ? { 'X-Chart-Token': token } : {} });
|
||||
if (!response.ok) return [];
|
||||
return (await response.json()).drawings.map(drawing => drawing.id);
|
||||
}, TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function deleteDrawings(page, ids) {
|
||||
return page.evaluate(async ([list, key]) => {
|
||||
const token = localStorage.getItem(key) || '';
|
||||
for (const id of list) {
|
||||
await fetch(`/api/lines/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE', headers: token ? { 'X-Chart-Token': token } : {},
|
||||
});
|
||||
}
|
||||
}, [ids, TOKEN_KEY]);
|
||||
}
|
||||
|
||||
/** Pixel box of the chart pane, for turning fractions into mouse positions. */
|
||||
export async function chartBox(page) {
|
||||
return page.evaluate(() => {
|
||||
const rect = document.querySelector('#chart').getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
|
||||
});
|
||||
}
|
||||
|
||||
/** A point inside the chart, given as fractions of its width and height. */
|
||||
export function at(box, fx, fy) {
|
||||
return { x: box.x + box.w * fx, y: box.y + box.h * fy };
|
||||
}
|
||||
|
||||
export async function selectTimeframe(page, tf) {
|
||||
await page.click(`.timeframes button:text-is("${tf}")`);
|
||||
await page.waitForTimeout(3000);
|
||||
}
|
||||
|
||||
export async function armTool(page, name) {
|
||||
await page.click(`.tool-head:has-text("${name}")`);
|
||||
await page.waitForTimeout(400);
|
||||
}
|
||||
|
||||
/** The newest hand-drawn sloped line, with the bar its anchor sits on. */
|
||||
export async function newestTrendline(page) {
|
||||
return page.evaluate(() => {
|
||||
const lines = window.__chart.levels.filter(l => l.kind === 'manual' && l.slope !== 0);
|
||||
const line = lines.sort((a, b) => b.number - a.number)[0];
|
||||
if (!line) return null;
|
||||
const bar = window.__chart.bars.find(b => b.t === line.anchor_t) || null;
|
||||
return {
|
||||
id: line.id, anchor_t: line.anchor_t, anchor_p: line.anchor_p, side: line.side,
|
||||
onExtreme: bar ? (line.anchor_p === bar.h || line.anchor_p === bar.l) : null,
|
||||
snappedTo: bar ? (line.anchor_p === bar.h ? 'high' : line.anchor_p === bar.l ? 'low' : 'neither') : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** What the chart is currently showing: series, viewport, and slot density. */
|
||||
export async function chartState(page) {
|
||||
return page.evaluate(() => {
|
||||
const chart = window.__chart;
|
||||
const data = chart.candles.data();
|
||||
const visible = chart.chart.timeScale().getVisibleRange();
|
||||
const logical = chart.chart.timeScale().getVisibleLogicalRange();
|
||||
const inView = visible ? data.filter(d => d.time >= visible.from && d.time <= visible.to).length : 0;
|
||||
return {
|
||||
bars: data.length,
|
||||
lastTime: data.length ? data[data.length - 1].time : null,
|
||||
lastClose: data.length ? data[data.length - 1].close : null,
|
||||
inView,
|
||||
slots: logical ? Math.round(logical.to - logical.from) : 0,
|
||||
visibleFrom: visible ? visible.from : null,
|
||||
visibleTo: visible ? visible.to : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Fails the test if the page logged any error, with the first few quoted. */
|
||||
export function assertNoPageErrors(page, assert) {
|
||||
const unique = [...new Set(page.__errors)];
|
||||
assert.deepStrictEqual(unique, [], `page errors: ${unique.slice(0, 3).join(' | ')}`);
|
||||
}
|
||||
164
tests/e2e/trendline.test.mjs
Normal file
164
tests/e2e/trendline.test.mjs
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue