PlotDirector/PlotLine.Tests/Browser/timeline-metric-layout.cjs

199 lines
12 KiB
JavaScript

// 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 += `<a class="timeline-scene-card" data-scene-id="${id}">Scene ${id}</a>`;
}
chapters += `<div class="timeline-chapter" style="--chapter-scene-count:${sceneCount}"><div class="timeline-chapter-title">Chapter ${chapter + 1}</div><div class="timeline-scenes">${cards}</div></div>`;
}
books += `<div class="timeline-book" style="--book-scene-count:${sceneCount * 3}"><div class="timeline-book-title" ${variedHeaders ? `style="width:${137 + book * 89}px"` : ''}>Book ${book + 1}</div><div class="timeline-chapters">${chapters}</div></div>`;
}
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(`<style>*{box-sizing:border-box}body{margin:0}${css}
.timeline-filtered-root{--timeline-label-width:180px;--timeline-grid-gap:10px}
${variedHeaders ? '.timeline-book{display:flex;width:max-content;gap:23px}.timeline-book-title{flex-shrink:0}' : ''}
</style><div class="timeline-filtered-root density-comfortable zoom-chapter" data-timeline-root>
<section class="timeline-shell"><div data-collapse-body="scene-cards"><div class="timeline-series-row"><div class="timeline-project-title">Project</div><div class="timeline-books">${books}</div></div></div>
<div class="metric-timeline-board" style="--scene-count:${scenes.length}"><div data-collapse-body="metrics"><div class="metric-shape-chart" data-metric-shape-chart data-timeline-metric-editor>
<div data-metric-toggles></div><div data-timeline-metric-edit-toolbar><input name="__RequestVerificationToken" value="test"></div><span data-edit-metric-status></span>
<div class="metric-timeline-row"><div class="metric-lane-label">Metric Shape</div><div class="metric-chart-frame metric-timeline-frame"><canvas></canvas></div></div>
<script type="application/json">${JSON.stringify(payload)}</script></div></div></div></section></div>`);
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; });