// @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('needs review'); }); 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