// 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(``);
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; });