1138 lines
64 KiB
JavaScript
1138 lines
64 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', hasAudio = false, progress = {} } = {}) {
|
||
// Each test starts from the article list. A previous test may deliberately
|
||
// switch to page thumbnails, and that preference is persisted by the app.
|
||
await page.addInitScript(() => localStorage.removeItem('ttsvc_mag_contents_view'));
|
||
const base = articleStatus === 'approved' ? ARTICLE_APPROVED : ARTICLE_DETECTED;
|
||
const article = { ...base, has_audio: hasAudio, audio_seconds: hasAudio ? 2 : 0 };
|
||
|
||
await page.route('**/api/reader/docs', route => route.fulfill({
|
||
json: { docs: [{ id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } }] },
|
||
}));
|
||
await page.route(`**/api/reader/docs/${DOC_ID}`, route => route.fulfill({
|
||
json: { id: DOC_ID, kind: 'magazine', title: 'Scientific American', issue: 'September 2026', pageCount: 8, ingest: { status: 'done', articles: 1 } },
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => {
|
||
if (route.request().method() === 'POST') {
|
||
const requestedPage = route.request().postDataJSON().page;
|
||
return route.fulfill({ json: { article: {
|
||
id: 'article-page-' + requestedPage, title: 'Page ' + requestedPage, author: '',
|
||
pages: [requestedPage], blocks: [], images: [], status: 'detected', confidence: 0, section: '',
|
||
} } });
|
||
}
|
||
return route.fulfill({
|
||
json: { articles: [article], progress, default_voice: '', podcast: { hosts: [] } },
|
||
});
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: LAYOUT }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, route => {
|
||
if (route.request().method() === 'PATCH') {
|
||
const body = route.request().postDataJSON();
|
||
return route.fulfill({ json: { ...article, ...body, status: body.status || article.status } });
|
||
}
|
||
return route.fulfill({ json: article });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
|
||
json: { article, blocks: LAYOUT.blocks },
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => route.fulfill({
|
||
body: silentWav(2000), contentType: 'audio/wav',
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/progress`, route => route.fulfill({ json: { ok: true } }));
|
||
// Reads the review screen makes on its own; tests that care override these routes.
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/*/audit`, route => route.fulfill({
|
||
json: { page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [], measured: true, spelling: 0, advertisement: false },
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, route => route.fulfill({ json: { issues: [], dictionary: {} } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({
|
||
json: { links: [], first: null, last: null } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/*`, route => route.fulfill({
|
||
// 1x1 PNG — only geometry/wiring is under test, not real page rendering.
|
||
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'),
|
||
contentType: 'image/png',
|
||
}));
|
||
await page.route('**/api/voices*', route => route.fulfill({
|
||
json: [{ id: 'smoke_voice', name: 'Smoke voice', enabled: true, tag: 'Magazine' }],
|
||
}));
|
||
}
|
||
|
||
test.describe('Magazine reader', () => {
|
||
test('review region header selects and clears every region on the page', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
|
||
const all = page.locator('[data-mag-check-all]').first();
|
||
await expect(all).toBeChecked();
|
||
await all.uncheck();
|
||
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(0);
|
||
await page.locator('[data-mag-check-all]').first().check();
|
||
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
|
||
});
|
||
|
||
test('thumbnail review opens the clicked page for multi-page articles', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 8' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 8');
|
||
});
|
||
|
||
test('Approve & Next advances one physical page inside a multi-page article', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const pageFour = {
|
||
id: 'article-page-4', title: 'Page 4', author: '', pages: [4], blocks: [],
|
||
images: [], status: 'detected', confidence: 0, section: '',
|
||
};
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, async route => {
|
||
if (route.request().method() === 'POST') return route.fulfill({ json: { article: pageFour } });
|
||
return route.fallback();
|
||
});
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.getByRole('button', { name: 'Review', exact: true }).click();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
|
||
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
|
||
});
|
||
|
||
test('figure boundary repair in review only sends the active page', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let requested = '';
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/figures/refine`, route => {
|
||
requested = new URL(route.request().url()).pathname;
|
||
return route.fulfill({ json: { job_id: 'page-figures' } });
|
||
});
|
||
await page.route('**/api/jobs/page-figures', route => route.fulfill({
|
||
json: { done: true, detail: 'Figure boundaries checked on page 3', result: { moved: 1 } },
|
||
}));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Fix figure boundaries (this page)', exact: true }).click();
|
||
await expect.poll(() => requested).toContain('/pages/3/figures/refine');
|
||
expect(requested).not.toBe(`/api/magazine/${DOC_ID}/figures/refine`);
|
||
});
|
||
|
||
test('contents view keeps whole-magazine figure repair as an explicit batch action', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let called = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/figures/refine`, route => {
|
||
called = true;
|
||
return route.fulfill({ json: { job_id: 'all-figures' } });
|
||
});
|
||
await page.route('**/api/jobs/all-figures', route => route.fulfill({
|
||
json: { done: true, detail: 'All figure boundaries checked', result: { moved: 2 } },
|
||
}));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
// The Material Design icon font can become part of the computed accessible
|
||
// name in Chromium, so use the action's stable control id here.
|
||
await page.locator('#mag-refine-all-figures-btn').click();
|
||
const dialog = page.getByRole('alertdialog', { name: 'Fix all figure boundaries?' });
|
||
expect(called).toBe(false);
|
||
await dialog.getByRole('button', { name: 'Fix all pages', exact: true }).click();
|
||
await expect.poll(() => called).toBe(true);
|
||
});
|
||
|
||
test('generated MP3 playlist shows status and synchronized article text', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true, progress: { 'article-001': { seconds: 0.5 } } });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.getByRole('button', { name: 'Playlist' }).click();
|
||
|
||
await expect(page.locator('.mag-playlist-track')).toContainText('p. 3');
|
||
await expect(page.locator('.mag-playlist-track')).toContainText('The Future of Batteries');
|
||
await expect(page.locator('.mag-playlist-track')).toHaveClass(/is-in-progress/);
|
||
await page.locator('.mag-playlist-track-play').click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
|
||
await expect(page.locator('#mag-playlist-companion')).toBeVisible();
|
||
await page.locator('#mag-driving-open').click();
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
|
||
await page.locator('#mag-driving .mag-driving-exit').click();
|
||
const exportPicker = page.locator('.vp-root[data-vp-for="mag-audio-voice"]');
|
||
await exportPicker.locator('.vp-trigger').click();
|
||
await exportPicker.locator('.vp-search').fill('magazine');
|
||
await expect(exportPicker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
|
||
});
|
||
|
||
test('library lists a magazine and opens its contents tree', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
|
||
await expect(page.locator('#mag-library-list .mag-card')).toHaveCount(1);
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
|
||
await expect(page.locator('#mag-view-contents')).toBeVisible();
|
||
await expect(page.locator('.mag-toc-title')).toHaveText('The Future of Batteries');
|
||
// Non-contiguous pages (a jump-linked continuation) must not read as a range.
|
||
await expect(page.locator('.mag-toc-row')).toContainText('pp. 3, 8');
|
||
await expect(page.locator('.mag-toc-row')).toContainText('90%');
|
||
await expect(page.locator('.mag-toc-row')).toContainText('not reviewed');
|
||
});
|
||
|
||
test('review screen shows layout blocks pre-selected and approves', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row');
|
||
|
||
await page.getByRole('button', { name: 'Review' }).click();
|
||
await expect(page.locator('#mag-view-review')).toBeVisible();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
|
||
await expect(page.locator('.mag-review-pagenav').getByRole('button', { name: 'Draw region', exact: true })).toBeVisible();
|
||
|
||
// The article's own blocks (b1-b3) start ticked; nothing else does.
|
||
await expect(page.locator('#mag-review input[data-block]:checked')).toHaveCount(3);
|
||
await expect(page.locator('#mag-rev-title')).toHaveValue('The Future of Batteries');
|
||
|
||
await page.getByRole('button', { name: 'Approve' }).click();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 4');
|
||
});
|
||
|
||
test('approved article reads, highlights sentences, and links to the PDF page', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect(page.locator('#mag-view-read')).toBeVisible();
|
||
await expect(page.locator('#mag-read-title')).toHaveText('The Future of Batteries');
|
||
await expect(page.locator('#mag-read-by')).toHaveText('By Jane Smith');
|
||
|
||
// title (1) + byline (1) + body split into 2 sentences = 4 spans.
|
||
const sentences = page.locator('#mag-article [data-i]');
|
||
await expect(sentences).toHaveCount(4);
|
||
await expect(sentences.first()).toHaveClass(/is-current/);
|
||
|
||
// Click-to-seek moves the highlight and the progress counter.
|
||
await sentences.nth(3).click();
|
||
await expect(sentences.nth(3)).toHaveClass(/is-current/);
|
||
await expect(sentences.first()).not.toHaveClass(/is-current/);
|
||
await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4');
|
||
|
||
// Switching to the PDF pane shows the page the current sentence came from.
|
||
await page.getByRole('button', { name: 'Original page' }).click();
|
||
await expect(page.locator('#mag-pane-pdf')).toBeVisible();
|
||
await expect(page.locator('#mag-page-label')).toHaveText('Page 3');
|
||
await expect(page.locator('#mag-page-box')).toBeVisible();
|
||
});
|
||
|
||
test('narrator uses the searchable picker; Prepare audio synthesises every sentence once, ahead of playback', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
const synthesised = [];
|
||
await page.route('**/api/tts-preview', route => {
|
||
synthesised.push(route.request().postDataJSON().text);
|
||
return route.fulfill({ body: silentWav(), contentType: 'audio/wav' });
|
||
});
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
|
||
|
||
// The plain <select> is replaced by the app-wide searchable picker.
|
||
const picker = page.locator('.vp-root[data-vp-for="mag-voice"]');
|
||
await expect(picker).toBeVisible();
|
||
await expect(page.locator('#mag-voice')).toBeHidden();
|
||
await picker.locator('.vp-trigger').click();
|
||
await picker.locator('.vp-search').fill('zzz-no-such-voice');
|
||
await expect(picker.locator('.vp-empty')).toHaveText('No voices found');
|
||
await picker.locator('.vp-search').fill('magazine');
|
||
await expect(picker.locator('.vp-item[data-value="smoke_voice"]')).toBeVisible();
|
||
await picker.locator('.vp-search').fill('smoke');
|
||
await picker.locator('.vp-item[data-value="smoke_voice"]').click();
|
||
await expect(page.locator('#mag-voice')).toHaveValue('smoke_voice');
|
||
|
||
// Preparing synthesises each of the 4 sentences exactly once, before any playback.
|
||
expect(synthesised).toHaveLength(0);
|
||
await page.getByRole('button', { name: 'Prepare audio' }).click();
|
||
await expect.poll(() => synthesised.length).toBe(4);
|
||
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
|
||
|
||
// Listening afterwards is served entirely from the prepared audio.
|
||
await page.locator('#mag-play').click();
|
||
await expect(page.locator('#mag-read-progress')).toHaveText('4 / 4', { timeout: 15_000 });
|
||
expect(synthesised).toHaveLength(4);
|
||
expect(new Set(synthesised).size).toBe(4);
|
||
});
|
||
|
||
test('Prepare audio without a narrator voice is refused and synthesises nothing', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
let calls = 0;
|
||
await page.route('**/api/tts-preview', route => { calls += 1; return route.fulfill({ body: silentWav(), contentType: 'audio/wav' }); });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4);
|
||
|
||
await page.getByRole('button', { name: 'Prepare audio' }).click();
|
||
await page.waitForTimeout(300);
|
||
expect(calls).toBe(0);
|
||
await expect(page.getByRole('button', { name: 'Prepare audio' })).toBeVisible();
|
||
});
|
||
|
||
test('podcast generation is refused before the article is approved', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'detected' });
|
||
let podcastCalled = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/podcast`, (route) => {
|
||
podcastCalled = true;
|
||
return route.fulfill({ status: 400, json: { detail: 'Approve the article before generating a podcast' } });
|
||
});
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row');
|
||
|
||
// Detected (unapproved) articles show Review only — no Listen/Continue,
|
||
// so there is no route into the read view (and podcast mode) at all.
|
||
await expect(page.locator('.mag-toc-row').getByRole('button', { name: /Listen|Continue/ })).toHaveCount(0);
|
||
expect(podcastCalled).toBe(false);
|
||
});
|
||
|
||
test('no console errors navigating the whole flow', async ({ page }) => {
|
||
const errors = [];
|
||
page.on('pageerror', (e) => errors.push(String(e)));
|
||
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await page.waitForSelector('#mag-article [data-i]');
|
||
await page.getByRole('button', { name: 'Original page' }).click();
|
||
await page.waitForTimeout(300);
|
||
|
||
const real = errors.filter(e => /ReferenceError|TypeError|SyntaxError|is not defined|is not a function/.test(e));
|
||
expect(real, real.join('\n')).toHaveLength(0);
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine pronunciation and spelling checks', () => {
|
||
const SPELL_ISSUE = {
|
||
token: 'Batterie', block_id: 'b3', page: 3, start: 0, end: 8, language: 'en',
|
||
suggestions: ['Battery'], context: 'Batterie technology is entering a new phase.',
|
||
};
|
||
|
||
async function openReadView(page) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.waitForSelector('#mag-library-list .mag-card');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await page.waitForSelector('#mag-article [data-i]');
|
||
}
|
||
|
||
test('the glossary is edited and saved, and a spelling finding is corrected with one click', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
const writes = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/acronyms`, (route) => {
|
||
if (route.request().method() === 'PUT') writes.push(route.request().postDataJSON());
|
||
return route.fulfill({ json: { language: 'en', glossary: writes.length ? writes[0].glossary : {},
|
||
unknown: writes.length ? [] : [{ acronym: 'NAS', count: 2, context: 'The NAS stores everything.' }] } });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
|
||
if (route.request().method() === 'POST') { writes.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); }
|
||
return route.fulfill({ json: { issues: writes.some((w) => w.fix) ? [] : [SPELL_ISSUE], dictionary: {} } });
|
||
});
|
||
await openReadView(page);
|
||
await page.locator('#mag-checks > summary').click();
|
||
|
||
const input = page.getByLabel('Spoken form of NAS');
|
||
await expect(input).toBeVisible();
|
||
await input.fill('network storage');
|
||
await input.blur();
|
||
await expect.poll(() => writes.find((w) => w.glossary)?.glossary).toEqual({ NAS: 'network storage' });
|
||
|
||
await page.getByRole('button', { name: 'Replace Batterie with Battery' }).click();
|
||
await expect.poll(() => writes.find((w) => w.fix)?.fix).toMatchObject(
|
||
{ block_id: 'b3', token: 'Batterie', replacement: 'Battery', start: 0, end: 8 });
|
||
await expect(page.locator('.mag-spell-list')).toHaveCount(0);
|
||
});
|
||
|
||
test('misspelled words are marked in the review list and can be kept in the dictionary', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
let kept = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
|
||
if (route.request().method() === 'POST') { kept = route.request().postDataJSON(); return route.fulfill({ json: { dictionary: {} } }); }
|
||
return route.fulfill({ json: { issues: kept ? [] : [{ ...SPELL_ISSUE, token: 'entering', start: 22, end: 30, suggestions: [] }], dictionary: {} } });
|
||
});
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
|
||
await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveText('entering');
|
||
await page.getByRole('button', { name: /^Spelling/ }).click();
|
||
await page.locator('#mag-spell-panel').getByRole('button', { name: 'Keep' }).click();
|
||
await expect.poll(() => kept).toEqual({ word: 'entering' });
|
||
await expect(page.locator('#mag-review .mag-block mark.mag-spell')).toHaveCount(0);
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine: page colours', () => {
|
||
async function openGrid(page, articleStatus) {
|
||
await mockBackend(page, { articleStatus });
|
||
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: {
|
||
summary: {}, pages: [{ page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [{ id: 'b3', side: 'right' }],
|
||
measured: true, spelling: [], needs_review: true, words_to_check: false }] } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
return page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).locator('.mag-card');
|
||
}
|
||
|
||
test('a flagged page nobody has approved is purple', async ({ page }) => {
|
||
await expect(await openGrid(page, 'detected')).toHaveClass(/is-page-flagged/);
|
||
});
|
||
|
||
test('an old MP3 on an unapproved article does not turn a flagged page green', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'detected', hasAudio: true });
|
||
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: {
|
||
summary: {}, pages: [{ page: 3, uncovered: [], uncovered_ratio: 0, suspect_blocks: [], overlaps: [], clipped: [{ id: 'b3', side: 'right' }],
|
||
measured: true, spelling: [], needs_review: true, words_to_check: false }] } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
const card = page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).locator('.mag-card');
|
||
await expect(card).toHaveClass(/is-page-flagged/);
|
||
await expect(card).not.toHaveClass(/is-page-audio/);
|
||
});
|
||
|
||
test('once the page\'s article is approved the approval wins over the flag', async ({ page }) => {
|
||
const card = await openGrid(page, 'approved');
|
||
await expect(card).not.toHaveClass(/is-page-flagged/);
|
||
await expect(card).toHaveClass(/is-page-approved/);
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine: unreviewed articles', () => {
|
||
async function openContents(page, opts) {
|
||
await mockBackend(page, opts);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row');
|
||
}
|
||
|
||
test('an article nobody has approved is blue and says "not reviewed"', async ({ page }) => {
|
||
await openContents(page, { articleStatus: 'detected' });
|
||
const row = page.locator('.mag-toc-row');
|
||
await expect(row).toHaveClass(/is-unreviewed/);
|
||
await expect(row.locator('.mag-pill.unreviewed')).toHaveText('not reviewed');
|
||
const edge = await row.evaluate((el) => getComputedStyle(el).borderLeftColor);
|
||
expect(edge).toBe('rgb(37, 99, 235)'); // the blue edge
|
||
const bg = await row.evaluate((el) => getComputedStyle(el).backgroundColor);
|
||
expect(bg).not.toBe('rgba(0, 0, 0, 0)'); // and a tinted background, not transparent
|
||
});
|
||
|
||
test('an approved article is not blue', async ({ page }) => {
|
||
await openContents(page, { articleStatus: 'approved' });
|
||
await expect(page.locator('.mag-toc-row')).not.toHaveClass(/is-unreviewed/);
|
||
await expect(page.locator('.mag-toc-row')).toHaveClass(/is-approved/);
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine: what is wrong on this page', () => {
|
||
async function openReview(page) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
}
|
||
|
||
test('each problem gets its own marker on the page and a plain-language row with a fix', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/*/audit`, route => route.fulfill({ json: {
|
||
page: 3, uncovered: [[300, 200, 100, 50]], uncovered_ratio: 0.02, suspect_blocks: ['b3'], overlaps: [['b1', 'b2']],
|
||
clipped: [{ id: 'b3', side: 'right' }], measured: true, spelling: 2, advertisement: false } }));
|
||
await openReview(page);
|
||
|
||
const panel = page.locator('#mag-issues-panel');
|
||
await expect(panel).toContainText('What is wrong on this page');
|
||
await expect(panel).toContainText('Text outside any box');
|
||
await expect(panel).toContainText('Edge cuts text');
|
||
await expect(panel).toContainText('Overlaps another box');
|
||
await expect(panel).not.toContainText('mis-read');
|
||
await expect(panel).toContainText('Unknown words');
|
||
await expect(page.locator('#mag-review-page-wrap .mag-issue-box.is-uncovered')).toHaveCount(1);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-clipped.side-right')).toHaveCount(1);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-overlap')).toHaveCount(2);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-review-box.is-issue-suspect')).toHaveCount(0);
|
||
});
|
||
|
||
test('Fix this page automatically starts the job, offers Undo and reloads the page', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let started = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/autofix`, (route) => {
|
||
started = route.request().postDataJSON();
|
||
return route.fulfill({ json: { job_id: 'fix1' } });
|
||
});
|
||
await page.route('**/api/jobs/fix1', route => route.fulfill({ json: {
|
||
done: true, detail: 'Page 3: 2 fixes applied', result: { boxes_fitted: 2, undo: 'tok' } } }));
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Fix this page automatically' }).click();
|
||
await expect.poll(() => started).toEqual({ mode: 'all' });
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Auto-fixed page 3');
|
||
});
|
||
|
||
test('a clean page says so', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-issues-panel')).toContainText('Nothing wrong found on this page');
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine: text flow indicator', () => {
|
||
async function openReview(page) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' })
|
||
.getByRole('button', { name: 'Review', exact: true }).click();
|
||
}
|
||
|
||
test('review draws an arrow for a continuation on the page and chips for the other pages and the ends', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
|
||
links: [
|
||
{ from: 'b1', to: 'b3', relation: 'column', from_page: 3, to_page: 3 },
|
||
{ from: 'b3', to: 'x9', relation: 'page', from_page: 3, to_page: 8 },
|
||
{ from: 'y1', to: 'b2', relation: 'page', from_page: 2, to_page: 3 }],
|
||
first: 'b1', last: 'x9' } }));
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-path')).toHaveCount(1);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-label')).toHaveText('next column');
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'continues on page 8' })).toHaveCount(1);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'from page 2' })).toHaveCount(1);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article starts' })).toHaveCount(1);
|
||
|
||
await page.getByLabel('Show text flow').uncheck();
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-path, #mag-review-page-wrap .mag-flow-chip')).toHaveCount(0);
|
||
});
|
||
|
||
test('an article without a signed end offers to link the next page instead of claiming it ends', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let linked = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
|
||
links: [], first: 'b1', last: 'b3', last_page: 3, ended: false } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/link-page`, (route) => {
|
||
linked = route.request().postDataJSON();
|
||
return route.fulfill({ json: { ok: true, undo: 'tok', added: ['b9'], page: 4 } });
|
||
});
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article ends' })).toHaveCount(0);
|
||
await page.getByRole('button', { name: /no end marker — link page 4/ }).click();
|
||
await expect.poll(() => linked).toEqual({ page: 4, where: 'after' });
|
||
});
|
||
|
||
test('a signed end says the article ends', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/flow`, route => route.fulfill({ json: {
|
||
links: [], first: 'b1', last: 'b3', last_page: 3, ended: true } }));
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-review-page-wrap .mag-flow-chip', { hasText: 'article ends' })).toHaveCount(1);
|
||
});
|
||
|
||
test('the reading text marks where a paragraph crosses a column or a page', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
const text = 'Der Satz beginnt hier und geht auf der nächsten Seite weiter, bis er endet.';
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: {
|
||
article: ARTICLE_APPROVED, blocks: [{
|
||
id: 'b3', page: 3, bbox: [50, 150, 200, 300], type: 'body', text, spoken_text: text, reading_order: 2,
|
||
parts: [{ id: 'b3', page: 3, bbox: [50, 150, 200, 300], start: 0, end: 30 },
|
||
{ id: 'z1', page: 4, bbox: [50, 60, 200, 300], start: 31, end: text.length }] }] } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.waitForSelector('.mag-toc-row.is-approved');
|
||
await page.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
const seam = page.locator('#mag-article .mag-seam');
|
||
await expect(seam).toHaveCount(1);
|
||
await expect(seam).toHaveText('p.4 ›');
|
||
await expect(seam).toHaveAttribute('title', 'Continues on page 4');
|
||
});
|
||
});
|
||
|
||
test('an article is linked to a page it was not connected to, from the review panel', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let body = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/link-page`, (route) => {
|
||
body = route.request().postDataJSON();
|
||
return route.fulfill({ json: { ok: true, undo: 'tok', added: ['b9', 'b10'], page: 4 } });
|
||
});
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-contents-view-toggle').click();
|
||
await page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' }).getByRole('button', { name: 'Review', exact: true }).click();
|
||
await page.waitForSelector('#mag-issues-panel .mag-issues-head');
|
||
await page.getByLabel('Link to page').fill('4');
|
||
await page.getByRole('button', { name: 'Article continues there' }).click();
|
||
await expect.poll(() => body).toEqual({ page: 4, where: 'after' });
|
||
await expect(page.locator('#mag-review .mag-review-pagenav-group strong')).toContainText('Page 4');
|
||
});
|
||
|
||
test('drawing a chart links the crop and excludes enclosed narration on approval', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let cropped = false;
|
||
const image = { id: 'manual-chart', page: 3, bbox: [40, 110, 220, 350], filename: 'manual-chart.png', reading_order: 4 };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({
|
||
json: { ...LAYOUT, images: cropped ? [image] : [] },
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions`, route => {
|
||
expect(route.request().postDataJSON().kind).toBe('image');
|
||
cropped = true;
|
||
return route.fulfill({ json: { job_id: 'crop-job' } });
|
||
});
|
||
await page.route('**/api/jobs/crop-job', route => route.fulfill({ json: { done: true } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.getByRole('button', { name: 'Review', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
|
||
await page.locator('#mag-review-page-wrap').evaluate(el => {
|
||
el.style.width = '400px';
|
||
el.scrollIntoView();
|
||
});
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
await page.mouse.move(wrap.x + wrap.width * 40 / 595, wrap.y + wrap.height * 110 / 842);
|
||
await page.mouse.down();
|
||
await page.mouse.move(wrap.x + wrap.width * 260 / 595, wrap.y + wrap.height * 460 / 842, { steps: 5 });
|
||
await page.mouse.up();
|
||
await page.locator('#mag-draw-kind-search').fill('Image');
|
||
await page.locator('#mag-draw-kind-list').getByRole('option', { name: 'Image', exact: true }).click();
|
||
await expect(page.locator('#mag-draw-kind')).toHaveValue('image');
|
||
await expect(page.locator('#mag-draw-text')).toBeHidden();
|
||
await page.getByRole('button', { name: 'Add region', exact: true }).click();
|
||
await expect(page.locator('[data-block-row="manual-chart"]')).toContainText('Shown during playback');
|
||
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked();
|
||
await expect(page.locator('#mag-review input[data-block="b2"]')).not.toBeChecked();
|
||
await expect(page.locator('#mag-review input[data-block="b3"]')).not.toBeChecked();
|
||
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
|
||
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
|
||
expect((await request).postDataJSON()).toMatchObject({ images: ['manual-chart'], blocks: ['b1'] });
|
||
});
|
||
|
||
test('dropping a PDF file onto mag-dropzone triggers creation without browser navigation', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let uploadCalled = false;
|
||
await page.route('**/api/magazine/docs', route => {
|
||
uploadCalled = true;
|
||
return route.fulfill({ json: { id: DOC_ID } });
|
||
});
|
||
await page.route(`**/api/reader/docs/${DOC_ID}/source*`, route => route.fulfill({ json: { ok: true } }));
|
||
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
|
||
const dropzone = page.locator('#mag-dropzone');
|
||
await expect(dropzone).toBeVisible();
|
||
|
||
const dataTransfer = await page.evaluateHandle(() => {
|
||
const dt = new DataTransfer();
|
||
const file = new File(['%PDF-1.4 dummy pdf content'], 'ct_KI_Wissen_2026.pdf', { type: 'application/pdf' });
|
||
dt.items.add(file);
|
||
return dt;
|
||
});
|
||
|
||
await dropzone.dispatchEvent('dragenter', { dataTransfer });
|
||
await dropzone.dispatchEvent('dragover', { dataTransfer });
|
||
await dropzone.dispatchEvent('drop', { dataTransfer });
|
||
|
||
await page.waitForTimeout(300);
|
||
expect(uploadCalled).toBe(true);
|
||
});
|
||
|
||
|
||
async function openReview(page) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.getByRole('button', { name: 'Review', exact: true }).click();
|
||
}
|
||
|
||
test('a region type can be changed from the review list without toggling its checkbox', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let changedTo = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b2`, route => {
|
||
expect(route.request().method()).toBe('PATCH');
|
||
changedTo = route.request().postDataJSON().type;
|
||
return route.fulfill({ json: { ok: true, id: 'b2', type: changedTo } });
|
||
});
|
||
await openReview(page);
|
||
const row = page.locator('[data-block-row="b2"]');
|
||
const checkbox = page.locator('#mag-review input[data-block="b2"]');
|
||
const before = await checkbox.isChecked();
|
||
const picker = row.getByRole('combobox', { name: /Type of region/ });
|
||
await expect(picker).toHaveValue('Author');
|
||
await picker.click();
|
||
await expect(picker).toHaveValue('');
|
||
await expect(checkbox).toBeChecked({ checked: before }); // opening the picker did not toggle the row
|
||
await row.getByRole('option', { name: 'Page Number', exact: true }).click();
|
||
await expect.poll(() => changedTo).toBe('page_number');
|
||
});
|
||
|
||
test('the icon picker offers every server type in editorial order', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
const row = page.locator('[data-block-row="b3"]');
|
||
await row.getByRole('combobox', { name: /Type of region/ }).click();
|
||
const values = await row.locator('.mag-type-picker-option').evaluateAll(
|
||
options => options.map(o => o.dataset.value));
|
||
expect(values).toEqual(['body', 'title', 'heading', 'deck', 'byline', 'image', 'caption', 'in_picture',
|
||
'table', 'quote', 'pullquote', 'sidebar', 'ad', 'page_number', 'source', 'duplicate', 'ocr_noise']);
|
||
await expect(row.locator('.mag-type-picker-option .mdi')).toHaveCount(values.length);
|
||
});
|
||
|
||
test('each review row has its own delete button, which deletes at once and offers undo', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let deleted = false, undone = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
|
||
expect(route.request().method()).toBe('DELETE');
|
||
deleted = true;
|
||
return route.fulfill({ json: { ok: true, undo: 'tok-1' } });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/undo`, route => {
|
||
expect(route.request().postDataJSON()).toEqual({ token: 'tok-1' });
|
||
undone = true;
|
||
return route.fulfill({ json: { ok: true } });
|
||
});
|
||
await openReview(page);
|
||
const button = page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ });
|
||
const [btn, text] = [await button.boundingBox(), await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox()];
|
||
// Wide screens: the button sits left of the text. Phones drop the text onto its own line, so the
|
||
// button must be above it instead.
|
||
if (text.y >= btn.y + btn.height - 1) expect(btn.y + btn.height).toBeLessThanOrEqual(text.y + 1);
|
||
else expect(btn.x + btn.width).toBeLessThanOrEqual(text.x);
|
||
await button.click();
|
||
await expect.poll(() => deleted).toBe(true);
|
||
await expect(page.getByRole('alertdialog')).toHaveCount(0);
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
|
||
await page.locator('#mag-undo-bar').getByRole('button', { name: 'Undo', exact: true }).click();
|
||
await expect.poll(() => undone).toBe(true);
|
||
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
|
||
});
|
||
|
||
test('fix reading order asks first, then reports how many pages changed', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let called = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => {
|
||
called = true;
|
||
return route.fulfill({ json: { ok: true, pages_changed: [3, 5], pages_locked: 1 } });
|
||
});
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
|
||
const dialog = page.getByRole('alertdialog', { name: 'Fix reading order?' });
|
||
await expect(dialog).toContainText('Pages you ordered by hand are kept');
|
||
expect(called).toBe(false);
|
||
await dialog.getByRole('button', { name: 'Fix reading order', exact: true }).click();
|
||
await expect.poll(() => called).toBe(true);
|
||
await expect(page.getByText(/Reading order fixed on 2 pages \(1 hand-ordered page kept\)/)).toBeVisible();
|
||
});
|
||
|
||
test('typing a position moves a region and saves the whole page order', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let sent = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
|
||
expect(route.request().method()).toBe('PUT');
|
||
sent = route.request().postDataJSON().order;
|
||
return route.fulfill({ json: { ok: true, locked: true } });
|
||
});
|
||
await openReview(page);
|
||
const position = page.getByRole('spinbutton', { name: /Reading position of region 3/ });
|
||
await position.fill('1');
|
||
await position.press('Enter');
|
||
await expect.poll(() => sent).toEqual(['b3', 'b1', 'b2']);
|
||
});
|
||
|
||
test('order by clicking: boxes clicked on the page are saved in click order', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let sent = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
|
||
sent = route.request().postDataJSON().order;
|
||
return route.fulfill({ json: { ok: true, locked: true } });
|
||
});
|
||
await openReview(page);
|
||
const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked();
|
||
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
|
||
await expect(page.locator('#mag-order-bar')).toContainText('0 of 3 set');
|
||
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
|
||
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
|
||
await expect(page.locator('#mag-order-bar')).toContainText('2 of 3 set');
|
||
await expect(page.locator('#mag-review-page-wrap [data-block="b2"] .mag-review-box-num')).toHaveText('1');
|
||
// Clicking boxes to order them must not tick or untick them.
|
||
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked });
|
||
await page.getByRole('button', { name: 'Save order', exact: true }).click();
|
||
await expect.poll(() => sent).toEqual(['b2', 'b1']);
|
||
});
|
||
|
||
test('a hand-ordered page says so and can go back to the automatic order', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let reset = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({
|
||
json: { ...LAYOUT, pages: [{ ...LAYOUT.pages[0], order_locked: !reset }] },
|
||
}));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/reading-order`, route => {
|
||
expect(route.request().method()).toBe('DELETE');
|
||
reset = true;
|
||
return route.fulfill({ json: { ok: true, locked: false } });
|
||
});
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-order-bar')).toContainText('set by hand');
|
||
await page.getByRole('button', { name: 'Automatic order', exact: true }).click();
|
||
await expect.poll(() => reset).toBe(true);
|
||
await expect(page.locator('#mag-order-bar')).not.toContainText('set by hand');
|
||
});
|
||
|
||
test('clean up boxes asks first and reports what it changed', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let called = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/boxes/clean-up`, route => {
|
||
called = true;
|
||
return route.fulfill({ json: { ok: true, backup: 'layout.backup-20260921T120000.json',
|
||
counts: { in_picture: 71, ocr_noise: 25, duplicate: 38, byline_split: 5 }, report: {} } });
|
||
});
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Clean up boxes', exact: true }).click();
|
||
const dialog = page.getByRole('alertdialog', { name: 'Clean up boxes?' });
|
||
await expect(dialog).toContainText('Nothing is deleted');
|
||
expect(called).toBe(false);
|
||
await dialog.getByRole('button', { name: 'Clean up', exact: true }).click();
|
||
await expect(page.getByText(/71 text in pictures, 25 OCR noise, 38 duplicates, 5 author lines split/)).toBeVisible();
|
||
});
|
||
|
||
test('a box marked as text in a picture shows that type in its picker', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT,
|
||
blocks: LAYOUT.blocks.map(b => b.id === 'b3' ? { ...b, type: 'furniture', region_label: 'in_picture' } : b) } }));
|
||
await openReview(page);
|
||
await expect(page.locator('[data-block-row="b3"] .mag-block-type-select')).toHaveValue('in_picture');
|
||
});
|
||
|
||
test('a region text can be corrected and split at the cursor from the list', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const calls = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3**`, route => {
|
||
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
|
||
return route.fulfill({ json: { ok: true, id: 'b3', new_id: 't0003-000' } });
|
||
});
|
||
await openReview(page);
|
||
const row = page.locator('[data-block-row="b3"]');
|
||
await row.getByRole('button', { name: /Edit text \(region 3\)/ }).click();
|
||
const area = row.getByRole('textbox', { name: 'Text of this region' });
|
||
await expect(area).toHaveValue('Battery technology is entering a new phase. It will take years to mature.');
|
||
// Typing letters that are shortcuts elsewhere must stay in the editor.
|
||
await area.press('End');
|
||
await area.type(' mr');
|
||
expect(calls).toEqual([]);
|
||
await row.getByRole('button', { name: 'Save text', exact: true }).click();
|
||
await expect.poll(() => calls.length).toBe(1);
|
||
expect(calls[0]).toMatchObject({ method: 'PATCH', body: { text: 'Battery technology is entering a new phase. It will take years to mature. mr' } });
|
||
|
||
await row.getByRole('button', { name: /Edit text \(region 3\)/ }).click();
|
||
const again = row.getByRole('textbox', { name: 'Text of this region' });
|
||
await again.evaluate(el => el.setSelectionRange(43, 43));
|
||
await row.getByRole('button', { name: 'Split here', exact: true }).click();
|
||
await expect.poll(() => calls.length).toBe(2);
|
||
expect(calls[1].url).toContain('/regions/b3/split');
|
||
expect(calls[1].body).toEqual({ at: 43 });
|
||
});
|
||
|
||
test('merge with next and read again are one click away', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const calls = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b2**`, route => {
|
||
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
|
||
return route.fulfill({ json: { ok: true, id: 'b2', removed: 'b3' } });
|
||
});
|
||
await openReview(page);
|
||
const row = page.locator('[data-block-row="b2"]');
|
||
await row.getByRole('button', { name: /Merge with the next region/ }).click();
|
||
await expect.poll(() => calls.length).toBe(1);
|
||
expect(calls[0].url).toContain('/regions/b2/merge-next');
|
||
await row.getByRole('button', { name: /Read again/ }).click();
|
||
await expect.poll(() => calls.length).toBe(2);
|
||
expect(calls[1]).toMatchObject({ method: 'PATCH', body: { reocr: true } });
|
||
// The last region has nothing after it to merge with.
|
||
await expect(page.locator('[data-block-row="b3"]').getByRole('button', { name: /Merge with the next region/ })).toHaveCount(0);
|
||
});
|
||
|
||
test('the review list can be worked from the keyboard', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const calls = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/**`, route => {
|
||
calls.push({ method: route.request().method(), url: route.request().url(), body: route.request().postDataJSON() });
|
||
return route.fulfill({ json: { ok: true } });
|
||
});
|
||
await openReview(page);
|
||
await page.locator('#mag-review input[data-block="b1"]').focus();
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(page.locator('#mag-review input[data-block="b2"]')).toBeFocused();
|
||
await page.keyboard.press('r');
|
||
await expect.poll(() => calls.length).toBe(1);
|
||
expect(calls[0].url).toContain('/regions/b2');
|
||
expect(calls[0].body).toEqual({ reocr: true });
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeFocused();
|
||
await page.keyboard.press('Enter');
|
||
await expect(page.locator('[data-block-row="b3"]').getByRole('textbox', { name: 'Text of this region' })).toBeFocused();
|
||
});
|
||
|
||
test('approving after turning a ticked text box into an image sends the new image, not the old id', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let converted = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: converted
|
||
? { ...LAYOUT, blocks: LAYOUT.blocks.filter(b => b.id !== 'b3'), images: [{ id: 'p0003-manual-000', page: 3, bbox: [50, 150, 200, 300], filename: 'x.png', reading_order: 2 }] }
|
||
: LAYOUT }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
|
||
converted = true;
|
||
return route.fulfill({ json: { ok: true, id: 'p0003-manual-000', type: 'image' } });
|
||
});
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-review input[data-block="b3"]')).toBeChecked();
|
||
const picker = page.locator('[data-block-row="b3"]').getByRole('combobox', { name: /Type of region/ });
|
||
await picker.click();
|
||
await page.locator('[data-block-row="b3"]').getByRole('option', { name: 'Image', exact: true }).click();
|
||
await expect(page.locator('[data-block-row="p0003-manual-000"]')).toBeVisible();
|
||
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
|
||
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
|
||
const body = (await request).postDataJSON();
|
||
expect(body.blocks).not.toContain('b3');
|
||
expect(body.images).toContain('p0003-manual-000');
|
||
});
|
||
|
||
test('approving after deleting a ticked box does not send its id', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let deleted = false;
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: deleted
|
||
? { ...LAYOUT, blocks: LAYOUT.blocks.filter(b => b.id !== 'b3') } : LAYOUT }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { deleted = true; return route.fulfill({ json: { ok: true } }); });
|
||
await openReview(page);
|
||
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
|
||
await expect(page.locator('[data-block-row="b3"]')).toHaveCount(0);
|
||
const request = page.waitForRequest(r => r.method() === 'PATCH' && r.url().includes('/articles/article-001'));
|
||
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
|
||
expect((await request).postDataJSON().blocks).not.toContain('b3');
|
||
});
|
||
|
||
test('select boxes by dragging a rectangle, then delete them in one go with undo', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let sent = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
|
||
sent = route.request().postDataJSON();
|
||
return route.fulfill({ json: { ok: true, undo: 'tok-2', remap: { b2: null, b3: null }, count: 2 } });
|
||
});
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
|
||
await expect(page.locator('#mag-select-bar')).toContainText('drag a rectangle');
|
||
await page.locator('#mag-review-page-wrap').evaluate(el => { el.style.width = '400px'; el.scrollIntoView(); });
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
// b2 [50,120,200,14] and b3 [50,150,200,300] on a 595x842 page; b1 (the headline at y 60) stays out.
|
||
const at = (x, y) => [wrap.x + wrap.width * x / 595, wrap.y + wrap.height * y / 842];
|
||
await page.mouse.move(...at(40, 112));
|
||
await page.mouse.down();
|
||
await page.mouse.move(...at(270, 470), { steps: 6 });
|
||
await page.mouse.up();
|
||
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
|
||
await expect(page.locator('#mag-review-page-wrap [data-block="b1"]')).not.toHaveClass(/is-selected/);
|
||
await page.locator('#mag-select-bar').getByRole('button', { name: /^Delete/ }).click();
|
||
await expect.poll(() => sent).toEqual({ ids: expect.arrayContaining(['b2', 'b3']), action: 'delete' });
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Deleted 2 boxes.');
|
||
});
|
||
|
||
test('clicking boxes in select mode builds a selection whose type changes at once', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let sent = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/bulk`, route => {
|
||
sent = route.request().postDataJSON();
|
||
return route.fulfill({ json: { ok: true, undo: 'tok-3', remap: {}, count: 2 } });
|
||
});
|
||
await openReview(page);
|
||
const b1Checked = await page.locator('#mag-review input[data-block="b1"]').isChecked();
|
||
await page.getByRole('button', { name: 'Select boxes', exact: true }).click();
|
||
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
|
||
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
|
||
await expect(page.locator('#mag-select-bar')).toContainText('2 selected.');
|
||
// Selecting is not ticking: the include checkboxes stay as they were.
|
||
await expect(page.locator('#mag-review input[data-block="b1"]')).toBeChecked({ checked: b1Checked });
|
||
await page.locator('#mag-bulk-type').selectOption('ocr_noise');
|
||
await expect.poll(() => sent).toEqual({ ids: ['b1', 'b2'], action: 'type', type: 'ocr_noise' });
|
||
});
|
||
|
||
test('an action without its own undo point clears the Undo button', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => route.fulfill({ json: { ok: true, undo: 'tok-9' } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/reading-order`, route => route.fulfill({ json: { ok: true, pages_changed: [], pages_locked: 0 } }));
|
||
await openReview(page);
|
||
await page.locator('[data-block-row="b3"]').getByRole('button', { name: /Delete region/ }).click();
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Region deleted.');
|
||
await page.getByRole('button', { name: 'Fix reading order', exact: true }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Fix reading order', exact: true }).click();
|
||
await expect(page.locator('#mag-undo-bar')).toBeEmpty();
|
||
});
|
||
|
||
test('the live preview draws the page regions over the page image as they arrive', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
// A 100x200 page image in a frame much wider than the page: the boxes must follow the image,
|
||
// not the frame (contain scaling leaves bars left and right).
|
||
await page.evaluate(async () => {
|
||
const preview = document.getElementById('mag-ingest-preview');
|
||
preview.hidden = false;
|
||
preview.style.width = '800px';
|
||
const canvas = document.createElement('canvas'); canvas.width = 100; canvas.height = 200;
|
||
const img = document.getElementById('mag-ingest-preview-img');
|
||
img.style.height = '1400px';
|
||
await new Promise(r => { img.onload = r; img.src = canvas.toDataURL('image/png'); });
|
||
});
|
||
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
|
||
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
|
||
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
|
||
], 'reading'));
|
||
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(2);
|
||
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('5 — reading');
|
||
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('Headline');
|
||
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('2 regions');
|
||
await expect(page.locator('#mag-ingest-preview-legend')).toContainText('marked in the second pass');
|
||
const [imgBox, layerBox] = await Promise.all([
|
||
page.locator('#mag-ingest-preview-img').boundingBox(), page.locator('#mag-ingest-preview-boxes').boundingBox()]);
|
||
// The layer has the page's 1:2 shape, is centred across the wider frame and sits at its top.
|
||
expect(Math.abs(layerBox.height - 2 * layerBox.width)).toBeLessThan(2);
|
||
expect(Math.abs((layerBox.x + layerBox.width / 2) - (imgBox.x + imgBox.width / 2))).toBeLessThan(2);
|
||
expect(Math.abs(layerBox.y - imgBox.y)).toBeLessThan(2);
|
||
// A region that appears on the next poll is marked new; the ones already shown are not.
|
||
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
|
||
{ x: 0, y: 0, w: 100, h: 10, type: 'title', n: 1 },
|
||
{ x: 0, y: 20, w: 50, h: 50, type: 'body', n: 2 },
|
||
{ x: 50, y: 20, w: 50, h: 50, type: 'ad', n: 3 },
|
||
], 'labelling'));
|
||
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box.is-new')).toHaveCount(1);
|
||
await expect(page.locator('#mag-ingest-preview-stage')).toContainText('labelling regions');
|
||
await expect(page.locator('#mag-ingest-preview-legend')).not.toContainText('second pass');
|
||
// Pictures, charts and tables each get their own entry; two kinds of picture share one.
|
||
await page.evaluate(() => window.magRenderPreviewBoxes(5, [
|
||
{ x: 0, y: 0, w: 50, h: 30, type: 'figure', n: 1 }, { x: 50, y: 0, w: 50, h: 30, type: 'image', n: null },
|
||
{ x: 0, y: 40, w: 50, h: 30, type: 'chart', n: 2 }, { x: 50, y: 40, w: 50, h: 30, type: 'table', n: 3 },
|
||
{ x: 0, y: 0, w: 100, h: 100, type: 'image', n: null },
|
||
], 'labelling'));
|
||
await expect(page.locator('#mag-ingest-preview-boxes .mag-preview-box')).toHaveCount(4); // not the page scan
|
||
const legend = page.locator('#mag-ingest-preview-legend .mag-preview-legend-item');
|
||
await expect(legend).toHaveText(['Picture', 'Chart', 'Table']);
|
||
const colour = (sel) => page.locator(sel).evaluate((el) => getComputedStyle(el).borderTopColor);
|
||
const [pic, chart, table] = await Promise.all(['.mpt-figure', '.mpt-chart', '.mpt-table'].map((t) => colour('#mag-ingest-preview-boxes ' + t)));
|
||
expect(new Set([pic, chart, table]).size).toBe(3);
|
||
});
|