695 lines
31 KiB
JavaScript
695 lines
31 KiB
JavaScript
/**
|
|
* 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 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);
|
|
});
|
|
});
|