tts-voice-creator-clone-and.../tests/magazine.spec.js
2026-09-28 16:35:33 +02:00

1138 lines
64 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;
}
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}/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', exact: true }).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', exact: true }).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', exact: true }).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', exact: true }).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('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', exact: true }).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: 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', exact: true }).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', exact: true }).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', exact: true }).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('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', exact: true }).click();
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
await page.locator('#mag-review-page-wrap').evaluate(el => {
el.style.width = '400px';
el.scrollIntoView();
});
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', exact: true }).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('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 page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; el.scrollIntoView(); });
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);
});