tts-voice-creator-clone-and.../tests/magazine.spec.js
2026-10-03 00:37:40 +02:00

3259 lines
196 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-check
// Functional safety net for the Magazine reader UI. Backend calls are mocked
// with page.route so this exercises the frontend against a fixed contract
// (matching routes/magazine.py's actual response shapes) without needing a
// live PyMuPDF/tesseract ingestion — that pipeline has its own coverage in
// tests/test_magazine.py and tests/test_magazine_routes.py.
const { test, expect } = require('@playwright/test');
const DOC_ID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const ARTICLE_DETECTED = {
id: 'article-001', title: 'The Future of Batteries', author: 'Jane Smith',
pages: [3, 8], blocks: ['b1', 'b2', 'b3'], images: [], status: 'detected',
confidence: 0.9, section: 'Features', continued_to: [8],
audio_status: 'none', estimated_seconds: 96, has_podcast: false,
};
const ARTICLE_APPROVED = { ...ARTICLE_DETECTED, status: 'approved' };
const LAYOUT = {
page_count: 8,
pages: [{ page: 3, width: 595, height: 842 }],
blocks: [
{ id: 'b1', page: 3, bbox: [50, 60, 470, 50], type: 'title', text: 'The Future of Batteries', reading_order: 0, confidence: 0.9, font_size: 26, font_name: '', bold: true, column: 0, source: 'native' },
{ id: 'b2', page: 3, bbox: [50, 120, 200, 14], type: 'byline', text: 'By Jane Smith', reading_order: 1, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' },
{ id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text: 'Battery technology is entering a new phase. It will take years to mature.', reading_order: 2, confidence: 0.85, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' },
],
contents_candidates: [],
};
// Minimal valid 8 kHz mono WAV of silence -- enough for the audio element to
// play and end, without needing a real TTS backend.
function silentWav(ms = 50) {
const rate = 8000, n = Math.floor(rate * ms / 1000);
const buf = Buffer.alloc(44 + n, 128);
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n, 4); buf.write('WAVEfmt ', 8);
buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22);
buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate, 28); buf.writeUInt16LE(1, 32); buf.writeUInt16LE(8, 34);
buf.write('data', 36); buf.writeUInt32LE(n, 40);
return buf;
}
// The review toolbar is sticky and several rows tall: scroll so the page image starts just below it,
// as a person would, otherwise a mouse press meant for the image lands on the toolbar.
async function scrollPageImageBelowToolbar(page, width) {
await page.locator('#mag-review-page-wrap').evaluate((el, w) => {
if (w) el.style.width = w + 'px';
let pane = el.parentElement; // the app scrolls inside a content pane, not the window
while (pane && !(pane.scrollHeight > pane.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(pane).overflowY))) pane = pane.parentElement;
pane = pane || document.scrollingElement;
const bar = document.querySelector('#mag-review .mag-review-pagenav');
// a phone lays the toolbar out in normal flow (not sticky): nothing to leave room for
const barBottom = bar && getComputedStyle(bar).position === 'sticky' ? bar.getBoundingClientRect().bottom : 0;
pane.scrollTop += el.getBoundingClientRect().top - barBottom - 8;
}, width);
}
async function navTo(page, section) {
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate((s) => window.navTo(s), section);
}
async function mockBackend(page, { articleStatus = 'detected', hasAudio = false, progress = {} } = {}) {
// Each test starts from the article list. A previous test may deliberately
// switch to page thumbnails, and that preference is persisted by the app.
await page.addInitScript(() => localStorage.removeItem('ttsvc_mag_contents_view'));
const base = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED;
const article = { ...base, has_audio: hasAudio, audio_seconds: hasAudio ? 2 : 0 };
await page.route('**/api/reader/docs', route => route.fulfill({
json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } }] },
}));
await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({
json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => {
if (route.request().method() === 'POST') {
const requestedPage = route.request().postDataJSON().page;
return route.fulfill({ json: { article: {
id: 'article-page-' + requestedPage, title: 'Page ' + requestedPage, author: '',
pages: [requestedPage], blocks: [], images: [], status: 'detected', confidence: 0, section: '',
} } });
}
return route.fulfill({
json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } },
});
});
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => {
if (route.request().method() === 'PATCH') {
const body = route.request().postDataJSON();
return route.fulfill({ json: { ...article, ...body, status: body.status || article.status } });
}
return route.fulfill({ json: article });
});
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
json: { article, blocks: LAYOUT.blocks },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({
json: { available: false, quality: 'estimated', sentences: [] },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => route.fulfill({
body: silentWav(2000), contentType: 'audio/wav',
}));
await page.route(`**/api/magazine/${DOC_ID}/progress`, route => route.fulfill({ json: { ok: true } }));
// Reads the review screen makes on its own; tests that care override these routes.
await page.route(`**/api/magazine/${DOC_ID}/pages/*/audit`, route => route.fulfill({
json: { page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [], measured: true, spelling: 0, advertisement: false },
}));
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, route => route.fulfill({ json: { issues: [], dictionary: {} } }));
await page.route(`**/api/magazine/${DOC_ID}/history`, route => route.fulfill({
json: { can_undo: false, can_redo: false, undo_label: '', redo_label: '', undo_depth: 0, redo_depth: 0 } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({
json: { links: [], first: null, last: null } }));
await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({
// 1x1 PNG — only geometry/wiring is under test, not real page rendering.
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
contentType: 'image/png',
}));
await page.route('**/api/voices*', route => route.fulfill({
json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true, tag: 'Magazine' }],
}));
}
test.describe('Magazine reader', () => {
test('review region header selects and clears every region on the page', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: /^Review/ }).click();
const all = page.locator('[data-mag-check-all]').first();
await expect(all).toBeChecked();
await all.uncheck();
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(0);
await page.locator('[data-mag-check-all]').first().check();
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
});
test('thumbnail review opens the clicked page for multi-page articles', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 8' })
.getByRole('button', { name: /^Review/ }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8');
});
test('Approve & Next advances one physical page inside a multi-page article', async ({ page }) => {
await mockBackend(page);
const pageFour = {
id: 'article-page-4', title: 'Page 4', author: '', pages: [4], blocks: [],
images: [], status: 'detected', confidence: 0, section: '',
};
await page.route(`**/api/magazine/${DOC_ID}/articles`, async route => {
if (route.request().method() === 'POST') return route.fulfill({ json: { article: pageFour } });
return route.fallback();
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
});
test('figure boundary repair in review only sends the active page', async ({ page }) => {
await mockBackend(page);
let requested = '';
await page.route(`**/api/magazine/${DOC_ID}/pages/3/figures/refine`, route => {
requested = new URL(route.request().url()).pathname;
return route.fulfill({ json: { job_id: 'page-figures' } });
});
await page.route('**/api/jobs/page-figures', route => route.fulfill({
json: { done: true, detail: 'Figure boundaries checked on page 3', result: { moved: 1 } },
}));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click();
await expect.poll(() => requested).toContain('/pages/3/figures/refine');
expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`);
});
test('contents view keeps whole-magazine figure repair as an explicit batch action', async ({ page }) => {
await mockBackend(page);
let called = false;
await page.route(`**/api/magazine/${DOC_ID}/figures/refine`, route => {
called = true;
return route.fulfill({ json: { job_id: 'all-figures' } });
});
await page.route('**/api/jobs/all-figures', route => route.fulfill({
json: { done: true, detail: 'All figure boundaries checked', result: { moved: 2 } },
}));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
// The Material Design icon font can become part of the computed accessible
// name in Chromium, so use the action's stable control id here.
await page.locator('#mag-refine-all-figures-btn').click();
const dialog = page.getByRole('alertdialog', { name: 'Fix all figure boundaries?' });
expect(called).toBe(false);
await dialog.getByRole('button', { name: 'Fix all pages', exact: true }).click();
await expect.poll(() => called).toBe(true);
});
test('generated MP3 playlist shows status and synchronized article text', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true, progress: { 'article-001': { seconds: 0.5 } } });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await expect(page.locator('.mag-playlist-track')).toContainText('p. 3');
await expect(page.locator('.mag-playlist-track')).toContainText('The Future of Batteries');
await expect(page.locator('.mag-playlist-track')).toHaveClass(/is-in-progress/);
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await expect(page.locator('#mag-playlist-companion')).toBeVisible();
await page.locator('#mag-driving-open').click();
await expect(page.locator('#mag-driving')).toBeVisible();
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
await page.locator('#mag-driving .mag-driving-exit').click();
const exportPicker = page.locator('.vp-root[data-vp-for="mag-audio-voice"]');
await exportPicker.locator('.vp-trigger').click();
await exportPicker.locator('.vp-search').fill('magazine');
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
});
test('library lists a magazine and opens its contents tree', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await expect(page.locator('#mag-library-list .mag-card')).toHaveCount(1);
await page.locator('#mag-library-list .mag-card').first().click();
await expect(page.locator('#mag-view-contents')).toBeVisible();
await expect(page.locator('.mag-toc-title')).toHaveText('The Future of Batteries');
// Non-contiguous pages (a jump-linked continuation) must not read as a range.
await expect(page.locator('.mag-toc-row')).toContainText('pp. 3, 8');
await expect(page.locator('.mag-toc-row')).toContainText('90%');
await expect(page.locator('.mag-toc-row')).toContainText('not reviewed');
});
test('review screen shows layout blocks pre-selected and approves', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row');
await page.getByRole('button', { name: 'Review' }).click();
await expect(page.locator('#mag-view-review')).toBeVisible();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw region', exact: true })).toBeVisible();
// The article's own blocks (b1-b3) start ticked; nothing else does.
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries');
await page.getByRole('button', { name: 'Approve' }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
});
test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-view-read')).toBeVisible();
await expect(page.locator('#mag-read-title')).toHaveText('The Future of Batteries');
await expect(page.locator('#mag-read-by')).toHaveText('By Jane Smith');
// title (1) + byline (1) + body split into 2 sentences = 4 spans.
const sentences = page.locator('#mag-article [data-i]');
await expect(sentences).toHaveCount(4);
await expect(sentences.first()).toHaveClass(/is-current/);
// Click-to-seek moves the highlight and the progress counter.
await sentences.nth(3).click();
await expect(sentences.nth(3)).toHaveClass(/is-current/);
await expect(sentences.first()).not.toHaveClass(/is-current/);
await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4');
// Switching to the PDF pane shows the page the current sentence came from.
await page.getByRole('button', { name: 'Original page' }).click();
await expect(page.locator('#mag-pane-pdf')).toBeVisible();
await expect(page.locator('#mag-page-label')).toHaveText('Page 3');
await expect(page.locator('#mag-page-box')).toBeVisible();
});
test('narrator uses the searchable picker; Prepare audio synthesises every sentence once, ahead of playback', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const synthesised = [];
await page.route('**/api/tts-preview', route => {
synthesised.push(route.request().postDataJSON().text);
return route.fulfill({ body: silentWav(), contentType: 'audio/wav' });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
// The plain <select> is replaced by the app-wide searchable picker.
const picker = page.locator('.vp-root[data-vp-for="mag-voice"]');
await expect(picker).toBeVisible();
await expect(page.locator('#mag-voice')).toBeHidden();
await picker.locator('.vp-trigger').click();
await picker.locator('.vp-search').fill('zzz-no-such-voice');
await expect(picker.locator('.vp-empty')).toHaveText('No voices found');
await picker.locator('.vp-search').fill('magazine');
await expect(picker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
await picker.locator('.vp-search').fill('smoke');
await picker.locator('.vp-item[data-value="smoke_voice"]').click();
await expect(page.locator('#mag-voice')).toHaveValue('smoke_voice');
// Preparing synthesises each of the 4 sentences exactly once, before any playback.
expect(synthesised).toHaveLength(0);
await page.getByRole('button', { name: 'Prepare audio' }).click();
await expect.poll(() => synthesised.length).toBe(4);
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
// Listening afterwards is served entirely from the prepared audio.
await page.locator('#mag-play').click();
await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4', { timeout: 15_000 });
expect(synthesised).toHaveLength(4);
expect(new Set(synthesised).size).toBe(4);
});
test('Prepare audio without a narrator voice is refused and synthesises nothing', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
let calls = 0;
await page.route('**/api/tts-preview', route => { calls += 1; return route.fulfill({ body: silentWav(), contentType: 'audio/wav' }); });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
await page.getByRole('button', { name: 'Prepare audio' }).click();
await page.waitForTimeout(300);
expect(calls).toBe(0);
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
});
test('podcast generation is refused before the article is approved', async ({ page }) => {
await mockBackend(page, { articleStatus: 'detected' });
let podcastCalled = false;
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/podcast`, (route) => {
podcastCalled = true;
return route.fulfill({ status: 400, json: { detail: 'Approve the article before generating a podcast' } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row');
// Detected (unapproved) articles show Review only — no Listen/Continue,
// so there is no route into the read view (and podcast mode) at all.
await expect(page.locator('.mag-toc-row').getByRole('button', { name: /Listen|Continue/ })).toHaveCount(0);
expect(podcastCalled).toBe(false);
});
test('no console errors navigating the whole flow', async ({ page }) => {
const errors = [];
page.on('pageerror', (e) => errors.push(String(e)));
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await mockBackend(page, { articleStatus: 'approved' });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await page.waitForSelector('#mag-article [data-i]');
await page.getByRole('button', { name: 'Original page' }).click();
await page.waitForTimeout(300);
const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e));
expect(real, real.join('\n')).toHaveLength(0);
});
});
test.describe('Magazine pronunciation and spelling checks', () => {
const SPELL_ISSUE = {
token: 'Batterie', block_id: 'b3', page: 3, start: 0, end: 8, language: 'en',
suggestions: ['Battery'], context: 'Batterie technology is entering a new phase.',
};
async function openReadView(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.waitForSelector('#mag-library-list .mag-card');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await page.waitForSelector('#mag-article [data-i]');
}
test('the glossary is edited and saved, and a spelling finding is corrected with one click', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const writes = [];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/acronyms`, (route) => {
if (route.request().method() === 'PUT') writes.push(route.request().postDataJSON());
return route.fulfill({ json: { language: 'en', glossary: writes.length ? writes[0].glossary : {},
unknown: writes.length ? [] : [{ acronym: 'NAS', count: 2, context: 'The NAS stores everything.' }] } });
});
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
if (route.request().method() === 'POST') { writes.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); }
return route.fulfill({ json: { issues: writes.some((w) => w.fix) ? [] : [SPELL_ISSUE], dictionary: {} } });
});
await openReadView(page);
await page.locator('#mag-checks > summary').click();
const input = page.getByLabel('Spoken form of NAS');
await expect(input).toBeVisible();
await input.fill('network storage');
await input.blur();
await expect.poll(() => writes.find((w) => w.glossary)?.glossary).toEqual({ NAS: 'network storage' });
await page.getByRole('button', { name: 'Replace Batterie with Battery' }).click();
await expect.poll(() => writes.find((w) => w.fix)?.fix).toMatchObject(
{ block_id: 'b3', token: 'Batterie', replacement: 'Battery', start: 0, end: 8 });
await expect(page.locator('.mag-spell-list')).toHaveCount(0);
});
test('click a flagged word, type your own correction and press Enter; Escape cancels', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const issue = { token: 'dasich', raw: 'dasich', block_id: 'b3', page: 3, start: 0, end: 6, language: 'de',
suggestions: [], context: 'dasich mitentwickelt habe und heraus- gebe' };
let fixed = null;
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
if (route.request().method() === 'POST') { fixed = route.request().postDataJSON(); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); }
return route.fulfill({ json: { issues: fixed ? [] : [issue], dictionary: {} } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: /^Spelling/ }).click();
const panel = page.locator('#mag-spell-panel');
await panel.locator('mark.mag-spell-edit').click(); // Escape first: nothing is sent
const input = panel.locator('.mag-spell-input');
await expect(input).toHaveValue('dasich');
await expect(input).toBeFocused();
await page.keyboard.press('Escape');
await expect(panel.locator('.mag-spell-input')).toHaveCount(0);
expect(fixed).toBeNull();
await panel.getByRole('button', { name: 'Edit…' }).click(); // the button opens the same editor
await panel.locator('.mag-spell-input').fill('das ich');
await page.keyboard.press('Enter');
await expect.poll(() => fixed).toMatchObject({ block_id: 'b3', token: 'dasich', start: 0, end: 6, replacement: 'das ich' });
});
test('misspelled words are marked in the review list and can be kept in the dictionary', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
let kept = null;
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
if (route.request().method() === 'POST') { kept = route.request().postDataJSON(); return route.fulfill({ json: { dictionary: {} } }); }
return route.fulfill({ json: { issues: kept ? [] : [{ ...SPELL_ISSUE, token: 'entering', start: 22, end: 30, suggestions: [] }], dictionary: {} } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: /^Review/ }).click();
await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveText('entering');
await page.getByRole('button', { name: /^Spelling/ }).click();
await page.locator('#mag-spell-panel').getByRole('button', { name: 'Keep' }).click();
await expect.poll(() => kept).toEqual({ word: 'entering' });
await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveCount(0);
});
});
test.describe('Magazine: undo and redo', () => {
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
}
// A tiny stand-in for the server's history: one change that can be undone, then redone.
async function mockHistory(page, calls) {
let state = { can_undo: true, can_redo: false, undo_label: 'Deleted 2 regions', redo_label: '', undo_depth: 1, redo_depth: 0 };
await page.route(`**/api/magazine/${DOC_ID}/history`, route => route.fulfill({ json: state }));
await page.route(`**/api/magazine/${DOC_ID}/undo`, (route) => {
calls.push('undo');
state = { can_undo: false, can_redo: true, undo_label: '', redo_label: 'Deleted 2 regions', undo_depth: 0, redo_depth: 1 };
return route.fulfill({ json: { ok: true, label: 'Deleted 2 regions', ...state } });
});
await page.route(`**/api/magazine/${DOC_ID}/redo`, (route) => {
calls.push('redo');
state = { can_undo: true, can_redo: false, undo_label: 'Deleted 2 regions', redo_label: '', undo_depth: 1, redo_depth: 0 };
return route.fulfill({ json: { ok: true, label: 'Deleted 2 regions', ...state } });
});
}
test('Undo and Redo lead the second row of the sticky bar, on the left, and are disabled with nothing to do', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 });
await mockBackend(page);
await openReview(page);
const contents = await page.locator('#mag-review-contents-btn').boundingBox();
const undo = await page.locator('#mag-undo-btn').boundingBox();
const redo = await page.locator('#mag-redo-btn').boundingBox();
const fixOrder = await page.locator('#mag-reading-order-btn').boundingBox();
const rowsAbove = await page.evaluate(() => {
const bar = document.querySelector('#mag-review .mag-review-pagenav');
const tops = [...new Set([...bar.children].filter((c) => c.id !== 'mag-undo-group' && !c.classList.contains('mag-pagenav-break')).map((c) => Math.round(c.getBoundingClientRect().top / 4)))];
const undoTop = Math.round(document.getElementById('mag-undo-group').getBoundingClientRect().top / 4);
return tops.filter((t) => t < undoTop).length; // how many rows lie above the group
});
expect(rowsAbove).toBe(1); // exactly one row above: it is the second row
expect(undo.y).toBeGreaterThan(contents.y + 10);
expect(Math.abs(undo.x - contents.x)).toBeLessThan(4); // at the left edge of the bar
expect(redo.x).toBeGreaterThan(undo.x);
expect(Math.abs(redo.y - undo.y)).toBeLessThan(4);
expect(fixOrder.y).toBeGreaterThan(contents.y + 10);
await expect(page.locator('#mag-undo-btn')).toBeDisabled();
await expect(page.locator('#mag-redo-btn')).toBeDisabled();
await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', 'Nothing to undo');
});
test('Undo, then Redo, call the server and swap which button is enabled', async ({ page }) => {
await mockBackend(page);
const calls = [];
await mockHistory(page, calls);
await openReview(page);
await expect(page.locator('#mag-undo-btn')).toBeEnabled();
await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', /Undo: Deleted 2 regions/);
await page.locator('#mag-undo-btn').click();
await expect.poll(() => calls).toEqual(['undo']);
await expect(page.locator('#mag-redo-btn')).toBeEnabled();
await expect(page.locator('#mag-undo-btn')).toBeDisabled();
await page.locator('#mag-redo-btn').click();
await expect.poll(() => calls).toEqual(['undo', 'redo']);
await expect(page.locator('#mag-undo-btn')).toBeEnabled();
});
test('Ctrl+Z and Ctrl+Shift+Z work in review but leave typing in a text field alone', async ({ page }) => {
await mockBackend(page);
const calls = [];
await mockHistory(page, calls);
await openReview(page);
await page.locator('#mag-rev-title').click();
await page.keyboard.press('Control+z');
await page.waitForTimeout(300);
expect(calls).toEqual([]); // native undo of typing, not a magazine undo
await page.locator('#mag-review .mag-review-page').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('Control+z');
await expect.poll(() => calls).toEqual(['undo']);
await expect(page.locator('#mag-redo-btn')).toBeEnabled(); // the undo has finished reloading
await page.keyboard.press('Control+Shift+z');
await expect.poll(() => calls).toEqual(['undo', 'redo']);
});
});
test('the Contents button is the first item of the sticky review bar and goes back', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
const bar = page.locator('#mag-review .mag-review-pagenav');
await expect(bar.locator('> :first-child')).toHaveId('mag-review-contents-btn');
await expect(page.locator('#mag-view-review > .mag-bar').getByRole('button', { name: 'Contents' })).toHaveCount(0);
await bar.getByRole('button', { name: 'Contents' }).click();
await expect(page.locator('#mag-view-contents')).toBeVisible();
});
test.describe('Magazine: thumbnail as the Review target', () => {
async function openGrid(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
return page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' });
}
test('a page with one article has a single Review target: its thumbnail', async ({ page }) => {
await mockBackend(page);
const card = await openGrid(page);
await expect(card.getByRole('button', { name: /Review/ })).toHaveCount(1);
await card.getByRole('button', { name: 'Review page 3' }).click();
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
});
test('a page shared by two articles keeps one labelled Review button per article', async ({ page }) => {
await mockBackend(page);
const second = { ...ARTICLE_DETECTED, id: 'article-002', title: 'A Second Piece', pages: [3], blocks: [] };
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles: [{ ...ARTICLE_DETECTED, has_audio: false }, second], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
const card = await openGrid(page);
await expect(card.getByRole('button', { name: 'Review page 3' })).toHaveCount(1);
await expect(card.getByRole('button', { name: 'Review A Second Piece' })).toHaveCount(1);
await expect(card.getByRole('button', { name: 'Review The Future of Batteries' })).toHaveCount(1);
});
});
test.describe('Magazine: page colours', () => {
async function openGrid(page, articleStatus) {
await mockBackend(page, { articleStatus });
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: {
summary: {}, pages: [{ page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [{ id: 'b3', side: 'right' }],
measured: true, spelling: [], needs_review: true, words_to_check: false }] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
return page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).locator('.mag-card');
}
test('a flagged page nobody has approved is purple', async ({ page }) => {
await expect(await openGrid(page, 'detected')).toHaveClass(/is-page-flagged/);
});
test('an old MP3 on an unapproved article does not turn a flagged page green', async ({ page }) => {
await mockBackend(page, { articleStatus: 'detected', hasAudio: true });
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: {
summary: {}, pages: [{ page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [{ id: 'b3', side: 'right' }],
measured: true, spelling: [], needs_review: true, words_to_check: false }] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
const card = page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).locator('.mag-card');
await expect(card).toHaveClass(/is-page-flagged/);
await expect(card).not.toHaveClass(/is-page-audio/);
});
test('once the page\'s article is approved the approval wins over the flag', async ({ page }) => {
const card = await openGrid(page, 'approved');
await expect(card).not.toHaveClass(/is-page-flagged/);
await expect(card).toHaveClass(/is-page-approved/);
});
});
test.describe('Magazine: unreviewed articles', () => {
async function openContents(page, opts) {
await mockBackend(page, opts);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row');
}
test('an article nobody has approved is blue and says "not reviewed"', async ({ page }) => {
await openContents(page, { articleStatus: 'detected' });
const row = page.locator('.mag-toc-row');
await expect(row).toHaveClass(/is-unreviewed/);
await expect(row.locator('.mag-pill.unreviewed')).toHaveText('not reviewed');
const edge = await row.evaluate((el) => getComputedStyle(el).borderLeftColor);
expect(edge).toBe('rgb(37, 99, 235)'); // the blue edge
const bg = await row.evaluate((el) => getComputedStyle(el).backgroundColor);
expect(bg).not.toBe('rgba(0, 0, 0, 0)'); // and a tinted background, not transparent
});
test('an approved article is not blue', async ({ page }) => {
await openContents(page, { articleStatus: 'approved' });
await expect(page.locator('.mag-toc-row')).not.toHaveClass(/is-unreviewed/);
await expect(page.locator('.mag-toc-row')).toHaveClass(/is-approved/);
});
});
test.describe('Magazine: what is wrong on this page', () => {
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: /^Review/ }).click();
}
test('each problem gets its own marker on the page and a plain-language row with a fix', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/pages/*/audit`, route => route.fulfill({ json: {
page: 3, uncovered: [[300, 200, 100, 50]], uncovered_ratio: 0.02, suspect_blocks: ['b3'], overlaps: [['b1', 'b2']],
clipped: [{ id: 'b3', side: 'right' }], measured: true, spelling: 2, advertisement: false } }));
await openReview(page);
const panel = page.locator('#mag-issues-panel');
await expect(panel).toContainText('What is wrong on this page');
await expect(panel).toContainText('Text outside any box');
await expect(panel).toContainText('Edge cuts text');
await expect(panel).toContainText('Overlaps another box');
await expect(panel).not.toContainText('mis-read');
await expect(panel).toContainText('Unknown words');
await expect(page.locator('#mag-review-page-wrap .mag-issue-box.is-uncovered')).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-clipped.side-right')).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-overlap')).toHaveCount(2);
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-suspect')).toHaveCount(0);
});
test('Fix this page automatically starts the job, offers Undo and reloads the page', async ({ page }) => {
await mockBackend(page);
let started = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/autofix`, (route) => {
started = route.request().postDataJSON();
return route.fulfill({ json: { job_id: 'fix1' } });
});
await page.route('**/api/jobs/fix1', route => route.fulfill({ json: {
done: true, detail: 'Page 3: 2 fixes applied', result: { boxes_fitted: 2, undo: 'tok' } } }));
await openReview(page);
await page.getByRole('button', { name: 'Fix this page automatically' }).click();
await expect.poll(() => started).toEqual({ mode: 'all' });
await expect(page.locator('#mag-undo-bar')).toContainText('Auto-fixed page 3');
});
test('a clean page says so', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await expect(page.locator('#mag-issues-panel')).toContainText('Nothing wrong found on this page');
});
});
test.describe('Magazine: text flow indicator', () => {
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
.getByRole('button', { name: /^Review/ }).click();
}
test('review draws an arrow for a continuation on the page and chips for the other pages and the ends', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
links: [
{ from: 'b1', to: 'b3', relation: 'column', from_page: 3, to_page: 3 },
{ from: 'b3', to: 'x9', relation: 'page', from_page: 3, to_page: 8 },
{ from: 'y1', to: 'b2', relation: 'page', from_page: 2, to_page: 3 }],
first: 'b1', last: 'x9' } }));
await openReview(page);
await expect(page.locator('#mag-review-page-wrap .mag-flow-path')).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-label')).toHaveText('next column');
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'continues on page 8' })).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'from page 2' })).toHaveCount(1);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article starts' })).toHaveCount(1);
await page.getByLabel('Show text flow').uncheck();
await expect(page.locator('#mag-review-page-wrap .mag-flow-path, #mag-review-page-wrap .mag-flow-chip')).toHaveCount(0);
});
test('an article without a signed end offers to link the next page instead of claiming it ends', async ({ page }) => {
await mockBackend(page);
let linked = null;
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
links: [], first: 'b1', last: 'b3', last_page: 3, ended: false } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/link-page`, (route) => {
linked = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok', added: ['b9'], page: 4 } });
});
await openReview(page);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article ends' })).toHaveCount(0);
await page.getByRole('button', { name: /no end marker — link page 4/ }).click();
await expect.poll(() => linked).toEqual({ page: 4, where: 'after' });
});
test('a signed end says the article ends', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
links: [], first: 'b1', last: 'b3', last_page: 3, ended: true } }));
await openReview(page);
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article ends' })).toHaveCount(1);
});
test('the reading text marks where a paragraph crosses a column or a page', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
const text = 'Der Satz beginnt hier und geht auf der nächsten Seite weiter, bis er endet.';
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: {
article: ARTICLE_APPROVED, blocks: [{
id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text, spoken_text: text, reading_order: 2,
parts: [{ id: 'b3', page: 3, bbox: [50, 150, 200, 300], start: 0, end: 30 },
{ id: 'z1', page: 4, bbox: [50, 60, 200, 300], start: 31, end: text.length }] }] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row.is-approved');
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
const seam = page.locator('#mag-article .mag-seam');
await expect(seam).toHaveCount(1);
await expect(seam).toHaveText('p.4 ›');
await expect(seam).toHaveAttribute('title', 'Continues on page 4');
});
});
test('an article is linked to a page it was not connected to, from the review panel', async ({ page }) => {
await mockBackend(page);
let body = null;
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/link-page`, (route) => {
body = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok', added: ['b9', 'b10'], page: 4 } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-issues-panel .mag-issues-head');
await page.getByLabel('Link to page').fill('4');
await page.getByRole('button', { name: 'Article continues there' }).click();
await expect.poll(() => body).toEqual({ page: 4, where: 'after' });
await expect(page.locator('#mag-review .mag-review-pagenav-group strong')).toContainText('Page 4');
});
test('the draw panel stays fully inside the window even for a box drawn at the bottom of the page', async ({ page }) => {
await page.setViewportSize({ width: 1300, height: 700 });
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await scrollPageImageBelowToolbar(page, 300);
// now scroll further so the bottom of the page image is at the bottom edge of the window
await page.locator('#mag-review-page-wrap').evaluate(el => window.scrollBy(0, el.getBoundingClientRect().bottom - window.innerHeight + 4));
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
const footerTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
const bottom = Math.min(wrap.y + wrap.height, footerTop) - 6; // lowest point of the image not under the footer
await page.mouse.move(wrap.x + 40, bottom - 60);
await page.mouse.down();
await page.mouse.move(wrap.x + 240, bottom, { steps: 5 });
await page.mouse.up();
const panel = page.locator('.mag-draw-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(700);
expect(box.x + box.width).toBeLessThanOrEqual(1300);
await expect(page.getByRole('button', { name: 'Add region', exact: true })).toBeInViewport();
await page.getByRole('button', { name: 'Cancel', exact: true }).click(); // a real click: fails if something covers it
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => {
await mockBackend(page);
let cropped = false;
const image = { id: 'manual-chart', page: 3, bbox: [40, 110, 220, 350], filename: 'manual-chart.png', reading_order: 4 };
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({
json: { ...LAYOUT, images: cropped ? [image] : [] },
}));
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions`, route => {
expect(route.request().postDataJSON().kind).toBe('image');
cropped = true;
return route.fulfill({ json: { job_id: 'crop-job' } });
});
await page.route('**/api/jobs/crop-job', route => route.fulfill({ json: { done: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await scrollPageImageBelowToolbar(page, 400);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842);
await page.mouse.down();
await page.mouse.move(wrap.x + wrap.width * 260 / 595, wrap.y + wrap.height * 460 / 842, { steps: 5 });
await page.mouse.up();
await page.locator('#mag-draw-kind-search').fill('Image');
await page.locator('#mag-draw-kind-list').getByRole('option', { name: 'Image', exact: true }).click();
await expect(page.locator('#mag-draw-kind')).toHaveValue('image');
await expect(page.locator('#mag-draw-text')).toBeHidden();
await page.getByRole('button', { name: 'Add region', exact: true }).click();
await expect(page.locator('[data-block-row="manual-chart"]')).toContainText('Shown during playback');
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked();
await expect(page.locator('#mag-review input[data-block="b2"]')).not.toBeChecked();
await expect(page.locator('#mag-review input[data-block="b3"]')).not.toBeChecked();
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
expect((await request).postDataJSON()).toMatchObject({ images: ['manual-chart'], blocks: ['b1'] });
});
test('dropping a PDF file onto mag-dropzone triggers creation without browser navigation', async ({ page }) => {
await mockBackend(page);
let uploadCalled = false;
await page.route('**/api/magazine/docs', route => {
uploadCalled = true;
return route.fulfill({ json: { id: DOC_ID } });
});
await page.route(`**/api/reader/docs/${DOC_ID}/source*`, route => route.fulfill({ json: { ok: true } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const dropzone = page.locator('#mag-dropzone');
await expect(dropzone).toBeVisible();
const dataTransfer = await page.evaluateHandle(() => {
const dt = new DataTransfer();
const file = new File(['%PDF-1.4 dummy pdf content'], 'ct_KI_Wissen_2026.pdf', { type: 'application/pdf' });
dt.items.add(file);
return dt;
});
await dropzone.dispatchEvent('dragenter', { dataTransfer });
await dropzone.dispatchEvent('dragover', { dataTransfer });
await dropzone.dispatchEvent('drop', { dataTransfer });
await page.waitForTimeout(300);
expect(uploadCalled).toBe(true);
});
async function openReview(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click();
}
test('a region type can be changed from the review list without toggling its checkbox', async ({ page }) => {
await mockBackend(page);
let changedTo = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b2`, route => {
expect(route.request().method()).toBe('PATCH');
changedTo = route.request().postDataJSON().type;
return route.fulfill({ json: { ok: true, id: 'b2', type: changedTo } });
});
await openReview(page);
const row = page.locator('[data-block-row="b2"]');
const checkbox = page.locator('#mag-review input[data-block="b2"]');
const before = await checkbox.isChecked();
const picker = row.getByRole('combobox', { name: /Type of region/ });
await expect(picker).toHaveValue('Author');
await picker.click();
await expect(picker).toHaveValue('');
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row
await row.getByRole('option', { name: 'Page Number', exact: true }).click();
await expect.poll(() => changedTo).toBe('page_number');
});
test('the icon picker offers every server type in editorial order', async ({ page }) => {
await mockBackend(page);
await openReview(page);
const row = page.locator('[data-block-row="b3"]');
await row.getByRole('combobox', { name: /Type of region/ }).click();
const values = await row.locator('.mag-type-picker-option').evaluateAll(
options => options.map(o => o.dataset.value));
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'image', 'caption', 'in_picture',
'table', 'quote', 'pullquote', 'sidebar', 'ad', 'page_number', 'source', 'duplicate', 'ocr_noise']);
await expect(row.locator('.mag-type-picker-option .mdi')).toHaveCount(values.length);
});
test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => {
await mockBackend(page);
let deleted = false, undone = false;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
expect(route.request().method()).toBe('DELETE');
deleted = true;
return route.fulfill({ json: { ok: true, undo: 'tok-1' } });
});
await page.route(`**/api/magazine/${DOC_ID}/undo`, route => {
expect(route.request().postDataJSON()).toEqual({ token: 'tok-1' });
undone = true;
return route.fulfill({ json: { ok: true } });
});
await openReview(page);
const button = page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ });
const [btn, text] = [await button.boundingBox(), await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox()];
// Wide screens: the button sits left of the text. Phones drop the text onto its own line, so the
// button must be above it instead.
if (text.y >= btn.y + btn.height - 1) expect(btn.y + btn.height).toBeLessThanOrEqual(text.y + 1);
else expect(btn.x + btn.width).toBeLessThanOrEqual(text.x);
await button.click();
await expect.poll(() => deleted).toBe(true);
await expect(page.getByRole('alertdialog')).toHaveCount(0);
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
await page.locator('#mag-undo-bar').getByRole('button', { name: 'Undo', exact: true }).click();
await expect.poll(() => undone).toBe(true);
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
});
test('fix reading order asks first, then reports how many pages changed', async ({ page }) => {
await mockBackend(page);
let called = false;
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => {
called = true;
return route.fulfill({ json: { ok: true, pages_changed: [3, 5], pages_locked: 1 } });
});
await openReview(page);
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
const dialog = page.getByRole('alertdialog', { name: 'Fix reading order?' });
await expect(dialog).toContainText('Pages you ordered by hand are kept');
expect(called).toBe(false);
await dialog.getByRole('button', { name: 'Fix reading order', exact: true }).click();
await expect.poll(() => called).toBe(true);
await expect(page.getByText(/Reading order fixed on 2 pages \(1 hand-ordered page kept\)/)).toBeVisible();
});
test('typing a position moves a region and saves the whole page order', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
expect(route.request().method()).toBe('PUT');
sent = route.request().postDataJSON().order;
return route.fulfill({ json: { ok: true, locked: true } });
});
await openReview(page);
const position = page.getByRole('spinbutton', { name: /Reading position of region 3/ });
await position.fill('1');
await position.press('Enter');
await expect.poll(() => sent).toEqual(['b3', 'b1', 'b2']);
});
test('order by clicking: boxes clicked on the page are saved in click order', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
sent = route.request().postDataJSON().order;
return route.fulfill({ json: { ok: true, locked: true } });
});
await openReview(page);
const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked();
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
await expect(page.locator('#mag-order-bar')).toContainText('0 of 3 set');
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await expect(page.locator('#mag-order-bar')).toContainText('2 of 3 set');
await expect(page.locator('#mag-review-page-wrap [data-block="b2"] .mag-review-box-num')).toHaveText('1');
// Clicking boxes to order them must not tick or untick them.
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked });
await page.getByRole('button', { name: 'Save order', exact: true }).click();
await expect.poll(() => sent).toEqual(['b2', 'b1']);
});
test('order by clicking: Save order stays on screen however far the page is scrolled', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const save = page.getByRole('button', { name: 'Save order', exact: true });
await expect(save).toBeVisible();
await expect(save).toBeDisabled(); // nothing numbered yet
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await expect(save).toBeEnabled();
// The app scrolls inside its own container, not the window. Find it, make the review long, and scroll that.
const scroller = await page.evaluateHandle(() => {
let el = document.getElementById('mag-review');
while (el && el !== document.documentElement) {
const oy = getComputedStyle(el).overflowY;
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight - 1) return el;
el = el.parentElement;
}
return document.scrollingElement;
});
await scroller.evaluate((el) => { const f = document.createElement('div'); f.style.height = '6000px'; f.id = 'test-filler'; (el === document.scrollingElement ? document.body : el).appendChild(f); });
const scrollBy = async (top) => scroller.evaluate((el, y) => { el.scrollTo(0, y); return el.scrollTop; }, top);
expect(await scrollBy(4000)).toBeGreaterThan(1000); // proves the container really scrolled
for (const y of [0, 1500, 4000, 6500]) {
await scrollBy(y);
await expect(save).toBeInViewport({ ratio: 1 });
}
// It floats in a corner: a proper touch target, inside the viewport, clear of the toolbar.
const box = await save.boundingBox();
const vp = page.viewportSize();
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
expect(box.height).toBeGreaterThanOrEqual(44);
await scrollBy(0);
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toBeInViewport();
});
test('order by clicking: progress, a ready state when every box is numbered, and Esc to cancel', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
const bar = page.locator('#mag-order-bar');
await expect(bar).toHaveClass(/is-floating/);
await expect(bar.locator('progress')).toHaveAttribute('value', '0');
for (const id of ['b1', 'b2', 'b3']) await page.locator(`#mag-review-page-wrap [data-block="${id}"]`).click();
await expect(bar.locator('progress')).toHaveAttribute('value', '3');
await expect(bar).toContainText('Every box is numbered');
await expect(page.getByRole('button', { name: 'Save order', exact: true })).toHaveClass(/is-ready/);
await page.keyboard.press('Escape');
await expect(bar).not.toHaveClass(/is-floating/);
await expect(bar).toBeEmpty();
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toHaveAttribute('aria-pressed', 'false');
});
test('order by clicking: Esc inside a position field does not cancel the numbering', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await page.getByRole('spinbutton', { name: /Reading position of region 2/ }).focus();
await page.keyboard.press('Escape');
await expect(page.locator('#mag-order-bar')).toHaveClass(/is-floating/);
});
test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => {
await mockBackend(page);
let reset = false;
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({
json: { ...LAYOUT, pages: [{ ...LAYOUT.pages[0], order_locked: !reset }] },
}));
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
expect(route.request().method()).toBe('DELETE');
reset = true;
return route.fulfill({ json: { ok: true, locked: false } });
});
await openReview(page);
await expect(page.locator('#mag-order-bar')).toContainText('set by hand');
await page.getByRole('button', { name: 'Automatic order', exact: true }).click();
await expect.poll(() => reset).toBe(true);
await expect(page.locator('#mag-order-bar')).not.toContainText('set by hand');
});
test('clean up boxes asks first and reports what it changed', async ({ page }) => {
await mockBackend(page);
let called = false;
await page.route(`**/api/magazine/${DOC_ID}/boxes/clean-up`, route => {
called = true;
return route.fulfill({ json: { ok: true, backup: 'layout.backup-20260921T120000.json',
counts: { in_picture: 71, ocr_noise: 25, duplicate: 38, byline_split: 5 }, report: {} } });
});
await openReview(page);
await page.getByRole('button', { name: 'Clean up boxes', exact: true }).click();
const dialog = page.getByRole('alertdialog', { name: 'Clean up boxes?' });
await expect(dialog).toContainText('Nothing is deleted');
expect(called).toBe(false);
await dialog.getByRole('button', { name: 'Clean up', exact: true }).click();
await expect(page.getByText(/71 text in pictures, 25 OCR noise, 38 duplicates, 5 author lines split/)).toBeVisible();
});
test('a box marked as text in a picture shows that type in its picker', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT,
blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } }));
await openReview(page);
await expect(page.locator('[data-block-row="b3"] .mag-block-type-select')).toHaveValue('in_picture');
});
test('a region text can be corrected and split at the cursor from the list', async ({ page }) => {
await mockBackend(page);
const calls = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3**`, route => {
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
return route.fulfill({ json: { ok: true, id: 'b3', new_id: 't0003-000' } });
});
await openReview(page);
const row = page.locator('[data-block-row="b3"]');
await row.getByRole('button', { name: /Edit text \(region 3\)/ }).click();
const area = row.getByRole('textbox', { name: 'Text of this region' });
await expect(area).toHaveValue('Battery technology is entering a new phase. It will take years to mature.');
// Typing letters that are shortcuts elsewhere must stay in the editor.
await area.press('End');
await area.type(' mr');
expect(calls).toEqual([]);
await row.getByRole('button', { name: 'Save text', exact: true }).click();
await expect.poll(() => calls.length).toBe(1);
expect(calls[0]).toMatchObject({ method: 'PATCH', body: { text: 'Battery technology is entering a new phase. It will take years to mature. mr' } });
await row.getByRole('button', { name: /Edit text \(region 3\)/ }).click();
const again = row.getByRole('textbox', { name: 'Text of this region' });
await again.evaluate(el => el.setSelectionRange(43, 43));
await row.getByRole('button', { name: 'Split here', exact: true }).click();
await expect.poll(() => calls.length).toBe(2);
expect(calls[1].url).toContain('/regions/b3/split');
expect(calls[1].body).toEqual({ at: 43 });
});
test('merge with next and read again are one click away', async ({ page }) => {
await mockBackend(page);
const calls = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b2**`, route => {
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
return route.fulfill({ json: { ok: true, id: 'b2', removed: 'b3' } });
});
await openReview(page);
const row = page.locator('[data-block-row="b2"]');
await row.getByRole('button', { name: /Merge with the next region/ }).click();
await expect.poll(() => calls.length).toBe(1);
expect(calls[0].url).toContain('/regions/b2/merge-next');
await row.getByRole('button', { name: /Read again/ }).click();
await expect.poll(() => calls.length).toBe(2);
expect(calls[1]).toMatchObject({ method: 'PATCH', body: { reocr: true } });
// The last region has nothing after it to merge with.
await expect(page.locator('[data-block-row="b3"]').getByRole('button', { name: /Merge with the next region/ })).toHaveCount(0);
});
test('the review list can be worked from the keyboard', async ({ page }) => {
await mockBackend(page);
const calls = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/**`, route => {
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
return route.fulfill({ json: { ok: true } });
});
await openReview(page);
await page.locator('#mag-review input[data-block="b1"]').focus();
await page.keyboard.press('ArrowDown');
await expect(page.locator('#mag-review input[data-block="b2"]')).toBeFocused();
await page.keyboard.press('r');
await expect.poll(() => calls.length).toBe(1);
expect(calls[0].url).toContain('/regions/b2');
expect(calls[0].body).toEqual({ reocr: true });
await page.keyboard.press('ArrowDown');
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeFocused();
await page.keyboard.press('Enter');
await expect(page.locator('[data-block-row="b3"]').getByRole('textbox', { name: 'Text of this region' })).toBeFocused();
});
test('approving after turning a ticked text box into an image sends the new image, not the old id', async ({ page }) => {
await mockBackend(page);
let converted = false;
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: converted
? { ...LAYOUT, blocks: LAYOUT.blocks.filter(b => b.id !== 'b3'), images: [{ id: 'p0003-manual-000', page: 3, bbox: [50, 150, 200, 300], filename: 'x.png', reading_order: 2 }] }
: LAYOUT }));
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
converted = true;
return route.fulfill({ json: { ok: true, id: 'p0003-manual-000', type: 'image' } });
});
await openReview(page);
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeChecked();
const picker = page.locator('[data-block-row="b3"]').getByRole('combobox', { name: /Type of region/ });
await picker.click();
await page.locator('[data-block-row="b3"]').getByRole('option', { name: 'Image', exact: true }).click();
await expect(page.locator('[data-block-row="p0003-manual-000"]')).toBeVisible();
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
const body = (await request).postDataJSON();
expect(body.blocks).not.toContain('b3');
expect(body.images).toContain('p0003-manual-000');
});
test('approving after deleting a ticked box does not send its id', async ({ page }) => {
await mockBackend(page);
let deleted = false;
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: deleted
? { ...LAYOUT, blocks: LAYOUT.blocks.filter(b => b.id !== 'b3') } : LAYOUT }));
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { deleted = true; return route.fulfill({ json: { ok: true } }); });
await openReview(page);
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
await expect(page.locator('[data-block-row="b3"]')).toHaveCount(0);
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
expect((await request).postDataJSON().blocks).not.toContain('b3');
});
test('select boxes by dragging a rectangle, then delete them in one go with undo', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
sent = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok-2', remap: { b2: null, b3: null }, count: 2 } });
});
await openReview(page);
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
await expect(page.locator('#mag-select-bar')).toContainText('drag a rectangle');
await scrollPageImageBelowToolbar(page, 400);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
// b2 [50,120,200,14] and b3 [50,150,200,300] on a 595x842 page; b1 (the headline at y 60) stays out.
const at = (x, y) => [wrap.x + wrap.width * x / 595, wrap.y + wrap.height * y / 842];
await page.mouse.move(...at(40, 112));
await page.mouse.down();
await page.mouse.move(...at(270, 470), { steps: 6 });
await page.mouse.up();
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
await expect(page.locator('#mag-review-page-wrap [data-block="b1"]')).not.toHaveClass(/is-selected/);
await page.locator('#mag-select-bar').getByRole('button', { name: /^Delete/ }).click();
await expect.poll(() => sent).toEqual({ ids: expect.arrayContaining(['b2', 'b3']), action: 'delete' });
await expect(page.locator('#mag-undo-bar')).toContainText('Deleted 2 boxes.');
});
test('clicking boxes in select mode builds a selection whose type changes at once', async ({ page }) => {
await mockBackend(page);
let sent = null;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
sent = route.request().postDataJSON();
return route.fulfill({ json: { ok: true, undo: 'tok-3', remap: {}, count: 2 } });
});
await openReview(page);
const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked();
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
// Selecting is not ticking: the include checkboxes stay as they were.
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked });
await page.locator('#mag-bulk-type').selectOption('ocr_noise');
await expect.poll(() => sent).toEqual({ ids: ['b1', 'b2'], action: 'type', type: 'ocr_noise' });
});
test('an action without its own undo point clears the Undo button', async ({ page }) => {
await mockBackend(page);
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => route.fulfill({ json: { ok: true, undo: 'tok-9' } }));
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => route.fulfill({ json: { ok: true, pages_changed: [], pages_locked: 0 } }));
await openReview(page);
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Fix reading order', exact: true }).click();
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
});
test('the live preview draws the page regions over the page image as they arrive', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
// A 100x200 page image in a frame much wider than the page: the boxes must follow the image,
// not the frame (contain scaling leaves bars left and right).
await page.evaluate(async () => {
const preview = document.getElementById('mag-ingest-preview');
preview.hidden = false;
preview.style.width = '800px';
const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200;
const img = document.getElementById('mag-ingest-preview-img');
img.style.height = '1400px';
await new Promise(r => { img.onload = r; img.src = canvas.toDataURL('image/png'); });
});
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
], 'reading'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(2);
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('5 — reading');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('Headline');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('2 regions');
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('marked in the second pass');
const [imgBox, layerBox] = await Promise.all([
page.locator('#mag-ingest-preview-img').boundingBox(), page.locator('#mag-ingest-preview-boxes').boundingBox()]);
// The layer has the page's 1:2 shape, is centred across the wider frame and sits at its top.
expect(Math.abs(layerBox.height - 2 * layerBox.width)).toBeLessThan(2);
expect(Math.abs((layerBox.x + layerBox.width / 2) - (imgBox.x + imgBox.width / 2))).toBeLessThan(2);
expect(Math.abs(layerBox.y - imgBox.y)).toBeLessThan(2);
// A region that appears on the next poll is marked new; the ones already shown are not.
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
{ x: 50, y: 20, w: 50, h: 50, type: 'ad', n: 3 },
], 'labelling'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box.is-new')).toHaveCount(1);
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('labelling regions');
await expect(page.locator('#mag-ingest-preview-legend')).not.toContainText('second pass');
// Pictures, charts and tables each get their own entry; two kinds of picture share one.
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
{ x: 0, y: 0, w: 50, h: 30, type: 'figure', n: 1 }, { x: 50, y: 0, w: 50, h: 30, type: 'image', n: null },
{ x: 0, y: 40, w: 50, h: 30, type: 'chart', n: 2 }, { x: 50, y: 40, w: 50, h: 30, type: 'table', n: 3 },
{ x: 0, y: 0, w: 100, h: 100, type: 'image', n: null },
], 'labelling'));
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(4); // not the page scan
const legend = page.locator('#mag-ingest-preview-legend .mag-preview-legend-item');
await expect(legend).toHaveText(['Picture', 'Chart', 'Table']);
const colour = (sel) => page.locator(sel).evaluate((el) => getComputedStyle(el).borderTopColor);
const [pic, chart, table] = await Promise.all(['.mpt-figure', '.mpt-chart', '.mpt-table'].map((t) => colour('#mag-ingest-preview-boxes ' + t)));
expect(new Set([pic, chart, table]).size).toBe(3);
});
test('the contents list shows synthesis state with the MP3 length and plays inline', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
const row = page.locator('.mag-toc-row').first();
await expect(row).toContainText('Synthesised · 0:02');
const play = row.getByRole('button', { name: /^Play / });
await expect(play).toHaveAttribute('aria-pressed', 'false');
await play.click();
await expect(row.getByRole('button', { name: /^Pause / })).toHaveAttribute('aria-pressed', 'true');
});
test('the contents list marks an approved article without audio as not synthesised', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
const row = page.locator('.mag-toc-row').first();
await expect(row).toContainText('Not synthesised');
await expect(row.getByRole('button', { name: /^Play / })).toHaveCount(0);
});
test('finding an issue online: search, licence shown, import starts ingestion', async ({ page }) => {
await mockBackend(page);
const imported = [];
await page.route('**/api/magazine-sources', route => route.fulfill({
json: { sources: [{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' }] } }));
await page.route('**/api/magazine-sources/internet-archive/search*', route => route.fulfill({
json: { source: 'internet-archive', items: [
{ id: 'sci-am-1923', title: 'Scientific American 1923', author: 'Munn', date: '1923-01-01', summary: 'Vol 128',
license: 'https://creativecommons.org/publicdomain/mark/1.0/', page_url: 'https://archive.org/details/sci-am-1923' },
{ id: 'other', title: '<img src=x onerror=alert(1)>', license: '' }] } }));
await page.route('**/api/magazine-sources/import', route => {
imported.push(route.request().postDataJSON());
route.fulfill({ json: { id: DOC_ID, job_id: 'job1' } });
});
await page.route('**/api/jobs/job1', route => route.fulfill({ json: { done: true, error: null, detail: 'Downloaded' } }));
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
await page.locator('#mag-find-query').fill('scientific american');
await page.getByRole('button', { name: 'Search', exact: true }).click();
await expect(page.locator('#mag-find-status')).toContainText('2 results');
await expect(page.locator('.mag-find-item').first()).toContainText('publicdomain');
await expect(page.locator('.mag-find-item').nth(1)).toContainText('Licence not stated');
// Titles from a remote source are text, never markup.
await expect(page.locator('.mag-find-item img')).toHaveCount(0);
await page.getByRole('button', { name: 'Import Scientific American 1923' }).click();
await expect.poll(() => imported.length).toBe(1);
expect(imported[0]).toMatchObject({ source: 'internet-archive', item_id: 'sci-am-1923' });
});
const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
async function openMagazineLibrary(page) {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await expect(page.locator('#mag-new-file')).toBeAttached();
}
test('several page images are uploaded together, in filename order', async ({ page }) => {
await openMagazineLibrary(page);
const posted = [];
await page.route('**/api/magazine/docs', route => route.fulfill({ json: { id: DOC_ID } }));
await page.route(`**/api/magazine/${DOC_ID}/source-images`, route => {
posted.push(route.request().postDataBuffer().toString('latin1'));
route.fulfill({ json: { ok: true, pages: 3 } });
});
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
await page.locator('#mag-new-file').setInputFiles([
{ name: 'page10.png', mimeType: 'image/png', buffer: TINY_PNG },
{ name: 'page2.png', mimeType: 'image/png', buffer: TINY_PNG },
{ name: 'page1.png', mimeType: 'image/png', buffer: TINY_PNG },
]);
await expect.poll(() => posted.length).toBe(1);
const names = [...posted[0].matchAll(/filename="([^"]+)"/g)].map(m => m[1]);
expect(names).toEqual(['page1.png', 'page2.png', 'page10.png']);
});
test('mixing a PDF with images, or choosing another type, is refused before anything is created', async ({ page }) => {
await openMagazineLibrary(page);
let created = 0;
await page.route('**/api/magazine/docs', route => { created++; route.fulfill({ json: { id: DOC_ID } }); });
await page.locator('#mag-new-file').setInputFiles([
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4') },
{ name: 'b.png', mimeType: 'image/png', buffer: TINY_PNG },
]);
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: /PDF|images/ }).first()).toBeVisible();
await page.locator('#mag-new-file').setInputFiles([{ name: 'c.gif', mimeType: 'image/gif', buffer: Buffer.from('GIF89a') }]);
await page.waitForTimeout(300);
expect(created).toBe(0);
});
test.describe('Edited text in review', () => {
async function openReviewWithEditedBlock(page) {
await mockBackend(page);
const edited = { ...LAYOUT, blocks: LAYOUT.blocks.map(b => b.id === 'b3'
? { ...b, ocr_text: 'Battery technolgy is entering a new phase. It will take years to mature.' } : b) };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({ json: edited }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await expect(page.locator('[data-block-row="b3"]')).toBeVisible();
}
test('a hand-edited region is marked and shows a word diff against the recognised text', async ({ page }) => {
await openReviewWithEditedBlock(page);
const row = page.locator('[data-block-row="b3"]');
await expect(row).toContainText('Edited');
await expect(page.locator('[data-block-row="b1"]')).not.toContainText('Edited');
const toggle = row.locator('.mag-edited button').first();
await expect(toggle).toHaveText('Show changes');
await toggle.click();
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
await expect(row.locator('.mag-diff del')).toHaveText('technolgy');
await expect(row.locator('.mag-diff ins')).toHaveText('technology');
await row.getByRole('button', { name: 'Hide changes' }).click();
await expect(row.locator('.mag-diff')).toHaveCount(0);
});
test('restoring puts the recognised text back through the region route', async ({ page }) => {
await openReviewWithEditedBlock(page);
const patches = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
patches.push(route.request().postDataJSON());
route.fulfill({ json: { ok: true } });
});
await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Restore recognised text' }).click();
await expect.poll(() => patches.length).toBe(1);
expect(patches[0]).toEqual({ text: 'Battery technolgy is entering a new phase. It will take years to mature.' });
});
test('the word diff handles insertions, deletions, escaping and oversize input', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
const r = await page.evaluate(() => ({
same: window.magWordDiff('a b c', 'a b c'),
ins: window.magWordDiff('a c', 'a b c'),
del: window.magWordDiff('a b c', 'a c'),
html: window.magWordDiff('x <b>y</b>', 'x y'),
big: window.magWordDiff('w '.repeat(2000), 'v '.repeat(2000)).startsWith('<del>'),
}));
expect(r.same).toBe('a b c');
expect(r.ins).toBe('a <ins>b</ins> c');
expect(r.del).toBe('a <del>b</del> c');
expect(r.html).not.toContain('<b>');
expect(r.big).toBe(true);
});
});
test('sidebars appear in the reading view in place, labelled, and are not sentences', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
json: { article: ARTICLE_APPROVED, blocks: LAYOUT.blocks, asides: [
{ id: 's1', page: 3, text: 'Box: <b>Did you know?</b> Lithium is light.', after: 'b2' },
{ id: 's2', page: 3, text: 'Glossary of terms', after: 'b3' },
{ id: 's0', page: 3, text: 'Before everything', after: null },
] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect(page.locator('#mag-view-read')).toBeVisible();
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4); // asides add no sentences
const asides = page.locator('#mag-article aside.mag-aside');
await expect(asides).toHaveCount(3);
await expect(asides.first()).toContainText('Before everything');
await expect(asides.first()).toHaveAttribute('aria-label', 'Sidebar, not read aloud');
await expect(asides.nth(1)).toContainText('Did you know?');
await expect(page.locator('#mag-article aside b')).toHaveCount(0); // text, never markup
// Order in the DOM: before-all aside, title/byline sentences, aside after the byline, body sentences, aside last.
const order = await page.locator('#mag-article').evaluate(el => Array.from(el.children).map(c => c.matches('aside') ? 'A' : 'S').join(''));
expect(order).toBe('ASSASSA');
await asides.nth(1).click();
await expect(page.locator('#mag-article [data-i].is-current')).toHaveCount(1);
});
test('the EPUB link appears only when an approved article has audio', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
const link = page.getByRole('link', { name: /EPUB with audio/ });
await expect(link).toBeVisible();
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub`);
});
test('no EPUB link for an approved article without audio', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.waitForSelector('.mag-toc-row');
await expect(page.locator('#mag-epub-link')).toBeHidden();
});
test.describe('Hands-free listening', () => {
async function startPlayback(page) {
await page.addInitScript(() => {
window.__ms = { actions: {}, positions: 0 };
const ms = navigator.mediaSession;
const set = ms.setActionHandler.bind(ms);
ms.setActionHandler = (name, fn) => { window.__ms.actions[name] = fn; return set(name, fn); };
const pos = ms.setPositionState.bind(ms);
ms.setPositionState = (s) => { window.__ms.positions++; return pos(s); };
window.__wl = { requested: 0, released: 0 };
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: {
request: async () => { window.__wl.requested++; return { addEventListener() {}, release: async () => { window.__wl.released++; } }; } } });
});
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect.poll(() => page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); return !!a && !a.paused; })).toBe(true);
}
test('lock screen shows the article and offers play, pause, next, previous and seek', async ({ page }) => {
await startPlayback(page);
const meta = await page.evaluate(() => ({ title: navigator.mediaSession.metadata?.title, album: navigator.mediaSession.metadata?.album,
state: navigator.mediaSession.playbackState, actions: Object.keys(window.__ms.actions).sort() }));
expect(meta.title).toBe('The Future of Batteries');
expect(meta.album).toContain('Scientific American');
expect(meta.state).toBe('playing');
expect(meta.actions).toEqual(['nexttrack', 'pause', 'play', 'previoustrack', 'seekbackward', 'seekforward', 'seekto', 'stop']);
await expect.poll(() => page.evaluate(() => window.__ms.positions)).toBeGreaterThan(0);
});
test('pause and play from the lock screen control the real player', async ({ page }) => {
await startPlayback(page);
await page.evaluate(() => window.__ms.actions.pause());
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(true);
await expect.poll(() => page.evaluate(() => navigator.mediaSession.playbackState)).toBe('paused');
await page.evaluate(() => window.__ms.actions.play());
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(false);
});
test('seekto and seekforward move the playhead', async ({ page }) => {
await startPlayback(page);
await page.evaluate(() => window.__ms.actions.seekto({ seekTime: 1.2 }));
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThan(1);
});
test('driving-mode text follows the recorded sentence times and gives captions no slot', async ({ page }) => {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
const blocks = [
{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'title', text: 'Heading Here', reading_order: 0 },
{ id: 'b3', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'First one. Second one.', reading_order: 1 },
{ id: 'c1', page: 3, bbox: [0, 0, 1, 1], type: 'caption', text: 'A caption here.', reading_order: 2 },
{ id: 'b4', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'Last one.', reading_order: 3 },
];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
// Recorded times are far from what text length would suggest (the estimate puts 0.25 s in the heading).
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact', sentences: [
{ text: 'Heading Here', block: 0, start: 0, end: 0.2 }, { text: 'First one.', block: 1, start: 0.2, end: 0.3 },
{ text: 'Second one.', block: 1, start: 0.3, end: 1.9 }, { text: 'Last one.', block: 2, start: 1.9, end: 2.0 }] } }));
// Seeking needs a server that answers Range requests, like the real audio route does.
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
const wav = silentWav(2000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(5);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics .mag-driving-line')).toHaveCount(5); // all built, three shown
await expect(page.locator('#mag-driving-lyrics .mag-driving-line:not([hidden])')).toHaveCount(3);
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
const at = async (t) => {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeCloseTo(t, 1);
await page.evaluate(() => window.magPlaylistSync());
return page.evaluate(() => ({
driving: document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i,
list: document.querySelector('#mag-playlist-transcript .is-current')?.dataset.playlistSentence,
}));
};
// sentence index 3 is the caption: it never gets the highlight, and "Last one." is index 4
expect(await at(0.25)).toEqual({ driving: '1', list: '1' });
expect(await at(1.0)).toEqual({ driving: '2', list: '2' });
expect(await at(1.95)).toEqual({ driving: '4', list: '4' });
expect(await at(0.05)).toEqual({ driving: '0', list: '0' }); // seeking back works the same way
});
test('the screen is kept awake only while driving mode is open and playing', async ({ page }) => {
await startPlayback(page);
expect(await page.evaluate(() => window.__wl.requested)).toBe(0); // reading view: no lock
await page.evaluate(() => window.magOpenDriving());
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(1);
await page.evaluate(() => window.__ms.actions.pause()); // paused: let the screen sleep
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(1);
await page.evaluate(() => window.__ms.actions.play());
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(2);
await page.evaluate(() => window.magToggleDriving()); // leaving driving mode releases it
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(2);
});
test('a refused wake lock never stops playback', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request: async () => { throw new Error('denied'); } } });
});
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
});
});
test.describe('Share link and deep link into driving mode', () => {
const LINK = `/#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`;
async function openLink(page, url = LINK) {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
await page.goto(url, { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
}
test('opening a shared link goes straight to driving mode for that article and plays', async ({ page }) => {
await openLink(page);
await expect(page.locator('#mag-driving')).toBeVisible();
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
await expect(page.locator('#mag-driving-lyrics .mag-driving-line').first()).toBeVisible();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
await expect(page.locator('#mag-driving-start')).toBeHidden();
});
test('a blocked autoplay shows a big Play prompt that starts the audio', async ({ page }) => {
await page.addInitScript(() => {
const real = HTMLMediaElement.prototype.play;
window.__blockPlay = true;
HTMLMediaElement.prototype.play = function () {
return window.__blockPlay ? Promise.reject(new DOMException('blocked', 'NotAllowedError')) : real.call(this);
};
});
await openLink(page);
const prompt = page.locator('#mag-driving-start');
await expect(prompt).toBeVisible();
const box = await prompt.boundingBox();
expect(box.height).toBeGreaterThanOrEqual(64);
await page.evaluate(() => { window.__blockPlay = false; });
await prompt.click();
await expect(prompt).toBeHidden();
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
});
test('a malformed or hostile link is ignored: no driving mode, nothing injected', async ({ page }) => {
const bad = `/#s-magazine?doc=${DOC_ID}&article=%22%3E%3Cimg%20src%3Dx%20onerror%3Dwindow.__xss%3D1%3E&mode=drive`;
await openLink(page, bad);
await navTo(page, 's-magazine');
await expect(page.locator('#mag-driving')).toBeHidden();
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
const { parse } = await page.evaluate(() => ({ parse: [
window.magParseDeepLink('#s-magazine?doc=../etc&mode=drive'),
window.magParseDeepLink('#s-magazine?doc=abc&article=a/b&mode=drive'),
window.magParseDeepLink('#s-magazine?doc=abc'),
window.magParseDeepLink('#s-magazine?doc=abc&article=article-001&mode=drive'),
] }));
expect(parse).toEqual([null, null, null, { doc: 'abc', article: 'article-001' }]);
});
test('Share uses the system share sheet with a deep link, at 375 px wide', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 700 });
await page.addInitScript(() => { navigator.share = (data) => { window.__shared = data; return Promise.resolve(); }; });
await openLink(page, '/');
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').click();
await page.evaluate(() => window.magOpenDriving());
const share = page.locator('#mag-driving .mag-driving-share');
await expect(share).toHaveAttribute('aria-label', /Share/);
const box = await share.boundingBox();
expect(box.width).toBeGreaterThanOrEqual(44);
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(375);
await share.focus();
await page.keyboard.press('Enter');
const shared = await page.evaluate(() => window.__shared);
const url = new URL(shared.url);
expect(url.hash).toBe(`#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`);
expect(url.search).toBe(''); // no tokens or keys anywhere in the link
});
test('Share without the share sheet copies the link and says so (aria-live toast)', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__copied = t; return Promise.resolve(); } }, configurable: true });
});
await openLink(page, '/');
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-share-book').click();
await expect(page.locator('#toast')).toContainText('Link copied');
await expect(page.locator('#toast')).toHaveAttribute('aria-live', 'polite');
expect(await page.evaluate(() => window.__copied)).toBe(`${new URL(page.url()).origin}/#s-magazine?doc=${DOC_ID}&mode=drive`);
});
});
test.describe('Page cards say what to do', () => {
const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args);
async function openGrid(page, articles, quality) {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
if (quality) await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { pages: quality, summary: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
}
const art = (id, title, pages, status = 'detected', extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
test('the guide names the state and the next step for every kind of page', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const a = (status, has_audio = false) => ({ id: 'x', title: 'T', pages: [3], status, has_audio });
const flagged = await guide(page, { page: 3, articles: [a('detected')], quality: { needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [['a', 'b'], ['c', 'd']], clipped: [], suspect_blocks: ['s'], duplicates: [] }, stateName: 'flagged', hasText: true });
expect(flagged.needs).toBe(true);
expect(flagged.label).toBe('Problems found');
expect(flagged.todo[0]).toMatch(/^1 text area lies outside every box: use "Find missed text"/);
expect(flagged.todo).toContain('2 pairs of boxes overlap: merge, shrink or delete one');
expect(flagged.todo.at(-1)).toBe('Then approve the page');
const detected = await guide(page, { page: 3, articles: [a('detected')], quality: null, stateName: 'detected', hasText: true });
expect(detected.todo[0]).toMatch(/approve it/);
const done = await guide(page, { page: 3, articles: [a('approved', true)], quality: null, stateName: 'audio', hasText: true });
expect([done.needs, done.label]).toEqual([false, 'Done: audio is ready']);
const noAudio = await guide(page, { page: 3, articles: [a('approved')], quality: null, stateName: 'approved', hasText: true });
expect(noAudio.todo[0]).toMatch(/Generate the audio/);
const ad = await guide(page, { page: 3, articles: [], quality: null, stateName: 'ad', hasText: false });
expect([ad.needs, ad.label]).toEqual([false, 'Advertisement: not read']);
const orphan = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: true });
expect(orphan.needs).toBe(true);
expect(orphan.todo[0]).toMatch(/belongs to no article/);
const empty = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: false });
expect([empty.needs, empty.label]).toEqual([false, 'No article on this page']);
const shared = await guide(page, { page: 3, articles: [a('detected'), a('approved')], quality: null, stateName: 'detected', hasText: true });
expect(shared.todo.join(' ')).toMatch(/1 of the 2 articles here still wait/);
const hidden = await guide(page, { page: 3, articles: [a('detected'), a('approved', true)], quality: null, stateName: 'audio', hasText: true });
expect([hidden.label, hidden.needs]).toEqual(['Partly done: some audio is ready', true]); // colour says audio, one article still waits
expect(hidden.todo.join(' ')).toMatch(/1 of the 2 articles here still wait for approval/);
const noted = await guide(page, { page: 3, articles: [a('approved', true)], quality: { needs_review: true, uncovered: [], overlaps: [['a', 'b']], clipped: [], suspect_blocks: [], duplicates: [] }, stateName: 'audio', hasText: true });
expect(noted.needs).toBe(false);
expect(noted.note).toMatch(/You approved this page, but the quality check had found/);
});
test('how an article flows between pages is computed from its page list', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const r = await page.evaluate(() => ({
first: window.magArticleFlow({ pages: [10, 11, 25] }, 10), mid: window.magArticleFlow({ pages: [10, 11, 25] }, 11),
last: window.magArticleFlow({ pages: [25, 10, 11] }, 25), single: window.magArticleFlow({ pages: [4] }, 4), absent: window.magArticleFlow({ pages: [4] }, 9) }));
expect(r.first).toEqual({ from: null, to: 11 });
expect(r.mid).toEqual({ from: 10, to: 25 });
expect(r.last).toEqual({ from: 11, to: null });
expect(r.single).toEqual({ from: null, to: null });
expect(r.absent).toEqual({ from: null, to: null });
});
test('every card shows its status, article count, page flow and to-do in words', async ({ page }) => {
await openGrid(page, [
art('a1', 'Long feature', [3, 4], 'detected'),
art('a2', 'Short piece', [4], 'approved', { has_audio: true }),
], [{ page: 3, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }]);
const p3 = page.locator('.mag-card-wrap[data-page="3"]');
await expect(p3.locator('.mag-card-count')).toHaveText('1 article');
await expect(p3.locator('.mag-page-status')).toContainText('Problems found');
await expect(p3.locator('.mag-flow')).toHaveText(/continues on p\.4/);
await expect(p3.locator('.mag-page-todo')).toContainText('Find missed text');
const p4 = page.locator('.mag-card-wrap[data-page="4"]');
await expect(p4.locator('.mag-card-count')).toHaveText('2 articles');
await expect(p4.locator('.mag-flow').first()).toHaveText(/continues from p\.3/);
await expect(p4.locator('.mag-page-todo')).toContainText('1 of the 2 articles here still wait for approval');
const p6 = page.locator('.mag-card-wrap[data-page="6"]');
await expect(p6.locator('.mag-card-count')).toHaveText('no article');
await expect(p6.locator('.mag-page-status')).toContainText('No article on this page');
});
test('the summary counts pages that need you and the filter hides the rest', async ({ page }) => {
await openGrid(page, [art('a1', 'Done piece', [3], 'approved', { has_audio: true }), art('a2', 'Open piece', [4], 'detected')], null);
const summary = page.locator('.mag-pages-summary');
await expect(summary).toContainText(/pages need you/);
const total = await page.locator('.mag-pages-grid .mag-card-wrap').count();
await page.getByLabel('Show only pages that need me').check();
const shown = await page.locator('.mag-pages-grid .mag-card-wrap').count();
expect(shown).toBeLessThan(total);
await expect(page.locator('.mag-card-wrap[data-page="4"]')).toBeVisible();
await expect(page.locator('.mag-card-wrap[data-page="3"]')).toHaveCount(0);
await page.getByLabel('Show only pages that need me').uncheck();
await expect(page.locator('.mag-pages-grid .mag-card-wrap')).toHaveCount(total);
});
test('the legend is large enough to read', async ({ page }) => {
await openGrid(page, [art('a1', 'X', [3])], null);
const sizes = await page.evaluate(() => ({
font: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-item')).fontSize),
swatch: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-swatch')).width) }));
expect(sizes.font).toBeGreaterThanOrEqual(14);
expect(sizes.swatch).toBeGreaterThanOrEqual(18);
});
});
test('catalogues can be added and removed, and the source list follows', async ({ page }) => {
await mockBackend(page);
let cats = [];
const puts = [];
await page.route('**/api/magazine-sources/catalogues', route => {
if (route.request().method() === 'PUT') {
cats = route.request().postDataJSON().catalogues.map(c => ({ ...c, password_set: !!c.password_env }));
puts.push(route.request().postDataJSON());
return route.fulfill({ json: { ok: true, count: cats.length } });
}
return route.fulfill({ json: { catalogues: cats } });
});
await page.route('**/api/magazine-sources', route => route.fulfill({ json: { sources: [
{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' },
...cats.map((c, i) => ({ key: `opds-${i + 1}`, label: c.name, kind: 'opds' }))] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-cat summary').click();
await expect(page.locator('#mag-cat-list')).toContainText('None yet');
await page.locator('#mag-cat-name').fill('My NAS');
await page.locator('#mag-cat-url').fill('http://nas.local:8083/opds/');
await page.locator('#mag-cat-user').fill('me');
await page.locator('#mag-cat-env').fill('NAS_PW');
await page.getByRole('button', { name: /Add catalogue/ }).click();
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue added.');
await expect(page.locator('#mag-cat-list')).toContainText('My NAS');
// The form never carries a password, only the name of the variable that holds it.
expect(JSON.stringify(puts[0])).not.toMatch(/"password"/);
expect(puts[0].catalogues[0]).toEqual({ name: 'My NAS', url: 'http://nas.local:8083/opds/', username: 'me', password_env: 'NAS_PW' });
await expect(page.locator('#mag-find-source option')).toHaveText(['Internet Archive', 'My NAS']);
await page.getByRole('button', { name: 'Remove My NAS' }).click();
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue removed.');
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
});
test('a refused catalogue shows the server reason and keeps the form', async ({ page }) => {
await mockBackend(page);
await page.route('**/api/magazine-sources/catalogues', route => route.request().method() === 'PUT'
? route.fulfill({ status: 400, json: { detail: 'Put the login in the username field, not in the address' } })
: route.fulfill({ json: { catalogues: [] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-cat summary').click();
await page.locator('#mag-cat-url').fill('http://me:pw@nas.local/opds');
await page.getByRole('button', { name: /Add catalogue/ }).click();
await expect(page.locator('#mag-cat-status')).toContainText('Put the login in the username field');
await expect(page.locator('#mag-cat-url')).toHaveValue('http://me:pw@nas.local/opds');
});
test.describe('Correcting flagged words in the review list', () => {
const ISSUES = [
{ token: 'technology', raw: 'technology', block_id: 'b3', page: 3, start: 8, end: 18, language: 'en', suggestions: ['technologie', 'technologies'], context: 'Battery technology is entering' },
{ token: 'mature', raw: 'mature', block_id: 'b3', page: 3, start: 67, end: 73, language: 'en', suggestions: [], context: 'years to mature.' },
];
async function open(page) {
await mockBackend(page);
const posts = [];
await page.route(`**/api/magazine/${DOC_ID}/spelling/dictionary`, route => { posts.push({ keep: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true } }); });
await page.route(`**/api/magazine/${DOC_ID}/spelling/fix`, route => { posts.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); });
await page.route(`**/api/magazine/${DOC_ID}/spelling?**`, route => route.fulfill({ json: { issues: posts.some(p => p.fix || p.keep) ? [] : ISSUES, dictionary: {} } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
await expect(page.locator('[data-block-row="b3"] mark.mag-spell')).toHaveCount(2);
return posts;
}
const fontOf = (page, sel) => page.locator(sel).first().evaluate(el => { const c = getComputedStyle(el); return `${c.fontSize}/${c.lineHeight}/${c.fontFamily}`; });
test('clicking a flagged word edits only that word, in the paragraph\'s own type, and leaves the other marks', async ({ page }) => {
await open(page);
const before = await fontOf(page, '[data-block-row="b3"] .mag-block-text');
const box = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await expect(input).toBeFocused();
await expect(input).toHaveValue('technology');
await expect(page.locator('[data-block-row="b3"] .mag-row-editor')).toHaveCount(0); // not the whole-text editor
await expect(page.locator('[data-block-row="b3"] mark.mag-spell:not(.is-editing)')).toHaveCount(1); // the other word stays underlined
expect(await fontOf(page, '[data-block-row="b3"] .mag-block-text')).toBe(before); // nothing restyled
expect(await fontOf(page, '[data-block-row="b3"] .mag-word-input')).toBe(before);
const after = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
expect(Math.abs(after.width - box.width)).toBeLessThan(2); // the column did not change width
await expect(page.locator('[data-block-row="b3"] input[type="checkbox"]')).toBeChecked(); // the click did not untick the region
});
test('a suggestion chip corrects the word with one click', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'technologie', exact: true }).click();
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ block_id: 'b3', start: 8, end: 18, token: 'technology', replacement: 'technologie' });
});
test('typing a correction and pressing Enter applies it; Escape puts the word back and sends nothing', async ({ page }) => {
const posts = await open(page);
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
await mark.click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await input.fill('Technologie');
await input.press('Escape');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
await expect(page.locator('[data-block-row="b3"] mark.mag-spell').first()).toHaveText('technology');
expect(posts).toEqual([]);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
await page.locator('[data-block-row="b3"] .mag-word-input').fill('Technologie');
await page.locator('[data-block-row="b3"] .mag-word-input').press('Enter');
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ token: 'technology', replacement: 'Technologie', start: 8, end: 18 });
});
test('an empty or unchanged word is not sent', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
const input = page.locator('[data-block-row="b3"] .mag-word-input');
await input.fill(' ');
await input.press('Enter');
await expect(input).toBeVisible(); // still editing: nothing to apply
await input.fill('technology');
await input.press('Enter');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
expect(posts).toEqual([]);
});
test('Keep adds the word to the issue dictionary', async ({ page }) => {
const posts = await open(page);
await page.locator('[data-block-row="b3"] mark.mag-spell').nth(1).click();
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'Keep' }).click();
await expect.poll(() => posts.find(p => p.keep)?.keep).toEqual({ word: 'mature' });
});
test('a flagged word can be reached and opened with the keyboard', async ({ page }) => {
await open(page);
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
await mark.focus();
await page.keyboard.press('Enter');
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toBeFocused();
});
test('the whole-text editor keeps the paragraph\'s type, width and the flagged words underlined', async ({ page }) => {
await open(page);
const textSel = '[data-block-row="b3"] .mag-block-text';
const before = await fontOf(page, textSel);
const box = await page.locator(textSel).boundingBox();
// Click plain text, away from any flagged word.
await page.locator(textSel).click({ position: { x: box.width - 4, y: 4 } });
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
await expect(area).toBeFocused();
expect(await fontOf(page, '[data-block-row="b3"] .mag-hl-area')).toBe(before);
const editorBox = await area.boundingBox();
expect(Math.abs(editorBox.width - box.width)).toBeLessThan(3); // same column, same wrapping
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(2);
// Fix one word by typing: its underline goes, the other stays.
await area.fill('Battery technologie is entering a new phase. It will take years to mature.');
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(1);
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveText('mature');
});
test('the editor grows with its text instead of scrolling inside a small box', async ({ page }) => {
await open(page);
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
const h1 = (await area.boundingBox()).height;
await area.fill(Array.from({ length: 40 }, (_, i) => 'Zeile ' + i + ' mit etwas mehr Text').join(' '));
await expect.poll(async () => (await area.boundingBox()).height).toBeGreaterThan(h1 * 3);
expect(await area.evaluate(el => el.scrollHeight <= el.clientHeight + 1)).toBe(true);
});
test('Save text still saves the edited text', async ({ page }) => {
await open(page);
const patches = [];
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { patches.push(route.request().postDataJSON()); route.fulfill({ json: { ok: true } }); });
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
await page.locator('[data-block-row="b3"] .mag-hl-area').fill('Neuer Text.');
await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Save text' }).click();
await expect.poll(() => patches[0]).toEqual({ text: 'Neuer Text.' });
});
});
test.describe('Turning a page scanned sideways', () => {
async function open(page) {
await mockBackend(page);
const calls = { rotate: [], layout: 0, jobs: 0 };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => { calls.layout++; return route.fulfill({ json: LAYOUT }); });
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
return calls;
}
const routeRotate = (page, calls, { status = 200, job = { done: true, error: null } } = {}) => Promise.all([
page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
calls.rotate.push(route.request().postDataJSON());
return status === 200 ? route.fulfill({ json: { job_id: 'rot1', degrees: calls.rotate.at(-1).degrees } })
: route.fulfill({ status, json: { detail: 'No vision OCR engine configured -- set one in Settings (turning re-reads the page)' } });
}),
page.route('**/api/jobs/rot1', route => { calls.jobs++; return route.fulfill({ json: job }); }),
]);
test('three labelled turn buttons sit together in the toolbar', async ({ page }) => {
await open(page);
const group = page.getByRole('group', { name: 'Turn this page' });
await expect(group.getByRole('button')).toHaveText([/Turn left/, /Turn right/, /Turn 180°/]);
await expect(group.getByRole('button', { name: /Turn right/ })).toHaveAttribute('title', /clockwise/);
});
test('turning right asks first and, once confirmed, sends 90 and reloads the page', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
const layoutBefore = calls.layout;
await page.getByRole('button', { name: /Turn right/ }).click();
const dialog = page.getByRole('alertdialog');
await expect(dialog).toContainText('Turn page 3 90° clockwise?');
await expect(dialog).toContainText('corrections you made to this page by hand are lost');
expect(calls.rotate).toEqual([]); // nothing is sent before the answer
await dialog.getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: 90 }]);
await expect.poll(() => calls.jobs).toBeGreaterThan(0);
await expect.poll(() => calls.layout).toBeGreaterThan(layoutBefore); // the layout is fetched again
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('while a turn is read the old picture and text are dimmed and labelled as old, then the notice goes', async ({ page }) => {
const calls = await open(page);
let polls = 0;
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => { calls.rotate.push(route.request().postDataJSON()); return route.fulfill({ json: { job_id: 'rot1', degrees: 90 } }); });
await page.route('**/api/jobs/rot1', route => { polls++; return route.fulfill({ json: polls < 3 ? { done: false, detail: 'Re-reading page 3' } : { done: true, error: null } }); });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
const note = page.locator('#mag-turn-busy');
await expect(note).toBeVisible();
await expect(note).toContainText('Turning page 3 and reading it again');
await expect(note).toContainText('from before');
await expect(note).toHaveAttribute('role', 'status');
await expect(page.locator('.mag-review-split')).toHaveClass(/is-stale/);
// what is stale cannot be clicked into by accident
expect(await page.locator('.mag-review-split').evaluate(el => getComputedStyle(el).pointerEvents)).toBe('none');
await expect(page.locator('#mag-turn-busy')).toHaveCount(0, { timeout: 15_000 });
await expect(page.locator('.mag-review-split')).not.toHaveClass(/is-stale/);
});
test('cancelling the question turns nothing', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('Turn page 3 90° counter-clockwise?');
await page.getByRole('alertdialog').getByRole('button', { name: /Cancel/ }).click();
await page.waitForTimeout(300);
expect(calls.rotate).toEqual([]);
});
test('left sends -90 and half a turn sends 180', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls);
await page.getByRole('button', { name: /Turn left/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate.length).toBe(1);
await page.getByRole('button', { name: /Turn 180°/ }).click();
await expect(page.getByRole('alertdialog')).toContainText('upside down');
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => calls.rotate).toEqual([{ degrees: -90 }, { degrees: 180 }]);
});
test('a failed read says the page was left as it was and the buttons work again', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { job: { done: true, error: 'The OCR engine returned nothing usable for this page' } });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'The page was left as it was' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
test('a refusal to start (no OCR engine) is explained', async ({ page }) => {
const calls = await open(page);
await routeRotate(page, calls, { status: 400 });
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'No vision OCR engine configured' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
});
test.describe('Draw region panel layering', () => {
async function openDrawing(page, height = 640) {
await page.setViewportSize({ width: 1300, height });
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: /^Review/ }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await scrollPageImageBelowToolbar(page, 300);
}
// Drag a rectangle whose bottom edge is at yEnd (window coordinates) over the page image.
async function drawEndingAt(page, yEnd, height = 40) {
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await page.mouse.move(wrap.x + 40, yEnd - height);
await page.mouse.down();
await page.mouse.move(wrap.x + 200, yEnd, { steps: 4 });
await page.mouse.up();
}
// What the user would actually hit at the centre of each button: it must be that button, not a bar laid over it.
const topmostIsOwn = (page, name) => page.locator('.mag-draw-panel').getByRole('button', { name, exact: true }).evaluate((btn) => {
const r = btn.getBoundingClientRect();
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
return !!hit && (hit === btn || btn.contains(hit));
});
async function geometry(page) {
const vp = page.viewportSize();
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
const footTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
await drawEndingAt(page, wrap.y + 80); // a first rectangle, only to measure the panel
const height = (await page.locator('.mag-draw-panel').boundingBox()).height;
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
return { vp, wrap, footTop, height };
}
test('a panel opened where the sticky bottom bar lies is on top of it, so every button can be clicked', async ({ page }) => {
await openDrawing(page);
const { vp, wrap, footTop, height } = await geometry(page);
// A rectangle that leaves room for the panel below it *only just*: it fits in the window, but reaches into the
// band the sticky Back / Approve bar occupies -- the case that hid Cancel / Add region.
const yEnd = Math.round(Math.min(vp.height - 24 - height, footTop - 8));
expect(yEnd - 40).toBeGreaterThan(wrap.y); // the rectangle is on the page image
expect(yEnd + 6 + height).toBeGreaterThan(footTop); // and the panel below it does overlap the bar
await drawEndingAt(page, yEnd);
const panel = page.locator('.mag-draw-panel');
await expect(panel).toBeVisible();
const box = await panel.boundingBox();
expect(box.y + box.height).toBeLessThanOrEqual(vp.height); // inside the window...
expect(box.y + box.height).toBeGreaterThan(footTop); // ...and over the bar's band
expect(await topmostIsOwn(page, 'Add region')).toBe(true);
expect(await topmostIsOwn(page, 'Cancel')).toBe(true);
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('the panel is mounted on the page body, outside the sticky stacking contexts', async ({ page }) => {
await openDrawing(page);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await drawEndingAt(page, wrap.y + 80);
const info = await page.locator('.mag-draw-panel').evaluate((el) => ({ parent: el.parentElement.tagName, z: getComputedStyle(el).zIndex }));
expect(info.parent).toBe('BODY');
expect(Number(info.z)).toBeGreaterThan(100); // above the sticky bars (30-100)
expect(Number(info.z)).toBeLessThan(1000); // below dialogs
});
test('the region type list stays inside the window and its options can be clicked', async ({ page }) => {
await openDrawing(page);
const { vp, footTop, height } = await geometry(page);
await drawEndingAt(page, Math.round(Math.min(vp.height - 24 - height, footTop - 8)));
await page.locator('#mag-draw-kind-search').focus();
const list = page.locator('#mag-draw-kind-list');
await expect(list).toBeVisible();
const r = await list.boundingBox();
expect(r.y).toBeGreaterThanOrEqual(0);
expect(r.y + r.height).toBeLessThanOrEqual(vp.height);
const reachable = await list.locator('.mag-type-picker-option').first().evaluate((el) => {
const b = el.getBoundingClientRect();
const hit = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
return !!hit && el.contains(hit);
});
expect(reachable).toBe(true);
await list.locator('.mag-type-picker-option').nth(2).click(); // a real click on an option
await expect(list).toBeHidden();
});
test('panels are removed when the review is left', async ({ page }) => {
await openDrawing(page);
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
await drawEndingAt(page, wrap.y + 80);
await expect(page.locator('.mag-draw-panel')).toHaveCount(1);
await page.evaluate(() => window.magBackToContents());
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
});
test('the save-new-position panel is mounted and placed the same way', async ({ page }) => {
await openDrawing(page);
await page.evaluate(() => {
const box = document.querySelector('#mag-review-page-wrap .mag-review-box');
window.magReviewOpenEditPanel(box.dataset.block, box, { origLeft: 0, origTop: 0, origW: 10, origH: 10 });
});
const panel = page.locator('.mag-edit-panel');
await expect(panel).toBeVisible();
expect(await panel.evaluate(el => el.parentElement.tagName)).toBe('BODY');
const box = await panel.boundingBox();
const vp = page.viewportSize();
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.y).toBeGreaterThanOrEqual(0);
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
expect(box.x + box.width).toBeLessThanOrEqual(vp.width);
});
});
test.describe('Unbreakable text cannot collapse the review', () => {
const HUGE = 'Giro + 2.' + '1.'.repeat(2500);
async function openWith(page, width = 1600) {
await page.setViewportSize({ width, height: 900 });
await mockBackend(page);
const layout = { ...LAYOUT, blocks: [...LAYOUT.blocks,
{ id: 'b9', page: 3, bbox: [50, 500, 200, 40], type: 'body', text: HUGE, reading_order: 3, confidence: 0.5, font_size: 10, font_name: '', bold: false, column: 0, source: 'vision_ocr' }] };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({ json: layout }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
await expect(page.locator('[data-block-row="b9"]')).toBeVisible();
}
test('the page picture keeps its column and the list stays inside the window', async ({ page }) => {
await openWith(page);
const m = await page.evaluate(() => {
const wrap = document.getElementById('mag-review-page-wrap').getBoundingClientRect();
const split = document.querySelector('.mag-review-split');
const cols = getComputedStyle(split).gridTemplateColumns.split(' ').map(parseFloat);
const list = document.querySelector('.mag-blocks').getBoundingClientRect();
return { wrapW: wrap.width, cols, listRight: list.right, vw: window.innerWidth, docW: document.documentElement.scrollWidth };
});
expect(m.wrapW).toBeGreaterThan(250); // the page picture is not squeezed to nothing
expect(Math.min(...m.cols)).toBeGreaterThan(250); // both columns have real width
expect(m.listRight).toBeLessThanOrEqual(m.vw + 1); // the list does not run off the window
expect(m.docW).toBeLessThanOrEqual(m.vw + 1); // and the page gets no sideways scroll
});
test('the same holds on a phone, where the columns stack', async ({ page }) => {
await openWith(page, 390);
const m = await page.evaluate(() => ({
wrapW: document.getElementById('mag-review-page-wrap').getBoundingClientRect().width,
docW: document.documentElement.scrollWidth, vw: window.innerWidth }));
expect(m.wrapW).toBeGreaterThan(200);
expect(m.docW).toBeLessThanOrEqual(m.vw + 1);
});
test('the huge token is still shown, wrapped, so it can be fixed or deleted', async ({ page }) => {
await openWith(page);
const row = page.locator('[data-block-row="b9"] .mag-block-text');
await expect(row).toContainText('Giro + 2.1.1.1');
const box = await row.boundingBox();
expect(box.width).toBeLessThan(1600);
expect(box.height).toBeGreaterThan(100); // wrapped onto many lines instead of one 20,000 px line
await expect(page.locator('[data-block-row="b9"]').getByRole('button', { name: /Delete region/ })).toBeVisible();
});
});
test.describe('A turned page shows at once, and its net turn is visible', () => {
async function open(page, { rotation = 0 } = {}) {
await mockBackend(page);
const state = { v: 100, rotation, rotates: [] };
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({
json: { ...LAYOUT, pages: LAYOUT.pages.map(p => ({ ...p, image_v: state.v, ...(state.rotation ? { rotation: state.rotation } : {}) })) } }));
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
state.rotates.push(route.request().postDataJSON());
state.v += 50; state.rotation = (state.rotation + ((state.rotates.at(-1).degrees % 360) + 360)) % 360; // what the server would now report
return route.fulfill({ json: { job_id: 'rot1', degrees: state.rotates.at(-1).degrees } });
});
await page.route('**/api/jobs/rot1', route => route.fulfill({ json: { done: true, error: null, detail: 'done' } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: /^Review/ }).click();
await page.waitForSelector('#mag-undo-btn');
return state;
}
test('every picture of a page carries its version, so a changed picture is a new URL', async ({ page }) => {
await open(page);
await expect(page.locator('#mag-review-page-wrap img')).toHaveAttribute('src', new RegExp(`/pages/3\\?v=100$`));
});
test('after a turn the page picture is requested again under its new version', async ({ page }) => {
const state = await open(page);
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => state.rotates.length).toBe(1);
await expect(page.locator('#mag-review-page-wrap img')).toHaveAttribute('src', /\/pages\/3\?v=150$/);
});
test('the page grid thumbnails use the version too', async ({ page }) => {
await open(page);
await page.getByRole('button', { name: /^Contents$/ }).click();
await expect(page.locator('.mag-card-wrap[data-page="3"] img.mag-card-thumb')).toHaveAttribute('src', /\/pages\/3\?v=100$/);
});
test('a page that has not been turned shows no turn state', async ({ page }) => {
await open(page);
await expect(page.locator('#mag-turn-state')).toHaveCount(0);
await expect(page.getByRole('button', { name: /Undo turns/ })).toHaveCount(0);
});
test('a turned page says by how much, and Undo turns sends what returns it to the PDF orientation', async ({ page }) => {
const state = await open(page, { rotation: 270 });
await expect(page.locator('#mag-turn-state')).toHaveText('Turned 270° clockwise from the scan');
await page.getByRole('button', { name: /Undo turns/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect.poll(() => state.rotates).toEqual([{ degrees: 90 }]); // 270 + 90 = a full circle
await expect(page.locator('#mag-turn-state')).toHaveCount(0); // and the state line is gone once it is back to 0
});
test('the server refusing a second turn while one runs is explained and the buttons come back', async ({ page }) => {
await open(page);
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => route.fulfill({
status: 409, json: { detail: 'Page 3 is still being turned or read; wait for it to finish before turning it again' } }));
await page.getByRole('button', { name: /Turn right/ }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'still being turned or read' }).first()).toBeVisible();
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
});
});
test.describe('Filtering the page grid by status', () => {
const art = (id, title, pages, status, extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
async function openGrid(page) {
await mockBackend(page, { articleStatus: 'approved' });
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [
art('a1', 'Done piece', [3], 'approved', { has_audio: true }),
art('a2', 'Open piece', [4], 'detected'),
art('a3', 'Approved piece', [5], 'approved'),
art('a4', 'Problem piece', [6], 'detected'),
], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { summary: {}, pages: [
{ page: 6, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }] } }));
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.locator('#mag-contents-view-toggle').click();
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
}
const shownPages = (page) => page.locator('.mag-pages-grid .mag-card-wrap').evaluateAll(els => els.map(e => Number(e.dataset.page)));
const chip = (page, status) => page.locator(`.mag-legend-filter[data-status="${status}"]`);
test('the filter logic: nothing chosen shows everything, several choices are "any of", and it combines with needs-me', async ({ page }) => {
await mockBackend(page);
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached' });
await navTo(page, 's-magazine');
const v = (st, needs, f) => page.evaluate(([a, b, c]) => window.magPageVisible(a, b, c), [st, needs, f]);
expect(await v('audio', false, {})).toBe(true);
expect(await v('audio', false, { status: [] })).toBe(true);
expect(await v('audio', false, { status: ['flagged'] })).toBe(false);
expect(await v('audio', false, { status: ['flagged', 'audio'] })).toBe(true);
expect(await v('flagged', true, { onlyTodo: true, status: ['flagged'] })).toBe(true);
expect(await v('flagged', false, { onlyTodo: true, status: ['flagged'] })).toBe(false); // both filters must agree
expect(await v('audio', true, { onlyTodo: true, status: ['flagged'] })).toBe(false);
expect(await v('audio', false, { onlyTodo: true })).toBe(false);
});
test('each legend entry is a toggle button with the number of pages in that status', async ({ page }) => {
await openGrid(page);
const group = page.getByRole('group', { name: 'Filter the pages by status' });
await expect(group.getByRole('button', { pressed: false }).first()).toBeVisible();
await expect(chip(page, 'audio')).toContainText('1');
await expect(chip(page, 'detected')).toContainText('1');
await expect(chip(page, 'flagged')).toContainText('1');
await expect(chip(page, 'blank')).toContainText('4');
await expect(chip(page, 'ad')).toBeDisabled(); // none of those: nothing to filter to
await expect(chip(page, 'audio')).toHaveAttribute('aria-pressed', 'false');
});
test('choosing a status shows only those pages, and the summary says how many', async ({ page }) => {
await openGrid(page);
expect((await shownPages(page)).length).toBe(8);
await chip(page, 'blank').click();
expect(await shownPages(page)).toEqual([1, 2, 7, 8]);
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('.mag-pages-shown')).toHaveText(/Showing 4 of 8 pages/);
});
test('several statuses add up, and choosing one again takes it off', async ({ page }) => {
await openGrid(page);
await chip(page, 'audio').click();
await chip(page, 'flagged').click();
expect(await shownPages(page)).toEqual([3, 6]);
await chip(page, 'audio').click();
expect(await shownPages(page)).toEqual([6]);
await chip(page, 'flagged').click();
expect((await shownPages(page)).length).toBe(8);
await expect(page.locator('.mag-pages-shown')).toHaveCount(0);
});
test('it combines with "Show only pages that need me"', async ({ page }) => {
await openGrid(page);
await page.getByLabel('Show only pages that need me').check();
const needing = await shownPages(page);
expect(needing).toEqual(expect.arrayContaining([4, 6]));
await chip(page, 'detected').click();
expect(await shownPages(page)).toEqual([4]); // needs me AND detected
await chip(page, 'detected').click();
await chip(page, 'audio').click();
expect(await shownPages(page)).toEqual([]); // page 3 is done: it does not need me
await expect(page.locator('.mag-pages-grid + *, .mag-pages-grid')).toContainText('No page has that status and needs you');
});
test('"Show all pages" clears every filter and only appears while one is on', async ({ page }) => {
await openGrid(page);
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
await chip(page, 'blank').click();
await page.getByLabel('Show only pages that need me').check();
await page.getByRole('button', { name: 'Show all pages' }).click();
expect((await shownPages(page)).length).toBe(8);
await expect(page.getByLabel('Show only pages that need me')).not.toBeChecked();
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'false');
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
});
test('the filter survives a change of the column count and works from the keyboard', async ({ page }) => {
await openGrid(page);
await chip(page, 'flagged').focus();
await page.keyboard.press('Enter');
expect(await shownPages(page)).toEqual([6]);
await page.evaluate(() => window.magSetPagesCols(5));
expect(await shownPages(page)).toEqual([6]);
await expect(chip(page, 'flagged')).toHaveAttribute('aria-pressed', 'true');
});
});
// ── Audio/text sync: the highlight is always the sentence containing audio.currentTime ──────────────────────
test.describe('Driving mode: three-line text and robust audio/text sync', () => {
// Four 3 s sentences. Each has 0.7 s of silent lead-in (speech), so a sentence lights up 0.15 s before its
// words: 0, 3.55, 6.55, 9.55 (the first always from 0).
const EFF = [0, 3.55, 6.55, 9.55];
const expectedAt = (t) => EFF.reduce((n, e, i) => (e <= t ? i : n), 0);
async function openDriving(page, { speed } = {}) {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'S' + i, block: 0, start: s, end: s + 3, speech: s + 0.7 })) } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
if (speed) await page.evaluate((v) => { const a = document.getElementById('mag-playlist-audio'); a.defaultPlaybackRate = v; a.playbackRate = v; }, speed);
}
const state = (page) => page.evaluate(() => {
const a = document.getElementById('mag-playlist-audio');
const cur = document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current');
return { t: a.currentTime, current: cur ? Number(cur.dataset.i) : -1,
shown: [...document.querySelectorAll('#mag-driving-lyrics .mag-driving-line:not([hidden])')].map((e) => Number(e.dataset.i)) };
});
test('shows only previous, current and next sentence, and the window moves with the audio', async ({ page }) => {
await openDriving(page);
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
for (const [t, shown] of [[1, [0, 1]], [4, [0, 1, 2]], [7, [1, 2, 3]], [11, [2, 3]]]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
await expect.poll(async () => (await state(page)).shown).toEqual(shown);
const s = await state(page);
expect(s.current).toBe(expectedAt(s.t));
}
// the box is a fixed height: it does not grow or shrink as the lines change
const h1 = await page.locator('#mag-driving-lyrics').evaluate((e) => e.getBoundingClientRect().height);
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 1; });
await expect.poll(async () => (await state(page)).current).toBe(0);
expect(await page.locator('#mag-driving-lyrics').evaluate((e) => e.getBoundingClientRect().height)).toBe(h1);
// not a live region: sentences are never announced one by one
expect(await page.locator('#mag-driving-lyrics').getAttribute('aria-live')).toBe('off');
});
test('tapping the previous or next line jumps to that sentence', async ({ page }) => {
await openDriving(page);
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
await expect.poll(async () => (await state(page)).current).toBe(1);
await page.locator('#mag-driving-lyrics .mag-driving-line.is-next').click();
await expect.poll(async () => (await state(page)).current).toBe(2);
expect((await state(page)).t).toBeGreaterThanOrEqual(6.5);
await page.locator('#mag-driving-lyrics .mag-driving-line.is-prev').click();
await expect.poll(async () => (await state(page)).current).toBe(1);
});
test('reduced motion turns the line transition off', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await openDriving(page);
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
await expect.poll(async () => (await state(page)).current).toBe(1);
expect(await page.locator('#mag-driving-lyrics .mag-driving-line.is-current').evaluate((e) => getComputedStyle(e).animationName)).toBe('none');
});
test('375 px wide: three lines fit the box and the page does not scroll sideways', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 700 });
await openDriving(page);
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
await expect.poll(async () => (await state(page)).shown).toEqual([0, 1, 2]);
const box = await page.locator('#mag-driving-lyrics').boundingBox();
expect(box.width).toBeLessThanOrEqual(375);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
});
test('seeking back and forward lands on the sentence containing the time, also in the silent lead-in', async ({ page }) => {
await openDriving(page);
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
// 3.2 s is inside sentence 1's silent lead-in (it lights up at 3.55): still the sentence just spoken
for (const t of [10.5, 2, 7.5, 3.2, 3.6, 0.1, 6.54, 6.56, 11.9, 0]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
await expect.poll(async () => (await state(page)).current, { message: 'seek to ' + t }).toBe(expectedAt(t));
}
});
for (const speed of [1.5, 2]) {
test(`playing at ${speed}x: highlight equals the sentence containing currentTime, and media time runs at the rate`, async ({ page }) => {
await openDriving(page, { speed });
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 1.2; a.play(); });
const samples = [];
const wall0 = Date.now();
let t0 = null;
while (Date.now() - wall0 < 4500) {
const s = await state(page);
if (t0 === null) t0 = { t: s.t, wall: Date.now() };
const nearEdge = EFF.some((e) => Math.abs(s.t - e) < 0.25);
if (!nearEdge) samples.push(s);
if (s.t > 11.5) break;
await page.waitForTimeout(60);
}
const rate = (await state(page)).t - t0.t;
expect(rate / ((Date.now() - t0.wall) / 1000)).toBeGreaterThan(speed * 0.7); // currentTime is media time
expect(samples.length).toBeGreaterThan(5);
for (const s of samples) expect(s.current, 'at ' + s.t).toBe(expectedAt(s.t));
});
}
test('changing the speed mid-sentence does not move the highlight', async ({ page }) => {
await openDriving(page);
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 4; a.play(); });
await expect.poll(async () => (await state(page)).current).toBe(1);
for (const v of [2, 0.75, 1.5]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').playbackRate = x; }, v);
const s = await state(page);
if (!EFF.some((e) => Math.abs(s.t - e) < 0.2)) expect(s.current).toBe(expectedAt(s.t));
}
});
test('rapid repeated seeks settle on the final position with no stale highlight', async ({ page }) => {
await openDriving(page, { speed: 2 });
await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {}));
const finals = [];
for (let round = 0; round < 4; round++) {
const last = await page.evaluate(() => {
const a = document.getElementById('mag-playlist-audio');
let x = 0;
for (let i = 0; i < 30; i++) { x = Math.random() * 11.5; a.currentTime = x; }
return x;
});
await page.waitForTimeout(250);
const s = await state(page);
finals.push(last);
expect(s.t).toBeGreaterThan(last - 0.1);
if (!EFF.some((e) => Math.abs(s.t - e) < 0.25)) expect(s.current, 'after seek to ' + last).toBe(expectedAt(s.t));
}
// a seek while paused is shown without any playback frame running
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 7; });
await expect.poll(async () => (await state(page)).current).toBe(2);
});
});
// ── Driving mode layout: top bar / content / dock; views, whole-text lyrics, picture zoom ──────────────
test.describe('Driving mode layout and views', () => {
const LONG_TITLE = 'Donaudampfschifffahrtsgesellschaftskapitän und die außergewöhnlich lange Überschrift über Batterien';
const EFF = [0, 3.55, 6.55, 9.55];
const expectedAt = (t) => EFF.reduce((n, e, i) => (e <= t ? i : n), 0);
const IMAGE = { id: 'img-1', page: 3, bbox: [0, 0, 1, 1], filename: 'x.png', reading_order: 0, type: 'figure', description: 'Ein sehr langes Bild mit einer langen Beschreibung, die umbrechen muss.' };
async function setup(page) {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
const article = { ...ARTICLE_APPROVED, title: LONG_TITLE, has_audio: true, audio_seconds: 12, images: [IMAGE.id] };
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, images: [IMAGE] } }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => route.fulfill({ json: article }));
await page.route(`**/api/magazine/${DOC_ID}/images/*`, route => route.fulfill({
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), contentType: 'image/png' }));
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'Sentence number ' + i + ' is spoken here.', block: 0, start: s, end: s + 3, speech: s + 0.7 })) } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
}
async function open(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
const card = page.locator('#mag-library-list .mag-card').first();
await page.locator('#mag-library-list .mag-card:visible, button:has-text("Playlist"):visible').first().waitFor();
if (await card.isVisible()) await card.click(); // after a reload the app may already have reopened the magazine
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').first().click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
}
const view = (page, v) => page.locator(`#mag-driving-menu [data-view="${v}"]`);
const openMenu = async (page) => { if ((await page.locator('#mag-driving-menubtn').getAttribute('aria-expanded')) !== 'true') await page.locator('#mag-driving-menubtn').click(); };
const pick = async (page, v) => { await openMenu(page); await view(page, v).click(); };
const toggleCollapse = async (page) => { await openMenu(page); await page.locator('#mag-driving-collapse').click(); };
// Everything is measured from the DOM: the dock is fully on screen, round buttons, nothing covers it or the share button.
async function measure(page) {
return page.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
const q = (s) => document.querySelector(s);
const hit = (a, b) => a.l < b.r - 0.5 && b.l < a.r - 0.5 && a.t < b.b - 0.5 && b.t < a.b - 0.5;
const dock = r(q('.mag-driving-dock')), share = r(q('.mag-driving-share')), content = r(q('.mag-driving-content')), top = r(q('.mag-driving-top'));
const title = q('#mag-driving-title');
const inContent = [...q('.mag-driving-content').querySelectorAll('*')].filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height > 0);
const buttons = [...q('.mag-driving-controls').querySelectorAll('.mag-driving-btn')].map((b) => r(b));
const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]];
return {
vw: innerWidth, vh: innerHeight, dock, share, content, top,
buttonsRound: buttons.every((b) => Math.abs(b.w - b.h) < 0.5),
buttonsInside: buttons.every((b) => b.l >= 0 && b.r <= innerWidth && b.t >= dock.t && b.b <= dock.b),
contentAboveDock: inContent.every((e) => e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption')))),
offenders: inContent.filter((e) => !(e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption'))))).map((e) => e.id || e.className || e.tagName),
contentBoxAboveDock: content.b <= dock.t + 0.5,
shareHitsContent: hit(share, content), shareHitsDock: hit(share, dock),
dockCovered: pts.some(([x, y]) => !q('.mag-driving-dock').contains(document.elementFromPoint(x, y))),
titleClipped: title.scrollWidth > title.clientWidth + 1,
titleBox: r(title),
scrollX: document.documentElement.scrollWidth,
buttonSizes: [...document.querySelectorAll('.mag-driving-top > button, #mag-driving-menubtn, #mag-driving-indexbtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)),
};
});
}
const sizes = [[320, 568], [375, 667], [414, 896], [667, 375], [1280, 720]];
for (const [w, h] of sizes) {
for (const v of ['compact', 'image', 'lyrics']) {
test(`${w}x${h}, ${v} view: dock fully visible, round buttons, nothing overlaps`, async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: w, height: h });
await open(page);
await pick(page, v);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
const m = await measure(page);
expect(m.dock.l).toBeGreaterThanOrEqual(0); expect(m.dock.r).toBeLessThanOrEqual(m.vw + 0.5);
expect(m.dock.t).toBeGreaterThanOrEqual(0); expect(m.dock.b).toBeLessThanOrEqual(m.vh + 0.5);
expect(m.buttonsRound).toBe(true); expect(m.buttonsInside).toBe(true);
expect(m.offenders).toEqual([]); expect(m.contentBoxAboveDock, JSON.stringify([m.content, m.dock])).toBe(true); expect(m.contentAboveDock).toBe(true);
expect(m.shareHitsContent).toBe(false); expect(m.shareHitsDock).toBe(false);
expect(m.dockCovered).toBe(false);
expect(m.content.t).toBeGreaterThanOrEqual(m.top.b - 0.5); // title lives below the top bar
expect(m.titleBox.l).toBeGreaterThanOrEqual(0); expect(m.titleBox.r).toBeLessThanOrEqual(m.vw + 0.5);
expect(m.titleBox.t).toBeGreaterThanOrEqual(m.top.b - 0.5);
expect(m.scrollX).toBeLessThanOrEqual(m.vw);
for (const s of m.buttonSizes) expect(s).toBeGreaterThanOrEqual(43.5); // 44 px touch targets
// minimized: still fully visible, and the current line stays
await toggleCollapse(page);
await expect(page.locator('#mag-driving-collapse')).toHaveAttribute('aria-checked', 'true');
const c = await measure(page);
expect(c.dock.b).toBeLessThanOrEqual(c.vh + 0.5); expect(c.contentBoxAboveDock).toBe(true);
expect(c.content.h).toBeLessThan(m.content.h + 1);
});
}
}
test('each view renders its own content', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(view(page, 'compact')).toHaveAttribute('aria-checked', 'true'); // the default
await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
await expect(page.locator('#mag-driving-full')).toBeHidden();
await pick(page, 'image');
await expect(page.locator('#mag-driving-visual')).toBeVisible();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
await expect(page.locator('#mag-driving-full')).toBeHidden();
await pick(page, 'lyrics');
await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(4);
await expect(page.locator('#mag-driving-visual')).toBeHidden();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
// keyboard: the menu button opens the flyout, arrows move, Enter selects and closes
await page.locator('#mag-driving-menubtn').focus();
await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving-menu')).toBeVisible();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact'); // lyrics -> next item (compact)
await expect(page.locator('#mag-driving-menu')).toBeHidden();
// no live region spam
expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off');
});
test('the chosen view and the minimized state survive a reload', async ({ page }) => {
await setup(page);
await open(page);
await pick(page, 'lyrics');
await page.reload();
await open(page);
await expect(view(page, 'lyrics')).toHaveAttribute('aria-checked', 'true');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics');
await toggleCollapse(page);
await page.reload();
await open(page);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true');
await toggleCollapse(page);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'false');
});
// ── flyout menu, moved dock toggles, speed beside the slider ──
test('flyout: one menu button opens a menu with the views, closes on Escape / outside tap / choice, keeps focus right', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
const btn = page.locator('#mag-driving-menubtn'), menu = page.locator('#mag-driving-menu');
await expect(page.locator('#mag-driving-views')).toHaveCount(0); // the three-icon switch is gone
await expect(btn).toHaveAttribute('aria-haspopup', 'menu');
await expect(btn).toHaveAttribute('aria-expanded', 'false');
await expect(menu).toBeHidden();
await btn.click();
await expect(menu).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'true');
await expect(menu).toHaveAttribute('role', 'menu');
expect(await page.locator('#mag-driving-menu [role="menuitemradio"]').count()).toBe(3);
for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse'])
await expect(page.locator(id)).toHaveAttribute('role', 'menuitemcheckbox');
await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0);
for (const b of await page.locator('#mag-driving-menu button').all()) {
const box = await b.boundingBox(); expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5);
}
await page.keyboard.press('Escape'); // closes, focus back on the button, overlay stays
await expect(menu).toBeHidden();
await expect(btn).toBeFocused();
await expect(page.locator('#mag-driving')).toBeVisible();
await btn.click(); await expect(menu).toBeVisible();
await page.mouse.click(190, 400); // outside tap
await expect(menu).toBeHidden();
await btn.click();
await view(page, 'lyrics').click(); // choosing closes it and applies the view
await expect(menu).toBeHidden();
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'lyrics');
await expect(btn).toHaveAttribute('aria-label', /Lyrics/);
for (const v of ['image', 'compact', 'lyrics']) {
await pick(page, v);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
await expect(view(page, v)).toHaveAttribute('aria-checked', 'true');
}
// keyboard: arrows, Home/End, Enter
await btn.focus(); await page.keyboard.press('ArrowDown');
await expect(menu).toBeVisible();
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'lyrics');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'image');
await page.keyboard.press('End');
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('id', 'mag-driving-collapse');
await page.keyboard.press('Home'); await page.keyboard.press('Enter');
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'image');
});
test('flyout: text and pictures toggles live in the menu and keep their state; speed sits after the time in the slider row', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'compact');
await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
await openMenu(page);
await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'true');
await page.locator('#mag-driving-text-toggle').click();
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
await openMenu(page);
await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'false');
await page.locator('#mag-driving-image-toggle').click();
await expect(page.locator('#mag-driving-visual')).toBeHidden();
await openMenu(page); await page.locator('#mag-driving-text-toggle').click();
await openMenu(page); await page.locator('#mag-driving-image-toggle').click();
await expect(page.locator('#mag-driving-visual')).toBeVisible();
// speed: last item of the slider row, 44px, cycles
const row = await page.evaluate(() => {
const r = (s) => document.querySelector(s).getBoundingClientRect();
const sp = r('#mag-driving-speed'), tm = r('#mag-driving-time'), sk = r('#mag-driving-seek'), dock = r('.mag-driving-dock');
return { speedAfterTime: sp.left >= tm.right - 0.5, sameRow: Math.abs((sp.top + sp.bottom) / 2 - (tm.top + tm.bottom) / 2) < 6,
h: sp.height, w: sp.width, slider: sk.width, inDock: sp.bottom <= dock.bottom && sp.right <= innerWidth,
dockOptions: document.querySelectorAll('.mag-driving-dock .mag-driving-options').length };
});
expect(row.speedAfterTime).toBe(true); expect(row.sameRow).toBe(true); expect(row.inDock).toBe(true);
expect(row.h).toBeGreaterThanOrEqual(43.5); expect(row.w).toBeGreaterThanOrEqual(43.5); expect(row.dockOptions).toBe(0);
await expect(page.locator('#mag-driving-speed')).toHaveText('1×');
await page.locator('#mag-driving-speed').click();
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').playbackRate)).toBe(1.25);
await page.locator('#mag-driving-speed').click();
await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click();
await page.locator('#mag-driving-speed').click(); // 6 speeds: back to 1x
await expect(page.locator('#mag-driving-speed')).toHaveText('1×');
});
const THREE = [
{ id: 'article-001', title: 'The Future of Batteries', pages: [1], has_audio: true, audio_seconds: 12 },
{ id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 },
{ id: 'article-003', title: 'Unvoiced', pages: [5], has_audio: false },
];
async function setupIndex(page, count) {
await setup(page);
const base = { ...ARTICLE_APPROVED, status: 'approved' };
const arts = count ? Array.from({ length: count }, (_, i) => ({ ...base, id: i === 0 ? 'article-001' : 'article-' + String(i + 1).padStart(3, '0'),
title: 'Article number ' + (i + 1), pages: [i + 1], has_audio: i % 10 !== 9, audio_seconds: i === 0 ? 12 : 60 + i }))
: THREE.map((a) => ({ ...base, ...a }));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
}
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
await setupIndex(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
const btn = page.locator('#mag-driving-indexbtn'), panel = page.locator('#mag-driving-index');
await expect(btn).toHaveAttribute('aria-expanded', 'false');
await expect(panel).toBeHidden();
await btn.click();
await expect(panel).toBeVisible();
await expect(btn).toHaveAttribute('aria-expanded', 'true');
const rows = page.locator('#mag-driving-index-list button');
await expect(rows).toHaveCount(3);
await expect(rows.nth(0)).toContainText('The Future of Batteries');
await expect(rows.nth(0)).toContainText('0:12');
await expect(rows.nth(0)).toHaveAttribute('aria-current', 'true');
await expect(rows.nth(1)).toContainText('1:02:05');
await expect(rows.nth(1)).not.toHaveAttribute('aria-current', 'true');
await expect(rows.nth(2)).toBeDisabled();
await expect(rows.nth(2)).toContainText('no audio');
expect(await page.evaluate(() => document.querySelector('.mag-driving-dock').getBoundingClientRect().top - document.getElementById('mag-driving-index').getBoundingClientRect().bottom)).toBeGreaterThanOrEqual(-0.5);
await expect(page.locator('#mag-driving-index-close')).toBeVisible();
await page.keyboard.press('Escape');
await expect(panel).toBeHidden(); await expect(btn).toBeFocused();
await btn.click();
await expect(rows.nth(0)).toBeFocused(); // focus lands on the current article
await page.keyboard.press('ArrowDown');
await expect(rows.nth(1)).toBeFocused();
await page.keyboard.press('ArrowDown'); // disabled row is skipped
await expect(rows.nth(1)).toBeFocused();
await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-close')).toBeFocused();
await page.keyboard.press('Tab'); await expect(rows.nth(0)).toBeFocused(); // trapped: wraps to the first control
await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter');
await expect(panel).toBeHidden();
await expect(page.locator('#mag-driving')).toBeVisible();
await btn.click();
await expect(rows.nth(1)).toHaveAttribute('aria-current', 'true');
await page.locator('#mag-driving-index-close').click();
await expect(panel).toBeHidden();
await btn.click(); await expect(panel).toBeVisible();
await page.mouse.click(230, 25); // tap outside: the top bar beside the buttons
await expect(panel).toBeHidden();
// the exact top-bar list button, while audio plays
await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {}));
await page.locator('.mag-driving-top #mag-driving-indexbtn').click();
await expect(page.locator('#mag-driving-index-list button').filter({ hasText: /\d+:\d\d/ })).toHaveCount(2);
await expect(panel).toBeVisible();
});
test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => {
await setupIndex(page, 113);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await page.evaluate(() => window.magPlaylistPlay('article-090'));
const t = await page.evaluate(() => { const t0 = performance.now(); magDrivingIndexOpen(); return performance.now() - t0; });
expect(t).toBeLessThan(250);
await expect(page.locator('#mag-driving-index-list button')).toHaveCount(113);
const inView = await page.evaluate(() => {
const l = document.getElementById('mag-driving-index-list').getBoundingClientRect(), c = document.querySelector('#mag-driving-index-list [aria-current="true"]').getBoundingClientRect();
return c.top >= l.top - 1 && c.bottom <= l.bottom + 1;
});
expect(inView).toBe(true);
await expect(page.locator('#mag-driving-index-list button:disabled').first()).toContainText('no audio');
});
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
test(`${w}x${h}: flyout and index stay in the viewport and above the dock; dock stays whole with 1:1 buttons`, async ({ page }) => {
await setupIndex(page, 113);
await page.setViewportSize({ width: w, height: h });
await open(page);
const geo = () => page.evaluate(() => {
const r = (el) => { const b = el.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
const dock = r(document.querySelector('.mag-driving-dock'));
const inside = (b) => b.l >= -0.5 && b.r <= innerWidth + 0.5 && b.t >= -0.5 && b.b <= innerHeight + 0.5;
const menu = document.getElementById('mag-driving-menu'), idx = document.getElementById('mag-driving-index');
const out = { dock, dockInside: inside(dock), btns: [...document.querySelectorAll('.mag-driving-controls .mag-driving-btn')].map(r).every((b) => Math.abs(b.w - b.h) < 0.5),
speed: r(document.getElementById('mag-driving-speed')), slider: r(document.getElementById('mag-driving-seek')),
topBtns: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)),
topInside: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].every((b) => inside(r(b))), scrollX: document.documentElement.scrollWidth };
if (!menu.hidden) { const m = r(menu); out.menu = m; out.menuInside = inside(m); out.menuAboveDock = m.b <= dock.t + 0.5;
out.menuItems = [...menu.querySelectorAll('button')].map((b) => b.getBoundingClientRect().height); out.menuScrolls = menu.scrollHeight > menu.clientHeight + 1; }
if (!idx.hidden) { const m = r(idx); out.idx = m; out.idxInside = inside(m); out.idxAboveDock = m.b <= dock.t + 0.5; out.idxBelowTop = m.t >= document.querySelector('.mag-driving-top').getBoundingClientRect().bottom - 0.5; }
const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]];
out.dockCovered = pts.some(([x, y]) => !document.querySelector('.mag-driving-dock').contains(document.elementFromPoint(x, y)));
return out;
});
let g = await geo();
expect(g.dockInside).toBe(true); expect(g.btns).toBe(true); expect(g.topInside).toBe(true);
expect(g.speed.w).toBeGreaterThanOrEqual(43.5); expect(g.speed.h).toBeGreaterThanOrEqual(43.5);
expect(g.slider.w).toBeGreaterThanOrEqual(w === 320 ? 100 : 120);
for (const s of g.topBtns) expect(s).toBeGreaterThanOrEqual(43.5);
await page.locator('#mag-driving-menubtn').click();
g = await geo();
expect(g.menuInside, JSON.stringify(g.menu)).toBe(true); expect(g.menuAboveDock, JSON.stringify([g.menu, g.dock])).toBe(true);
for (const hh of g.menuItems) expect(hh).toBeGreaterThanOrEqual(43.5);
expect(g.dockCovered).toBe(false); expect(g.scrollX).toBeLessThanOrEqual(w);
await page.keyboard.press('Escape');
await page.locator('#mag-driving-indexbtn').click();
g = await geo();
expect(g.idxInside, JSON.stringify(g.idx)).toBe(true); expect(g.idxAboveDock).toBe(true); expect(g.idxBelowTop).toBe(true);
expect(g.dockInside).toBe(true); expect(g.dockCovered).toBe(false); expect(g.btns).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(w);
await expect(page.locator('#mag-driving-index-list button').first()).toBeVisible();
const rowH = await page.locator('#mag-driving-index-list button').first().evaluate((e) => e.getBoundingClientRect().height);
expect(rowH).toBeGreaterThanOrEqual(43.5);
});
}
test('lyrics highlight the sentence containing currentTime after a seek, centered; manual scroll pauses following', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await pick(page, 'lyrics');
const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .is-current')?.dataset.i ?? -1));
for (const t of [10.5, 2, 7.5, 3.6, 0.1]) {
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
await expect.poll(current, { message: 'seek to ' + t }).toBe(expectedAt(t));
}
// the current line ends up near the vertical middle of the scroller
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 7; });
await expect.poll(current).toBe(2);
await expect.poll(() => page.evaluate(() => {
const box = document.getElementById('mag-driving-full').getBoundingClientRect(), el = document.querySelector('#mag-driving-full .is-current').getBoundingClientRect();
return Math.abs((el.top + el.height / 2) - (box.top + box.height / 2));
})).toBeLessThan(40);
// dim neighbours
expect(Number(await page.evaluate(() => getComputedStyle(document.querySelector('#mag-driving-full [data-i="3"]')).opacity))).toBeLessThan(1);
// manual scroll: no auto-follow, "Back to current" appears and restores it
await page.locator('#mag-driving-full').hover();
await page.mouse.wheel(0, -400);
await expect(page.locator('#mag-driving-backcur')).toBeVisible();
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 10; });
await expect.poll(current).toBe(3);
await page.locator('#mag-driving-backcur').click();
await expect(page.locator('#mag-driving-backcur')).toBeHidden();
// tapping a sentence seeks to it
await page.locator('#mag-driving-full [data-i="1"]').click();
await expect.poll(current).toBe(1);
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThanOrEqual(3.5);
});
test('picture zoom: buttons, double tap, keys and Escape', async ({ page }) => {
await setup(page);
await page.setViewportSize({ width: 375, height: 667 });
await open(page);
await pick(page, 'image');
const stage = page.locator('#mag-driving-stage');
await expect(stage).toBeVisible();
await expect(stage.locator('img')).toHaveCount(1);
await expect(stage).toHaveAttribute('data-zoom', '1');
await expect(page.locator('#mag-driving-zoom-reset')).toBeDisabled();
await page.locator('#mag-driving-zoom-in').click();
await expect(stage).toHaveAttribute('data-zoom', '1.5');
await page.locator('#mag-driving-zoom-reset').click();
await expect(stage).toHaveAttribute('data-zoom', '1');
await stage.dblclick();
await expect(stage).toHaveAttribute('data-zoom', '2.5');
await stage.dblclick();
await expect(stage).toHaveAttribute('data-zoom', '1');
await stage.focus();
await page.keyboard.press('+');
await expect(stage).toHaveAttribute('data-zoom', '1.5');
await page.keyboard.press('Escape');
await expect(stage).toHaveAttribute('data-zoom', '1');
await expect(page.locator('#mag-driving')).toBeVisible(); // Escape only reset the zoom
for (const b of ['#mag-driving-zoom-in', '#mag-driving-zoom-out', '#mag-driving-zoom-reset']) {
const box = await page.locator(b).boundingBox();
expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44);
await expect(page.locator(b)).toHaveAttribute('aria-label', /Zoom/i);
}
});
});
// ── Word-level highlight from measured word times (forced aligner), with fallback to interpolation ──────────
test.describe('Driving mode: measured word times drive the word highlight', () => {
const W_START = [0.7, 1.2, 1.6, 2.3], W_END = [1.1, 1.5, 2.2, 2.9]; // seconds into each 3 s sentence
const EFF = [0, 3.55, 6.55, 9.55];
const N_WORDS = [4, 4, 3, 4]; // words in 'Alpha one is here.' ... 'Delta four ends it.'
const expectedWord = (t) => {
const i = EFF.reduce((n, e, k) => (e <= t ? k : n), 0), rel = t - 3 * i;
if (rel < W_START[0] - 0.02) return [i, -1];
return [i, W_START.slice(0, N_WORDS[i]).reduce((n, s, k) => (s <= rel + 0.02 ? k : n), 0)];
};
async function openWords(page, { withWords = true, speed } = {}) {
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
words: withWords ? 'ready' : 'none',
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'S' + i, block: 0, start: s, end: s + 3, speech: s + 0.7,
...(withWords ? { words: W_START.slice(0, N_WORDS[i]).map((w, k) => [s + w, s + W_END[k]]) } : {}) })) } }));
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
});
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await navTo(page, 's-magazine');
await page.locator('#mag-library-list .mag-card').first().click();
await page.getByRole('button', { name: 'Playlist' }).click();
await page.locator('.mag-playlist-track-play').click();
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
await page.evaluate(() => window.magOpenDriving());
await expect(page.locator('#mag-driving')).toBeVisible();
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
if (speed) await page.evaluate((v) => { const a = document.getElementById('mag-playlist-audio'); a.defaultPlaybackRate = v; a.playbackRate = v; }, speed);
}
const lit = (page) => page.evaluate(() => {
const idx = (sel, wsel, attr) => {
const line = document.querySelector(sel); if (!line) return null;
const ws = [...line.querySelectorAll(wsel)]; return [Number(line.dataset[attr]), ws.findIndex((w) => w.classList.contains('is-current'))];
};
return { t: document.getElementById('mag-playlist-audio').currentTime,
compact: idx('#mag-driving-lyrics .mag-driving-line.is-current', '.mag-dw', 'i'),
lyrics: idx('#mag-driving-full .mag-driving-fline.is-current', '.mag-fw', 'i'),
list: idx('#mag-playlist-transcript .is-current', '.mag-w', 'playlistSentence') };
});
const seekPaused = (page, t) => page.evaluate((x) => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = x; }, t);
test('word follows the measured times at several positions and seeks, in compact, lyrics and playlist views', async ({ page }) => {
await openWords(page);
await page.evaluate(() => window.magDrivingSetView('compact'));
for (const t of [0.3, 0.9, 1.35, 2.5, 3.8, 4.3, 5.0, 3.0, 8.4, 11.2, 0.8, 7.0]) {
await seekPaused(page, t);
const want = expectedWord(t);
await expect.poll(async () => (await lit(page)).compact, { message: 'compact at ' + t }).toEqual(want);
await expect.poll(async () => (await lit(page)).list, { message: 'playlist at ' + t }).toEqual(want);
}
await page.evaluate(() => window.magDrivingSetView('lyrics'));
for (const t of [1.0, 2.0, 4.0, 9.0, 6.0]) {
await seekPaused(page, t);
await expect.poll(async () => (await lit(page)).lyrics, { message: 'lyrics at ' + t }).toEqual(expectedWord(t));
}
});
test('measured words differ from the length estimate: the long-pause word stays lit until the next starts', async ({ page }) => {
await openWords(page);
await seekPaused(page, 1.52 + 0.0); // word 1 ended at 1.5; next starts at 1.6: word 1 stays lit
await expect.poll(async () => (await lit(page)).list).toEqual([0, 1]);
await seekPaused(page, 1.7);
await expect.poll(async () => (await lit(page)).list).toEqual([0, 2]);
});
for (const speed of [1.5, 2]) {
test(`playing at ${speed}x the lit word matches the measured table`, async ({ page }) => {
await openWords(page, { speed });
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 0.4; a.play(); });
const wall0 = Date.now(); let checked = 0;
while (Date.now() - wall0 < 5000) {
const s = await lit(page);
const near = [...EFF, ...W_START.flatMap((w) => [0, 3, 6, 9].map((b) => b + w))].some((e) => Math.abs(s.t - e) < 0.07 * speed + 0.04);
if (!near && s.compact) { expect(s.compact, 'at ' + s.t).toEqual(expectedWord(s.t)); checked++; }
if (s.t > 11.5) break;
await page.waitForTimeout(50);
}
expect(checked).toBeGreaterThan(4);
});
}
test('without measured words the highlight falls back to the interpolation and still moves', async ({ page }) => {
await openWords(page, { withWords: false });
await page.evaluate(() => window.magDrivingSetView('compact'));
await seekPaused(page, 1.0);
await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(0);
await seekPaused(page, 2.9);
await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(2);
});
});