/** * 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('Escape cancels a pending trendline anchor and disarms the tool', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); const before = await page.evaluate(() => window.__chart.levels.length); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForFunction(() => window.__chart.pendingAnchor != null); await page.keyboard.press('Escape'); const state = await page.evaluate(() => ({ armed: window.__chart.armedTool, pending: window.__chart.pendingAnchor, previewHidden: document.querySelector('.chart-preview line').hasAttribute('hidden'), })); assert.equal(state.armed, null); assert.equal(state.pending, null); assert.equal(state.previewHidden, true); await page.mouse.click(second.x, second.y); await page.waitForTimeout(500); assert.equal(await page.evaluate(() => window.__chart.levels.length), before, 'a click after cancellation completed the abandoned line'); 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('dragging an anchor uses the same bar-extreme snapping as placement', { timeout: 180000 }, async () => { await withChart(async page => { const beforeNumber = await page.evaluate(() => Math.max(0, ...window.__chart.levels .filter(level => level.kind === 'manual').map(level => level.number))); const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.30, 0.55); const second = at(box, 0.48, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForFunction(number => { const c = window.__chart; const selected = c.levels.find(level => level.id === c.selectedLineId); return selected?.number > number && document.querySelectorAll('.chart-anchor:not([hidden])').length === 2; }, beforeNumber); const target = await page.evaluate(() => { const c = window.__chart; const bar = c.bars[c.bars.length - 35]; const plot = c.plotCanvas().getBoundingClientRect(); return { id: c.selectedLineId, t: bar.t, p: bar.l, x: plot.left + c.chart.timeScale().timeToCoordinate(bar.t), y: plot.top + c.candles.priceToCoordinate(bar.l), }; }); const handle = await page.locator('.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox(); assert.ok(handle, 'the selected trendline has no end handle'); await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2); await page.mouse.down(); await page.mouse.move(target.x, target.y, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(id => { const line = window.__chart.levels.find(level => level.id === id); return line && { t: line.last_t, p: line.anchor_p + line.slope * (line.last_t - line.anchor_t), }; }, target.id); assert.equal(moved.t, target.t, 'the handle moved to a different bar'); assert.ok(Math.abs(moved.p - target.p) < 1e-6, `the handle landed at ${moved.p}, not the bar low ${target.p}`); assertNoPageErrors(page, assert); }); }); test('future whitespace accepts endpoint and whole-line movement without entering the time scale', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); await page.evaluate(() => { const scale = window.__chart.chart.timeScale(); const range = scale.getVisibleLogicalRange(); scale.setVisibleLogicalRange({ from: range.from + 20, to: range.to + 20 }); }); await page.waitForTimeout(400); const future = await page.evaluate(() => { const c = window.__chart; const last = c.bars.length - 1; const index = last + 10; const line = c.levels.find(level => level.id === c.selectedLineId); const plot = c.plotCanvas().getBoundingClientRect(); return { id: line.id, x: plot.left + c.coordinateAtIndex(index), y: plot.top + plot.height * 0.45, }; }); const endHandle = await page.locator( '.chart-anchor[data-anchor="end"]:not([hidden])').boundingBox(); await page.mouse.move(endHandle.x + endHandle.width / 2, endHandle.y + endHandle.height / 2); await page.mouse.down(); await page.mouse.move(future.x, future.y, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const projected = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); const data = c.levelSeries.get(id).series.data(); const lastBar = c.bars[c.bars.length - 1]; const projection = document.querySelector(`.chart-projections [data-line-id="${id}"]`); return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), lastBarIndex: c.bars.length - 1, maxSeriesTime: Math.max(...data.map(point => Number(point.time))), lastBarTime: lastBar.t, projectionEnd: Number(projection?.getAttribute('x2')), plotWidth: c.plotCanvas().getBoundingClientRect().width, }; }, future.id); assert.ok(projected.endIndex > projected.lastBarIndex, 'the endpoint was clamped to the newest candle'); assert.ok(projected.maxSeriesTime <= projected.lastBarTime, 'future drawing geometry entered the shared time scale'); assert.ok(projected.projectionEnd >= projected.plotWidth - 2, 'the active line did not project through the visible whitespace'); const drag = await page.evaluate(() => { const c = window.__chart; const hit = c.lineHitTarget; const plot = c.plotCanvas().getBoundingClientRect(); const x1 = Number(hit.getAttribute('x1')); const y1 = Number(hit.getAttribute('y1')); const x2 = Number(hit.getAttribute('x2')); const y2 = Number(hit.getAttribute('y2')); const spacing = c.coordinateAtIndex(c.bars.length - 1) - c.coordinateAtIndex(c.bars.length - 2); return { x: plot.left + x1 + (x2 - x1) * 0.65, y: plot.top + y1 + (y2 - y1) * 0.65, dx: spacing * 3, }; }); await page.mouse.move(drag.x, drag.y); await page.mouse.down(); await page.mouse.move(drag.x + drag.dx, drag.y + 20, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t) }; }, future.id); assert.equal( Math.round(moved.endIndex - projected.endIndex), Math.round(moved.anchorIndex - projected.anchorIndex), 'the future endpoint did not translate with the rest of the line', ); assertNoPageErrors(page, assert); }); }); test('the line context menu duplicates by ten bars and deletes through the normal flow', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); const original = await page.evaluate(() => { const lines = window.__chart.levels.filter(level => level.kind === 'manual' && level.slope !== 0); return lines.sort((a, b) => b.number - a.number)[0]; }); assert.ok(original, 'no trendline was available to duplicate'); await page.mouse.click(box.x + box.w * 0.60, box.y + box.h * 0.45, { button: 'right' }); await page.keyboard.press('Escape'); assert.equal(await page.locator('.chart-context-menu').isVisible(), false, 'Escape did not close the trendline context menu'); await page.mouse.click(box.x + box.w * 0.60, box.y + box.h * 0.45, { button: 'right' }); await page.locator('.chart-context-menu [data-action="duplicate"]').click(); await page.waitForFunction(number => { const c = window.__chart; const lines = c.levels.filter(level => level.kind === 'manual' && level.slope !== 0); const duplicate = lines.find(level => level.number > number); return duplicate && c.selectedLineId === duplicate.id; }, original.number); const duplicated = await page.evaluate(originalLine => { const c = window.__chart; const line = c.levels.filter(level => level.kind === 'manual' && level.number > originalLine.number) .sort((a, b) => b.number - a.number)[0]; const original = c.levels.find(level => level.id === originalLine.id); // Socket snapshots and style edits resync every series. That resync // must not restore full names to unselected price-axis labels. c.syncLevels(c.levels); const nearestIndex = time => c.bars.reduce((nearest, bar, index) => Math.abs(bar.t - time) < Math.abs(c.bars[nearest].t - time) ? index : nearest, 0); return { line, anchorShift: nearestIndex(line.anchor_t) - nearestIndex(original.anchor_t), endShift: nearestIndex(line.last_t) - nearestIndex(original.last_t), originalTitle: c.levelSeries.get(original.id).series.options().title, duplicateTitle: c.levelSeries.get(line.id).series.options().title, }; }, original); assert.equal(duplicated.anchorShift, 10); assert.equal(duplicated.endShift, 10); const expectedName = `${original.side === 'support' ? 'up' : 'down'}${original.tf}`; assert.equal(original.label, expectedName); assert.equal(duplicated.line.label, `${expectedName} 2`); assert.equal(duplicated.originalTitle, `#${original.number}`); assert.equal(duplicated.duplicateTitle, duplicated.line.label); assert.equal(duplicated.line.color, original.color); assert.equal(duplicated.line.line_width, original.line_width); await page.mouse.click(box.x + box.w * 0.65, box.y + box.h * 0.45, { button: 'right' }); await page.locator('.chart-context-menu [data-action="delete"]').click(); await page.waitForFunction(id => !window.__chart.levels.some(level => level.id === id), duplicated.line.id); assertNoPageErrors(page, assert); }); }); test('dragging the selected line body translates both endpoints together', { timeout: 180000 }, async () => { await withChart(async page => { const box = await chartBox(page); await armTool(page, 'Trendline'); const first = at(box, 0.35, 0.55); const second = at(box, 0.55, 0.35); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForTimeout(1200); const original = await page.evaluate(() => { const c = window.__chart; const line = c.levels.find(level => level.id === c.selectedLineId); const hit = c.lineHitTarget; const plot = c.plotCanvas().getBoundingClientRect(); return { line: { ...line }, endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t), anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), drag: { x: plot.left + (Number(hit.getAttribute('x1')) + Number(hit.getAttribute('x2'))) / 2, y: plot.top + (Number(hit.getAttribute('y1')) + Number(hit.getAttribute('y2'))) / 2, }, }; }); await page.mouse.move(original.drag.x, original.drag.y); await page.mouse.down(); await page.mouse.move(original.drag.x + 55, original.drag.y + 35, { steps: 8 }); await page.mouse.up(); await page.waitForTimeout(900); const moved = await page.evaluate(originalLine => { const c = window.__chart; const line = c.levels.find(level => level.id === originalLine.id); return { line, endP: line.anchor_p + line.slope * (line.last_t - line.anchor_t), anchorIndex: c.indexAt(line.anchor_t), endIndex: c.indexAt(line.last_t), }; }, original.line); const anchorShift = Math.round(moved.anchorIndex - original.anchorIndex); const endShift = Math.round(moved.endIndex - original.endIndex); const anchorPriceShift = moved.line.anchor_p - original.line.anchor_p; const endPriceShift = moved.endP - original.endP; assert.notEqual(anchorShift, 0, 'the line did not move horizontally'); assert.equal(endShift, anchorShift, 'the endpoints moved by different bar counts'); assert.ok(Math.abs(anchorPriceShift) >= 0.25, 'the line did not move vertically'); assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6, 'the endpoints moved by different price amounts'); await page.waitForFunction(async expected => { const response = await fetch('/api/drawings'); const drawing = (await response.json()).drawings.find(item => item.id === expected.id); return drawing?.anchor_t === expected.anchor_t && drawing?.anchor_p === expected.anchor_p; }, { id: moved.line.id, anchor_t: moved.line.anchor_t, anchor_p: moved.line.anchor_p }); 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 is painted under the cursor in page coordinates', { 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; }; await probe(); 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); }); }); test('the right price axis is outside the snapping area', { timeout: 180000 }, async () => { await withChart(async page => { await armTool(page, 'Trendline'); const regions = await page.evaluate(() => { const c = window.__chart; const chart = c.chartEl.getBoundingClientRect(); const plot = c.plotCanvas().getBoundingClientRect(); return { plot: { x: plot.left + plot.width * 0.65, y: plot.top + plot.height * 0.45 }, axis: { x: plot.right + (chart.right - plot.right) / 2, y: plot.top + plot.height * 0.45 }, axisWidth: chart.right - plot.right, }; }); assert.ok(regions.axisWidth > 10, `right price axis is only ${regions.axisWidth}px wide`); const dotHidden = () => page.evaluate(() => document.querySelector('.chart-snap-dot')?.hidden); await page.mouse.move(regions.plot.x, regions.plot.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), false, 'snap dot did not appear over the plot'); await page.mouse.move(regions.axis.x, regions.axis.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), true, 'snap dot remained visible over the right price axis'); await page.mouse.move(regions.plot.x, regions.plot.y); await page.waitForTimeout(300); assert.equal(await dotHidden(), false, 'snap dot did not return after leaving the price axis'); assertNoPageErrors(page, assert); }); });