/** * 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'); }); });