199 lines
12 KiB
JavaScript
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; });
|