import { test } from 'node:test'; import assert from 'node:assert/strict'; import { withChart, chartBox, at, armTool, drawingIds, assertNoPageErrors, } from './helpers.mjs'; test('the timeframe visibility setting governs every drawing type', { timeout: 180000 }, async () => { await withChart(async page => { const ids = await page.evaluate(async () => { const bars = (await (await fetch('/api/bars?tf=30m&limit=2')).json()).bars; const [first, second] = bars; const post = async (url, body) => (await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), })).json(); const comment = await post('/api/comments', { text: `tf comment ${Date.now()}`, tf: '30m', pinned: true, anchor_t: first.t, anchor_p: first.c, }); const symbol = await post('/api/comments', { text: 'TF symbol', icon: 'skull', tf: '30m', pinned: true, anchor_t: first.t, anchor_p: first.c, }); const fib = await post('/api/lines', { kind: 'fibonacci', tf: '30m', side: 'support', anchor_t: first.t, anchor_p: first.l, end_t: second.t, end_p: second.h, }); const level = await post('/api/lines/price', { tf: '30m', price: first.c, note: `tf level ${Date.now()}`, }); return { comment: comment.id, symbol: symbol.id, fib: fib.id, level: level.id }; }); await page.reload({ waitUntil: 'networkidle' }); await page.waitForFunction(() => window.__chart?.bars?.length > 0); const allVisible = expected => page.waitForFunction(([values, visible]) => { const chart = window.__chart; const comments = chart.comments.some(item => item.id === values.comment) && chart.comments.some(item => item.id === values.symbol); const fib = chart.fibs.some(item => item.id === values.fib); const level = chart.priceLines.has(values.level); return visible ? comments && fib && level : !comments && !fib && !level; }, [ids, expected]); await allVisible(true); await page.click('.timeframes button:text-is("1d")'); await page.waitForFunction(() => window.__chart.bars[0]?.tf === '1d'); await allVisible(false); await page.locator('.config-section summary').click(); const setting = page.locator('.config-section label:has-text("Hide lower-TF drawings") input'); assert.equal(await setting.isChecked(), true, 'timeframe filtering does not default on'); await setting.uncheck(); await allVisible(true); assert.equal(await page.evaluate(() => localStorage.getItem('chart-hide-lower-tf-drawings')), 'false', 'the Config choice was not persisted'); await page.reload({ waitUntil: 'networkidle' }); await page.waitForFunction(() => window.__chart?.bars?.length > 0); await page.locator('.config-section summary').click(); assert.equal(await page.locator( '.config-section label:has-text("Hide lower-TF drawings") input').isChecked(), false, 'the Config choice reset on reload'); await allVisible(true); assertNoPageErrors(page, assert); }); }); test('editing a drawing name with Backspace or Delete cannot delete the drawing', { timeout: 180000, skip: 'quarantined: optimistic creation races the shared live levels broadcast', }, async () => { await withChart(async page => { const label = `e2e rename ${Date.now()}`; const box = await chartBox(page); await armTool(page, 'Trendline'); assert.deepEqual(await page.locator('select[aria-label="Trendline width"] option').allTextContents(), ['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']); await page.selectOption('select[aria-label="Trendline width"]', '9'); await page.fill('input[aria-label="Trendline label"]', label); const first = at(box, 0.42, 0.55); const second = at(box, 0.62, 0.40); await page.mouse.click(first.x, first.y); await page.waitForTimeout(300); await page.mouse.click(second.x, second.y); await page.waitForFunction(name => [...document.querySelectorAll('input[aria-label="Drawing name"]')] .some(input => input.value === name), label); await page.waitForFunction(name => window.__chart.levels.some(level => level.label === name && !level.id.startsWith('tmp_')), label); const created = await page.evaluate(name => { const line = window.__chart.levels.find( level => level.label === name && !level.id.startsWith('tmp_'), ); return line?.id || null; }, label); assert.ok(created, 'the trendline was not created'); assert.ok((await drawingIds(page)).includes(created), 'the trendline was not persisted'); assert.equal(await page.evaluate(id => window.__chart.levels.find(level => level.id === id)?.line_width, created), 9, 'the 9px trendline width was not persisted'); await page.fill('input[aria-label="Filter drawings by text"]', label); const input = page.locator('input[aria-label="Drawing name"]'); assert.equal(await input.count(), 1, 'the unique drawing filter did not isolate the test line'); const row = page.locator('.trendline-row'); assert.deepEqual(await row.locator('select[aria-label="Drawing width"] option').allTextContents(), ['1', '2', '3', '4', '5', '6', '7', '8', '9']); const controls = await row.locator('.drawing-controls>*').evaluateAll(nodes => nodes.map(node => node.classList.contains('color-picker') ? 'color' : node.tagName.toLowerCase())); assert.deepEqual(controls, ['select', 'color'], 'the color picker is not the rightmost control'); const rowBox = await row.boundingBox(); assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`); assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1, 'the armed state is not represented by a bell'); assert.equal(await row.locator('.color-picker>summary').getAttribute('title'), 'custom (#65B7CF)', 'the current-color swatch does not name its color'); const checkbox = row.locator('.line-select'); if (await checkbox.isChecked()) await checkbox.click(); await row.locator('.drawing-secondary>span').click(); assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null, 'clicking drawing-list content selected the trendline'); await checkbox.check(); await page.waitForFunction(id => window.__chart.selectedLineId === id, created); assert.equal(await page.locator('.chart-line-focus').isVisible(), true, 'selecting the drawing did not point out its trendline on the chart'); await row.locator('.color-picker>summary').click(); assert.equal(await row.locator('.color-family-swatches>button[aria-label^="Use "]').count(), 24, 'the preset palette does not contain 24 colors'); assert.equal(await row.locator('.color-family-swatches>button[title="blue1 (#4699FE)"]').count(), 1, 'palette colors do not expose readable names'); assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1, 'the custom color choice is missing'); assert.equal(await row.locator('.palette-close').count(), 1, 'the palette close control is missing'); await page.keyboard.press('Escape'); assert.equal(await row.locator('.color-popover').isVisible(), false, 'Escape did not close the color palette'); await row.locator('.color-picker>summary').click(); await row.locator('.palette-close').click(); assert.equal(await row.locator('.color-popover').isVisible(), false, 'the close control did not close the color palette'); await row.locator('.color-picker>summary').click(); await row.locator('button[aria-label="Use red2 (#F45B78)"]').click(); await page.waitForFunction(([id, color]) => window.__chart.levels.find(level => level.id === id)?.color === color, [created, '#F45B78']); await input.focus(); await input.press('End'); await input.press('Backspace'); assert.equal(await input.inputValue(), label.slice(0, -1), 'Backspace did not edit the name'); await page.waitForTimeout(500); assert.ok((await drawingIds(page)).includes(created), 'Backspace deleted the drawing'); const beforeDelete = await input.inputValue(); await input.press('Home'); await input.press('Delete'); assert.equal(await input.inputValue(), beforeDelete.slice(1), 'Delete did not edit the name'); await page.waitForTimeout(500); assert.ok((await drawingIds(page)).includes(created), 'Delete deleted the drawing'); assertNoPageErrors(page, assert); }); }); test('a price level can be edited from the list and adjusted on the chart', { timeout: 180000 }, async () => { await withChart(async page => { const label = `e2e level focus ${Date.now()}`; const price = await page.evaluate(() => window.__chart.bars.at(-1).c); await armTool(page, 'Price level'); assert.deepEqual(await page.locator('select[aria-label="Level width"] option').allTextContents(), ['1px', '2px', '3px', '4px', '5px', '6px', '7px', '8px', '9px']); await page.fill('input[aria-label="Level label"]', label); await page.fill('input[aria-label="Level price"]', String(price)); await page.locator('.price-row button[type="submit"]').click(); await page.fill('input[aria-label="Filter drawings by text"]', label); const row = page.locator('.trendline-row'); await row.waitFor(); await row.locator('.color-picker>summary').click(); assert.deepEqual( await row.locator('.color-family-swatches>button[aria-label^="Use "]').evaluateAll(nodes => nodes.map(node => node.getAttribute('title'))), [ 'green1 (#A6D8AA)', 'green2 (#4DB155)', 'green3 (#258F33)', 'green4 (#006D09)', 'red1 (#FAADBC)', 'red2 (#F45B78)', 'red3 (#D13F62)', 'red4 (#AF2850)', 'blue1 (#A3CCFF)', 'blue2 (#4699FE)', 'blue3 (#1E76D8)', 'blue4 (#0054B3)', 'orange1 (#F8C592)', 'orange2 (#F08A24)', 'orange3 (#D66B12)', 'orange4 (#B94E08)', 'teal1 (#80D8D8)', 'teal2 (#00B0B1)', 'teal3 (#008E8F)', 'teal4 (#006C6E)', 'purple1 (#DDBCEB)', 'purple2 (#BB79D7)', 'purple3 (#9858B3)', 'purple4 (#763790)', ], ); assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1, 'the palette lost its custom color wheel'); assert.equal(await row.locator('.palette-close[title="Cancel"]').count(), 1, 'the palette lost its Cancel control'); assert.equal(await row.locator('input[aria-label="blue row annotation"]').inputValue(), 'blue'); await row.locator('input[aria-label="blue row annotation"]').fill('primary structure'); await row.locator('input[aria-label="blue row annotation"]').press('Tab'); assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('chart-color-row-labels')).blue), 'primary structure'); await row.locator('button[aria-label="Use blue2 (#4699FE)"]').click(); const id = await page.evaluate(name => window.__chart.levels.find(level => level.label === name)?.id || null, label); assert.ok(id, 'the price level was not created'); await page.waitForFunction(levelId => window.__chart.levels.find(level => level.id === levelId)?.color === '#4699FE', id); const priceInput = row.locator('input[aria-label="Level price in drawing list"]'); const earlyInput = row.locator('input[aria-label="Level alert early points"]'); await priceInput.fill(String(price + 1)); await priceInput.press('Enter'); await page.waitForFunction(([levelId, expectedPrice]) => window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expectedPrice, [id, price + 1]); await earlyInput.fill('1.5'); await earlyInput.press('Enter'); await page.waitForFunction(levelId => window.__chart.levels.find(level => level.id === levelId)?.alert_early_points === 1.5, id); const number = await page.evaluate(levelId => window.__chart.levels.find(level => level.id === levelId).number, id); assert.equal(await page.evaluate(levelId => window.__chart.priceLines.get(levelId).line.options().title, id), `#${number}`, 'an unselected price level exposed its full name on the price axis'); await row.locator('.drawing-secondary>span').click(); assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null, 'clicking drawing-list content selected the drawing'); await row.locator('.line-select').check(); await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id); assert.equal(await page.evaluate(levelId => window.__chart.priceLines.get(levelId).line.options().title, id), label, 'a selected price level did not show its full name on the price axis'); const focus = page.locator('.chart-line-focus'); assert.equal(await focus.getAttribute('hidden'), null, 'the selected price level was not highlighted'); assert.equal(await focus.evaluate(node => getComputedStyle(node).animationName), 'line-focus', 'the price-level focus animation did not run'); assert.equal(await focus.getAttribute('y1'), await focus.getAttribute('y2'), 'the price-level highlight was not horizontal'); assert.equal(await page.evaluate(levelId => { const c = window.__chart; const level = c.levels.find(value => value.id === levelId); c.updateLineTooltip({ point: { x: c.plotCanvas().getBoundingClientRect().width / 2, y: c.candles.priceToCoordinate(level.anchor_p), } }); return c.tooltip.textContent; }, id), `#${number} ${label}\nblue2 (#4699FE)\nprimary structure`); const target = await page.evaluate(() => { const chart = window.__chart; const bar = chart.bars[chart.bars.length - 30]; const plot = chart.plotCanvas().getBoundingClientRect(); return { price: bar.h, x: plot.left + chart.chart.timeScale().timeToCoordinate(bar.t), y: plot.top + chart.candles.priceToCoordinate(bar.h), }; }); const handle = page.locator('.chart-anchor[data-anchor="start"]:not([hidden])'); const handleBox = await handle.boundingBox(); assert.ok(handleBox, 'a selected price level has no grab handle'); await page.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2); await page.mouse.down(); await page.mouse.move(target.x, target.y, { steps: 8 }); await page.mouse.up(); await page.waitForFunction(([levelId, expected]) => window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected, [id, target.price]); await page.keyboard.press('ArrowUp'); await page.waitForFunction(([levelId, expected]) => window.__chart.levels.find(level => level.id === levelId)?.anchor_p === expected, [id, target.price + 0.25]); await page.waitForFunction(expected => Number(document.querySelector('input[aria-label="Level price in drawing list"]')?.value) === expected, target.price + 0.25); assert.equal(Number(await priceInput.inputValue()), target.price + 0.25, 'the drawing-list price did not follow the chart nudge'); assertNoPageErrors(page, assert); }); }); test('Delete removes a drawing selected through its checkbox', { timeout: 180000 }, async () => { await withChart(async page => { const label = `e2e checkbox delete ${Date.now()}`; const price = await page.evaluate(() => window.__chart.bars.at(-1).c); await armTool(page, 'Price level'); await page.fill('input[aria-label="Level label"]', label); await page.fill('input[aria-label="Level price"]', String(price)); await page.locator('.price-row button[type="submit"]').click(); await page.fill('input[aria-label="Filter drawings by text"]', label); const row = page.locator('.trendline-row'); await row.waitFor(); const id = await page.evaluate(name => window.__chart.levels.find(level => level.label === name)?.id || null, label); assert.ok(id, 'the drawing was not created'); const checkbox = row.locator('.line-select'); await checkbox.check(); assert.equal(await checkbox.evaluate(node => document.activeElement === node), true, 'the checkbox did not retain keyboard focus'); await page.keyboard.press('Delete'); await page.waitForFunction(levelId => !window.__chart.levels.some(level => level.id === levelId), id); assertNoPageErrors(page, assert); }); }); test('filtered selections can hide and show drawings without deleting them', { timeout: 180000 }, async () => { await withChart(async page => { const label = `bulk visibility ${Date.now()}`; const created = await page.evaluate(async name => { const chart = window.__chart; const start = chart.bars.at(-30); const end = chart.bars.at(-10); const response = await fetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tf: '1m', side: 'support', anchor_t: start.t, anchor_p: start.l, end_t: end.t, end_p: end.l, note: name, }), }); return response.json(); }, label); await page.waitForFunction(id => window.__chart.levels.some(level => level.id === id), created.id); const filter = page.locator('input[aria-label="Filter drawings by text"]'); await filter.fill('1m'); assert.equal(await page.locator(`input[aria-label="Drawing name"][value="${label}"]`).count(), 1, 'the drawing filter did not match the timeframe'); await filter.fill(label); const row = page.locator('.trendline-row'); await page.getByRole('button', { name: 'Select shown' }).click(); assert.equal(await page.locator('.selection-count').textContent(), '1 selected'); await page.getByRole('button', { name: 'Hide selected drawings' }).click(); await page.waitForFunction(id => window.__chart.levels.find(level => level.id === id)?.hidden, created.id); assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), true); assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), false, 'the hidden drawing remained on the chart'); await page.getByRole('button', { name: 'Show selected drawings' }).click(); await page.waitForFunction(id => !window.__chart.levels.find(level => level.id === id)?.hidden, created.id); assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), false); assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), true, 'the shown drawing did not return to the chart'); assertNoPageErrors(page, assert); }); }); test('trendline slope in the tooltip is signed points per hour', { timeout: 180000 }, async () => { await withChart(async page => { const labels = await page.evaluate(() => ({ up: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 2 / 3600 }), down: ConfluenceChart.slopeLabel({ kind: 'manual', slope: -0.5 / 3600 }), flat: ConfluenceChart.slopeLabel({ kind: 'manual', slope: 0 }), })); assert.equal(labels.up, '+2.00 /h'); assert.equal(labels.down, '-0.50 /h'); assert.equal(labels.flat, ''); assertNoPageErrors(page, assert); }); });