Some checks are pending
Test and SonarQube / quality (push) Waiting to run
The preview carries each page's regions (percent of the page, type, reading order) for the reading pass and the labelling pass, drawn over the page image with a legend. A server restart that killed the import left it "running" for ever; the status route now reports it as failed. v1.42.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
684 lines
37 KiB
JavaScript
684 lines
37 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', 'in_picture', 'ocr_noise', 'duplicate', 'image']);
|
|
});
|
|
|
|
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()];
|
|
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"] select.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();
|
|
await page.locator('[data-block-row="b3"] select.mag-block-type-select').selectOption('image');
|
|
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', 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', 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 = '400px';
|
|
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');
|
|
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 and is centred in the wider frame.
|
|
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);
|
|
// 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');
|
|
});
|