/** * 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 30m line keeps source geometry when the 1m window starts after its anchors', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const halfHour = 30 * 60; const weekend = 49 * 3600; const sourceTimes = [ 0, halfHour, halfHour + weekend, 2 * halfHour + weekend, 3 * halfHour + weekend, 4 * halfHour + weekend, ]; const displayed = []; for (let t = sourceTimes[4]; t <= sourceTimes[5]; t += 60) { displayed.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true }); } const line = { id: 'synthetic_30m', kind: 'manual', tf: '30m', anchor_t: sourceTimes[0], anchor_p: 100, last_t: sourceTimes[3], slope: 3 / (sourceTimes[3] - sourceTimes[0]), cutoff_t: null, }; const coordinateProbe = { bars: [{ t: 0 }, { t: halfHour * 2 }], chart: { timeScale: () => ({ timeToCoordinate: () => 0 }) }, indexAt: () => 0.5, coordinateAtIndex: () => 123, }; const fractionalCoordinate = ConfluenceChart.prototype.coordinateAtTime.call( coordinateProbe, halfHour, ); c.bars = displayed; c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: sourceTimes, duration: halfHour } }, }); const sourcePrice = c.linePriceAtIndex(line, displayed.length - 1); const sampled = c.seriesLineData(line); const shifted = c.shiftLineTime(line, line.anchor_t, 1); c.updateTrendlineTime('30m', sourceTimes[5], halfHour, sourceTimes[2]); const trimmedTimes = c.trendlineSourceSeries.get('30m').times; const gapAccepted = c.updateTrendlineTime( '30m', sourceTimes[5] + halfHour, halfHour, sourceTimes[2], sourceTimes[3], ); const timesAfterGap = c.trendlineSourceSeries.get('30m').times; c.setTrendlineGeometry({ mode: 'legacy', series: {} }); const legacyPrice = c.linePriceAtIndex(line, displayed.length - 1); return { sourcePrice, legacyPrice, shifted, sourceSecondBar: sourceTimes[1], sampledTimes: sampled.map(point => point.time), displayedTimes: displayed.map(bar => bar.t), trimmedTimes, expectedTrimmedTimes: sourceTimes.slice(2), gapAccepted, timesAfterGap, fractionalCoordinate, }; }); assert.equal(result.sourcePrice, 105, 'the 30m line was repriced in the truncated 1m window'); assert.notEqual(result.legacyPrice, result.sourcePrice, 'the rollback path no longer restores displayed-grid geometry'); assert.equal(result.shifted, result.sourceSecondBar, 'a source-timeframe nudge did not move by one 30m bar'); assert.deepEqual(result.sampledTimes, result.displayedTimes, 'manual line samples introduced timestamps outside the displayed candle grid'); assert.deepEqual(result.trimmedTimes, result.expectedTrimmedTimes, 'source timestamps evicted by the server remained in the browser'); assert.equal(result.gapAccepted, false, 'a dropped source timestamp was accepted without requesting a full replacement'); assert.deepEqual(result.timesAfterGap, result.expectedTrimmedTimes, 'a lossy source delta mutated geometry before resynchronization'); assert.equal(result.fractionalCoordinate, 123, 'a non-candle source anchor trusted a foreign shared-scale coordinate'); assert.equal(await page.locator('.chart-line-hit').evaluate(node => getComputedStyle(node).fill), 'none', 'the invisible polyline hit region filled the chart as a black polygon'); assertNoPageErrors(page, assert); }); }); test('future 5m projections keep their slope after the settlement break', { timeout: 180000 }, async () => { await withChart(async page => { const result = await page.evaluate(() => { const c = window.__chart; const fiveMinutes = 5 * 60; const thirtyMinutes = 30 * 60; // The final 65-minute gap is the 17:00-18:00 CME settlement break. const displayed = Array.from({ length: 30 }, (_, index) => ({ tf: '5m', t: index * fiveMinutes, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, })); displayed.push({ tf: '5m', t: displayed.at(-1).t + 13 * fiveMinutes, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true, }); const line = { id: 'settlement-gap', kind: 'manual', tf: '30m', anchor_t: 0, anchor_p: 100, last_t: thirtyMinutes, slope: 6 / thirtyMinutes, cutoff_t: null, }; c.bars = displayed; c.setTrendlineGeometry({ mode: 'source_tf', series: { '30m': { times: [0, thirtyMinutes, 2 * thirtyMinutes, 3 * thirtyMinutes, 4 * thirtyMinutes, 7 * thirtyMinutes], duration: thirtyMinutes, future_times: [8 * thirtyMinutes, 9 * thirtyMinutes], }, }, }); const last = displayed.length - 1; const before = c.linePriceAtIndex(line, last - 1); const current = c.linePriceAtIndex(line, last); const future = c.linePriceAtIndex(line, last + 1); return { historicalChange: current - before, futureChange: future - current, futureTime: c.timeAtIndex(last + 1), expectedFutureTime: displayed[last].t + fiveMinutes, }; }); assert.equal(result.futureTime, result.expectedFutureTime, 'one future 5m slot inherited the 65-minute settlement gap'); assert.ok(Math.abs(result.futureChange - result.historicalChange) < 1e-9, `future slope changed from ${result.historicalChange} to ${result.futureChange}`); assertNoPageErrors(page, assert); }); }); 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 remembered = await page.evaluate(() => { const latest = window.__chart.levels .filter(level => level.kind === 'manual' && level.slope !== 0) .sort((a, b) => (b.number || 0) - (a.number || 0))[0]; const expected = latest?.color || '#80D8D8'; const probe = document.createElement('span'); probe.style.color = expected; document.body.appendChild(probe); const expectedRgb = getComputedStyle(probe).color; probe.remove(); return { expectedRgb, actualRgb: getComputedStyle(document.querySelector('.tool.armed .color-picker>summary')).backgroundColor, }; }); assert.equal(remembered.actualRgb, remembered.expectedRgb, 'the Trendline tool did not remember the newest trendline color'); assert.ok((await page.locator('.tool.armed .color-picker>summary').boundingBox()).width >= 55, 'the Trendline color preview is still the tiny drawing-list size'); assert.equal(await page.locator('.tool.armed').evaluate(node => getComputedStyle(node).overflow), 'visible', 'the Trendline tool clips its color palette'); assert.equal(await page.locator('.tool.armed .color-family-swatches>button').count(), 24, 'the Trendline tool does not use the shared color palette'); 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.locator('.tool.armed .color-picker>summary').click(); assert.equal(await page.locator('.tool.armed .color-picker').getAttribute('open'), ''); 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'), paletteOpen: document.querySelector('.tool .color-picker[open]') != null, })); assert.equal(state.armed, null); assert.equal(state.pending, null); assert.equal(state.previewHidden, true); assert.equal(state.paletteOpen, false); 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('Escape cancels every armed drawing tool', { timeout: 180000 }, async () => { await withChart(async page => { for (const name of ['Price level', 'Comment', 'Mark']) { await armTool(page, name); assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`); await page.keyboard.press('Escape'); assert.equal(await page.evaluate(() => window.__chart.armedTool), null, `${name} stayed armed after Escape`); assert.equal(await page.locator('.tool.armed').count(), 0, `${name} kept an armed panel after Escape`); } assertNoPageErrors(page, assert); }); }); test('the snapped extreme decides the side, overriding the dropdown', { timeout: 180000, skip: 'quarantined: fixed viewport fractions are unstable against the moving live feed', }, 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, skip: 'quarantined: fixed bar selection races the moving live feed during the drag', }, 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, skip: 'quarantined: selected line can disappear from shared levelSeries during the test', }, 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, skip: 'quarantined: shared-state selection can leave the context menu unbound', }, 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, skip: 'quarantined: live chart geometry can lose the body-drag displacement', }, 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'); assert.equal(await page.evaluate(() => window.__chart.selectedLineId), moved.line.id, 'body-drag reconciliation cleared the selected line'); await page.keyboard.press('ArrowUp'); await page.waitForTimeout(1200); const afterVertical = await page.evaluate(id => { const c = window.__chart; return c.levels.find(level => level.id === id)?.anchor_p; }, moved.line.id); assert.equal(afterVertical, moved.line.anchor_p + 0.25, 'ArrowUp did not nudge the selected trendline'); const beforeHorizontal = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { id, p: line.anchor_p, index: c.indexAt(line.anchor_t) }; }, moved.line.id); await page.keyboard.press('Shift+ArrowRight'); await page.waitForFunction(expected => { const c = window.__chart; const line = c.levels.find(level => level.id === expected.id); return line?.anchor_p === expected.p && Math.round(c.indexAt(line.anchor_t) - expected.index) === 4; }, beforeHorizontal); const nudged = await page.evaluate(id => { const c = window.__chart; const line = c.levels.find(level => level.id === id); return { anchor_t: line.anchor_t, anchor_p: line.anchor_p }; }, moved.line.id); 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, ...nudged }); 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); }); });