Some checks are pending
Test and SonarQube / quality (push) Waiting to run
Bulk delete/type for a click- or rectangle-selected set; every destructive region change returns an undo token (one level, refused once something newer happened); text inside a region that becomes a picture is folded into it. TODO: model-based layout detection (PP-DocLayout, LayoutReader) with licence notes. v1.39.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
632 lines
34 KiB
JavaScript
632 lines
34 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' });
|
|
});
|