chart/tests/e2e/comments.test.mjs

211 lines
10 KiB
JavaScript

/**
* Comments stay where they were put, across zoom, scroll and timeframe.
*
* A comment is stored as a drawing but must never behave like a level, so the
* last test here guards the invariant that matters most: a note on a chart can
* never end up in a confluence cluster or fire a push notification.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
withChart, chartBox, at, armTool, selectTimeframe, assertNoPageErrors,
} from './helpers.mjs';
const TEXT = 'e2e placement check';
/** Place a comment at a point on the chart. */
async function placeComment(page, box, fx, fy, { floating = false } = {}) {
await armTool(page, 'Comment');
await page.fill('textarea[aria-label="Comment text"]', TEXT);
if (floating) {
await page.evaluate(() => {
const body = [...document.querySelectorAll('.tool-body')].find(b => b.querySelector('textarea'));
const box_ = body.querySelector('input[type=checkbox]');
if (!box_.checked) box_.click();
});
await page.waitForTimeout(300);
}
const point = at(box, fx, fy);
await page.mouse.move(point.x, point.y);
await page.mouse.down(); await page.mouse.up();
await page.waitForTimeout(1800);
}
const commentNode = page => page.evaluate(text => {
const node = [...document.querySelectorAll('.chart-comment')].find(n => n.title === text);
if (!node) return null;
return {
left: Math.round(node.getBoundingClientRect().left),
collapsed: node.classList.contains('collapsed'),
parked: node.classList.contains('off-left') || node.classList.contains('off-right'),
};
}, TEXT);
test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await selectTimeframe(page, '30m');
await placeComment(page, box, 0.55, 0.40);
const anchor = await page.evaluate(t => {
const c = window.__chart.comments.find(x => x.note === t);
return c ? { t: c.anchor_t, p: c.anchor_p } : null;
}, TEXT);
assert.ok(anchor, 'the comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Shift+ArrowRight');
await page.waitForFunction(([text, original]) => {
const c = window.__chart;
const comment = c.comments.find(value => value.note === text);
return comment?.anchor_p === original.p + 0.25
&& Math.round(c.indexAt(comment.anchor_t) - c.indexAt(original.t)) === 4;
}, [TEXT, anchor]);
// The bug: timeToCoordinate answers only for exact data points, so a 30m
// bucket returned null on 15m and null was read as "off the left edge".
for (const tf of ['15m', '1h', '30m']) {
await selectTimeframe(page, tf);
const node = await commentNode(page);
assert.ok(node, `the comment vanished on ${tf}`);
assert.equal(node.parked, false, `the comment was edge-parked on ${tf} despite being in view`);
}
assertNoPageErrors(page, assert);
});
});
test('clicking a comment collapses it', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.35);
assert.equal((await commentNode(page)).collapsed, false);
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
const row = page.locator('.trendline-row');
const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes =>
nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.className));
assert.deepEqual(controls, ['collapse-toggle', 'color'],
'the comment color picker is not the rightmost control');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
'selecting the comment row did not point out the comment on the chart');
await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection');
const chartComment = page.locator(`.chart-comment[title="${TEXT}"]`);
// The body selects without collapsing; collapse is deliberately confined
// to the narrow control on the left.
await chartComment.locator('.chart-comment-text').click();
assert.equal(await row.evaluate(node => node.classList.contains('selected')), true,
'clicking the chart comment did not select its drawing row');
assert.equal((await commentNode(page)).collapsed, false,
'selecting the comment also collapsed it');
await chartComment.locator('.chart-comment-collapse').click();
await page.waitForTimeout(1200);
assert.equal((await commentNode(page)).collapsed, true, 'clicking did not collapse it');
assertNoPageErrors(page, assert);
});
});
test('a floating comment holds its position while the chart scrolls', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.4, 0.3, { floating: true });
const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.keyboard.press('ArrowRight');
await page.waitForFunction(([text, left]) => {
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
return node && Math.round(node.getBoundingClientRect().left) === left + 4;
}, [TEXT, before.left]);
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
await page.waitForTimeout(1200);
const after = await commentNode(page);
assert.equal(after.left, before.left + 4, 'a floating comment moved with the chart');
assertNoPageErrors(page, assert);
});
});
test('a comment is never a level', { timeout: 180000 }, async () => {
await withChart(async page => {
const box = await chartBox(page);
await placeComment(page, box, 0.5, 0.5);
// Levels cluster and alert. A comment reaching that list would push a
// phone notification about a piece of text.
const leaked = await page.evaluate(t => {
const note = window.__chart.comments.find(x => x.note === t);
if (!note) return 'comment missing';
return window.__chart.levels.some(level => level.id === note.id);
}, TEXT);
assert.equal(leaked, false, 'a comment appeared in the level list');
assertNoPageErrors(page, assert);
});
});
test('a symbol can be dropped at a price and dragged to a new one', {
timeout: 180000,
skip: 'quarantined: persisted off-screen symbols can overlap and intercept the test gesture',
}, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Symbol');
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
'the two-row symbol palette is incomplete');
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
'the Go symbol is not represented by a play triangle');
await page.locator('.symbol-head-color>summary').click();
assert.equal(await page.locator('.symbol-head-color .color-popover>button[title="Cyan"]').count(), 1,
'the symbol color palette does not expose named presets');
assert.equal(await page.locator('.symbol-head-color .color-popover>button[aria-label^="Use "]').count(), 16,
'the symbol color palette does not contain all presets');
await page.locator('.symbol-head-color .palette-close').click();
await page.locator('button[aria-label="Skull"]').dragTo(page.locator('#chart'), {
targetPosition: { x: box.w * 0.55, y: box.h * 0.4 },
});
await page.waitForFunction(() => window.__chart.comments.some(comment =>
comment.kind === 'symbol' && comment.icon === 'skull'));
const symbolTool = page.locator('.tool:has(.symbol-head)');
assert.equal(await symbolTool.locator('.tool-body').isVisible(), true,
'placing a symbol closed its tool palette');
assert.equal(await symbolTool.evaluate(node => node.classList.contains('armed')), false,
'placing a symbol left the chart armed to create another one');
const created = await page.evaluate(() => {
const symbol = window.__chart.comments.find(comment =>
comment.kind === 'symbol' && comment.icon === 'skull');
return { id: symbol.id, anchor_t: symbol.anchor_t, anchor_p: symbol.anchor_p };
});
assert.equal(created.anchor_p * 4, Math.round(created.anchor_p * 4),
'the dropped symbol price was not tick-snapped');
const node = page.locator(`.chart-comment.symbol[data-drawing-id="${created.id}"]`);
assert.equal(await node.locator('.fa-skull').count(), 1,
'the symbol did not render with its approved Font Awesome icon');
assert.equal(await page.evaluate(id =>
window.__chart.levels.some(level => level.id === id), created.id), false,
'the symbol leaked into chart levels');
assert.equal(await node.locator('.chart-comment-grab').isVisible(), false,
'an unselected symbol exposed its grab handle');
await node.locator('.chart-comment-text').click();
assert.equal(await node.locator('.chart-comment-grab').isVisible(), true,
'a selected symbol did not expose its grab handle');
const before = await node.boundingBox();
const grip = await node.locator('.chart-comment-grab').boundingBox();
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x + grip.width / 2 + 70, grip.y + grip.height / 2 + 45,
{ steps: 10 });
await page.mouse.up();
await page.waitForFunction(previous => {
const symbol = window.__chart.comments.find(comment => comment.id === previous.id);
return symbol && (symbol.anchor_t !== previous.anchor_t || symbol.anchor_p !== previous.anchor_p);
}, created);
const movedPrice = await page.evaluate(id =>
window.__chart.comments.find(comment => comment.id === id).anchor_p, created.id);
assert.equal(movedPrice * 4, Math.round(movedPrice * 4),
'the dragged symbol price was not tick-snapped');
assertNoPageErrors(page, assert);
});
});