/** * 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 page x. * * Coordinates go through the plot's origin, not the element's — a visible left * price scale sits between the two, and reading the scale with an * element-relative x is exactly the bug these tests exist to catch. */ const timeAt = (page, box, point) => page.evaluate(x => { const c = window.__chart; return Number(c.chart.timeScale().coordinateToTime(x - c.plotOffsetX())); }, 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('the snap dot stays under the cursor when the left scale changes width', { timeout: 180000 }, async () => { await withChart(async page => { await armTool(page, 'Trendline'); const probe = async () => { const target = await page.evaluate(() => { const c = window.__chart; const bar = c.bars[Math.max(0, c.bars.length - 80)]; const chartRect = c.chartEl.getBoundingClientRect(); const plotX = c.chart.timeScale().timeToCoordinate(bar.t); return { x: chartRect.left + c.plotOffsetX() + plotX, y: chartRect.top + c.candles.priceToCoordinate(bar.l) - 6, offset: c.plotOffsetX(), }; }); await page.mouse.move(target.x, target.y); await page.waitForTimeout(350); const dotX = await page.evaluate(() => { const dot = document.querySelector('.chart-snap-dot'); if (!dot || dot.hidden) return null; const rect = dot.getBoundingClientRect(); return rect.left + rect.width / 2; }); assert.notEqual(dotX, null, 'the snap dot was not painted'); assert.ok(Math.abs(dotX - target.x) <= 2, `snap dot was ${(dotX - target.x).toFixed(1)}px from the cursor`); return target.offset; }; const initialOffset = await probe(); await page.evaluate(() => { const c = window.__chart; c.__scaleProbe = c.chart.addSeries(LightweightCharts.LineSeries, { priceScaleId: 'left', lastValueVisible: true, priceLineVisible: false, }); c.__scaleProbe.setData([{ time: c.bars[c.bars.length - 1].t, value: 123456.75, }]); }); await page.waitForTimeout(600); const widenedOffset = await probe(); assert.ok(widenedOffset > initialOffset + 2, `left scale did not widen: ${initialOffset}px -> ${widenedOffset}px`); await page.evaluate(() => { const c = window.__chart; c.chart.removeSeries(c.__scaleProbe); delete c.__scaleProbe; }); 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; // x values below are plot-relative, matching what snapPoint expects. 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); }); });