tts-voice-creator-clone-and.../tests/magazine.spec.js
2026-09-30 11:54:50 +02:00

1992 lines
116 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/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('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('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.' });
});
});