// npm install && npx playwright install chromium && npm test // Optional: CHROMIUM_PATH=/path/to/chrome npm test const { chromium } = require('playwright'); const fs = require('node:fs'); const path = require('node:path'); const assert = require('node:assert/strict'); const repo = path.resolve(__dirname, '../..'); const source = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/js/site.js'), 'utf8'); const start = source.indexOf('(() => {\n const chartRoots ='); const chartScript = source.slice(start, source.indexOf('\n})();', start) + 6); const css = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/css/site.css'), 'utf8'); const chartJs = path.join(path.dirname(require.resolve('chart.js')), 'chart.umd.js'); const settle = async page => { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); await page.waitForTimeout(100); }; async function fixture(page, bookCount, sceneCount, variedHeaders = false, mode = "visible") { const scenes = []; let books = ''; for (let book = 0; book < bookCount; book++) { let chapters = ''; for (let chapter = 0; chapter < 3; chapter++) { let cards = ''; for (let n = 0; n < sceneCount; n++) { const id = scenes.length + 1; scenes.push({ sceneId: id, sceneNumber: id, chapterLabel: `Chapter ${chapter + 1}`, sceneTitle: `Scene ${id}`, axisLabel: `S${id}`, bookTitle: `Book ${book + 1}`, timelineUrl: `https://plotdirector.test/Timeline?selectedSceneId=${id}` }); cards += `Scene ${id}`; } chapters += `
Chapter ${chapter + 1}
${cards}
`; } books += `
Book ${book + 1}
${chapters}
`; } const payload = { scenes, metrics: ['Tension', 'Action'].map((metricName, i) => ({ metricName, metricTypeId: i + 1, points: scenes.map(s => ({ sceneId: s.sceneId, value: 3 + s.sceneId % 5 })) })) }; await page.setContent(`
Project
${books}
Metric Shape
`); if (mode !== "visible") { await page.evaluate(mode => { const body = document.querySelector('[data-collapse-body="scene-cards"]'); if (mode === "disabled") body.remove(); else body.classList.add('timeline-section-collapsed'); }, mode); } await page.addScriptTag({ path: chartJs }); await page.addScriptTag({ content: chartScript }); await settle(page); } async function checkAlignment(page, label) { const result = await page.evaluate(() => { const chart = Chart.getChart(document.querySelector('canvas')); const left = chart.canvas.getBoundingClientRect().left; const points = chart.data.datasets[0].data; const errors = points.filter(p => p.sceneId).map(p => { const rect = document.querySelector(`[data-scene-id="${p.sceneId}"]`).getBoundingClientRect(); return Math.abs(left + chart.scales.x.getPixelForValue(p.x) - rect.left - rect.width / 2); }); const drawnErrors = chart.getDatasetMeta(0).data.flatMap((point, i) => { if (point.skip || point.x < chart.chartArea.left || point.x > chart.chartArea.right) return []; const rect = document.querySelector(`[data-scene-id="${points[i].sceneId}"]`).getBoundingClientRect(); return [Math.abs(left + point.x - rect.left - rect.width / 2)]; }); return { max: Math.max(...errors), drawnMax: Math.max(0, ...drawnErrors), bitmap: chart.canvas.width, limit: document.querySelector('.timeline-shell').clientWidth * devicePixelRatio + 1 }; }); assert.ok(result.max < 0.5, `${label}: scale drift ${JSON.stringify(result)}`); assert.ok(result.drawnMax < 0.5, `${label}: rendered drift ${JSON.stringify(result)}`); assert.ok(result.bitmap <= result.limit, `${label}: canvas exceeds viewport`); } async function checkInteractions(page) { await page.evaluate(() => { const canvas = document.querySelector('canvas'); canvas.scrollIntoView({ block: 'center' }); const shell = document.querySelector('.timeline-shell'); shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) / 2; }); await settle(page); const point = await page.evaluate(() => { const chart = Chart.getChart(document.querySelector('canvas')); const index = chart.getDatasetMeta(0).data.findIndex(p => !p.skip && p.x > chart.width / 2 && p.x < chart.chartArea.right - 20); const p = chart.getDatasetMeta(0).data[index]; const rect = chart.canvas.getBoundingClientRect(); return { index, sceneId: chart.data.datasets[0].data[index].sceneId, x: rect.left + p.x, y: rect.top + p.y }; }); assert.ok(point.index > 0, 'exercise a scene well beyond the first'); await page.mouse.move(point.x, point.y); await settle(page); assert.ok(await page.evaluate(id => { const chart = Chart.getChart(document.querySelector('canvas')); return chart.tooltip.title[0].endsWith(`Scene ${id}`); }, point.sceneId), 'tooltip must resolve scene identity from dataset index'); await page.evaluate(() => { const checkboxes = document.querySelectorAll('[data-metric-toggles] input'); checkboxes[1].click(); window.savedMetric = null; window.fetch = async (_, options) => { window.savedMetric = JSON.parse(options.body); return { ok: true, json: async () => ({ success: true, value: window.savedMetric.value }) }; }; }); await page.mouse.move(point.x, point.y); await page.mouse.down(); await page.mouse.move(point.x, point.y - 25, { steps: 3 }); await page.mouse.up(); await settle(page); const saved = await page.evaluate(() => window.savedMetric); assert.equal(saved.sceneId, point.sceneId, 'drag saves the correct scene'); assert.equal(saved.metricId, 1); await checkAlignment(page, 'after metric editing'); await page.evaluate(() => document.querySelectorAll('[data-metric-toggles] input')[1].click()); assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).isDatasetVisible(1)), true); const navigation = page.waitForRequest(request => request.url().includes('selectedSceneId=')); await page.route('https://plotdirector.test/**', route => route.abort()); await page.evaluate(index => { const chart = Chart.getChart(document.querySelector('canvas')); const p = chart.getDatasetMeta(0).data[index]; const rect = chart.canvas.getBoundingClientRect(); chart.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: rect.left + p.x, clientY: rect.top + p.y })); }, point.index); assert.ok((await navigation).url().endsWith(`selectedSceneId=${point.sceneId}`), 'point click targets its scene'); } (async () => { const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH }); try { for (const dpr of [1, 2]) { const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, deviceScaleFactor: dpr }); const errors = []; page.on('pageerror', error => errors.push(error.message)); for (const [books, count, headers] of [[1, 12, false], [3, 18, true]]) { await fixture(page, books, count, headers); for (const density of ['compact', 'comfortable', 'expanded']) { for (const zoom of ['book', 'chapter', 'scene-detail', 'scene-overview']) { await page.evaluate(({ density, zoom }) => { document.querySelector('[data-timeline-root]').className = `timeline-filtered-root density-${density} zoom-${zoom}`; }, { density, zoom }); await settle(page); for (const fraction of [0, 0.5, 1]) { await page.evaluate(f => { const shell = document.querySelector('.timeline-shell'); shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) * f; }, fraction); await settle(page); await checkAlignment(page, `${books} books ${density}/${zoom} scroll=${fraction} dpr=${dpr}`); } } } await page.setViewportSize({ width: 760, height: 900 }); await settle(page); await checkAlignment(page, 'resize'); // Width changes without an application event must trigger ResizeObserver. await page.evaluate(() => { document.querySelectorAll('.timeline-scene-card').forEach((el, i) => el.style.width = `${120 + i % 4 * 73}px`); document.querySelectorAll('.timeline-chapter,.timeline-book').forEach(el => el.style.width = 'max-content'); }); await settle(page); await checkAlignment(page, 'unequal scene widths'); for (const section of ['metrics', 'scene-cards']) { await page.evaluate(section => document.querySelector(`[data-collapse-body="${section}"]`).classList.add('timeline-section-collapsed'), section); await settle(page); await page.evaluate(section => { document.querySelector(`[data-collapse-body="${section}"]`).classList.remove('timeline-section-collapsed'); window.dispatchEvent(new Event('plotline:timeline-layout-changed')); }, section); await settle(page); await checkAlignment(page, `expand ${section}`); } await page.setViewportSize({ width: 1100, height: 900 }); } await fixture(page, 1, 6, false, 'collapsed'); await page.evaluate(() => { document.querySelector('[data-collapse-body="scene-cards"]').classList.remove('timeline-section-collapsed'); window.dispatchEvent(new Event('plotline:timeline-layout-changed')); }); await settle(page); await checkAlignment(page, 'initially collapsed scene cards'); await fixture(page, 1, 6, false, 'disabled'); assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).scales.x.type), 'linear', 'standalone layout remains supported'); await fixture(page, 3, 18, true); await checkInteractions(page); assert.deepEqual(errors, []); await page.close(); } console.log('PASS: timeline metric geometry and interactions (views/densities, single/multi-book, unequal widths, scrolling, resize, collapse/disabled cards, DPR 1/2, long timelines, tooltips, toggles, click, drag-save)'); } finally { await browser.close(); } })().catch(error => { console.error(error); process.exitCode = 1; });