tts-voice-creator-clone-and.../tests/magazine.spec.js
mARTin-B78 baab49dbdf feat(magazine): set a page's reading order by hand (type a position or click in order)
A hand-ordered page is locked: Fix reading order, type changes and newly drawn
boxes leave it alone until Automatic order. v1.36.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 11:58:37 +02:00

454 lines
24 KiB
JavaScript

// @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' } = {}) {
const article = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED;
await page.route('**/api/reader/docs', route => route.fulfill({
json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', 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', ingest: { status: 'done', articles: 1 } },
}));
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
json: { articles: [article], progress: {}, default_voice: '', podcast: { hosts: [] } },
}));
await page.route(`**/api/magazine/${DOC_ID}/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}/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 }],
}));
}
test.describe('Magazine reader', () => {
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: 'Review pages' })).toContainText('Page 3');
await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw image', 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.locator('#mag-view-contents')).toBeVisible();
});
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('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('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 image', 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 expect(page.locator('input[name="mag-draw-kind"][value="image"]')).toBeChecked();
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', 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('byline');
await picker.click();
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row
await picker.selectOption('page_number');
await expect.poll(() => changedTo).toBe('page_number');
});
test('every type the server knows is offered, image included', async ({ page }) => {
await mockBackend(page);
await openReview(page);
await expect(page.locator('[data-block-row="b3"] select.mag-block-type-select')).toBeVisible();
const values = await page.locator('[data-block-row="b3"] select.mag-block-type-select option').evaluateAll(
options => options.map(o => o.value));
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'caption', 'pullquote', 'sidebar',
'page_number', 'source', 'ad', 'image']);
});
test('each review row has its own delete button that asks first', async ({ page }) => {
await mockBackend(page);
let deleted = 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 } });
});
await openReview(page);
const button = page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ });
// The button sits in its own column and does not cover the paragraph text.
const [btn, text] = [await button.boundingBox(), await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox()];
expect(btn.x + btn.width).toBeLessThanOrEqual(text.x);
await button.click();
const dialog = page.getByRole('alertdialog', { name: 'Delete region?' });
await expect(dialog).toContainText('Delete this region?');
expect(deleted).toBe(false);
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();
await expect.poll(() => deleted).toBe(true);
});
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');
});