diff --git a/bin/e2e b/bin/e2e new file mode 100755 index 0000000..1bbcd80 --- /dev/null +++ b/bin/e2e @@ -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') diff --git a/docker-compose.yml b/docker-compose.yml index 97d52d4..6da33d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 8ed09cd..3e224cf 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -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. diff --git a/screenshot1.png b/screenshot1.png new file mode 100644 index 0000000..34da7f3 Binary files /dev/null and b/screenshot1.png differ diff --git a/static/chart.js b/static/chart.js index e7fdc49..ea69fd3 100644 --- a/static/chart.js +++ b/static/chart.js @@ -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() { diff --git a/tests/e2e/chart.test.mjs b/tests/e2e/chart.test.mjs new file mode 100644 index 0000000..84cb46c --- /dev/null +++ b/tests/e2e/chart.test.mjs @@ -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); + }); +}); diff --git a/tests/e2e/comments.test.mjs b/tests/e2e/comments.test.mjs new file mode 100644 index 0000000..c98698f --- /dev/null +++ b/tests/e2e/comments.test.mjs @@ -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); + }); +}); diff --git a/tests/e2e/helpers.mjs b/tests/e2e/helpers.mjs new file mode 100644 index 0000000..88df4eb --- /dev/null +++ b/tests/e2e/helpers.mjs @@ -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(' | ')}`); +} diff --git a/tests/e2e/trendline.test.mjs b/tests/e2e/trendline.test.mjs new file mode 100644 index 0000000..e832614 --- /dev/null +++ b/tests/e2e/trendline.test.mjs @@ -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); + }); + });