ID3 provenance tags (synthetic speech, generator, version, date, opt-in voice name) on magazine MP3/ZIP/EPUB exports via a stream-copied temp copy; verify endpoint and CLI; AudioSeal tier is a documented stub. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
4230 lines
266 KiB
JavaScript
4230 lines
266 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;
|
||
}
|
||
|
||
// The review toolbar is sticky and several rows tall: scroll so the page image starts just below it,
|
||
// as a person would, otherwise a mouse press meant for the image lands on the toolbar.
|
||
async function scrollPageImageBelowToolbar(page, width) {
|
||
await page.locator('#mag-review-page-wrap').evaluate((el, w) => {
|
||
if (w) el.style.width = w + 'px';
|
||
let pane = el.parentElement; // the app scrolls inside a content pane, not the window
|
||
while (pane && !(pane.scrollHeight > pane.clientHeight + 1 && /(auto|scroll)/.test(getComputedStyle(pane).overflowY))) pane = pane.parentElement;
|
||
pane = pane || document.scrollingElement;
|
||
const bar = document.querySelector('#mag-review .mag-review-pagenav');
|
||
// a phone lays the toolbar out in normal flow (not sticky): nothing to leave room for
|
||
const barBottom = bar && getComputedStyle(bar).position === 'sticky' ? bar.getBoundingClientRect().bottom : 0;
|
||
pane.scrollTop += el.getBoundingClientRect().top - barBottom - 8;
|
||
}, width);
|
||
}
|
||
|
||
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 = {}, textAmount = 'context' } = {}) {
|
||
// Driving mode defaults to the current sentence alone; the older specs assert the three-line window, so they start there.
|
||
if (textAmount) await page.addInitScript((amount) => { try { if (!localStorage.getItem('ttsvc_mag_driving_prefs')) localStorage.setItem('ttsvc_mag_driving_prefs', JSON.stringify({ amount })); } catch (_) {} }, textAmount);
|
||
// 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/timings`, route => route.fulfill({
|
||
json: { available: false, quality: 'estimated', sentences: [] },
|
||
}));
|
||
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}/history`, route => route.fulfill({
|
||
json: { can_undo: false, can_redo: false, undo_label: '', redo_label: '', undo_depth: 0, redo_depth: 0 } }));
|
||
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/ }).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/ }).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/ }).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/ }).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('click a flagged word, type your own correction and press Enter; Escape cancels', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
const issue = { token: 'dasich', raw: 'dasich', block_id: 'b3', page: 3, start: 0, end: 6, language: 'de',
|
||
suggestions: [], context: 'dasich mitentwickelt habe und heraus- gebe' };
|
||
let fixed = null;
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling**`, (route) => {
|
||
if (route.request().method() === 'POST') { fixed = route.request().postDataJSON(); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); }
|
||
return route.fulfill({ json: { issues: fixed ? [] : [issue], 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/ }).click();
|
||
await page.getByRole('button', { name: /^Spelling/ }).click();
|
||
const panel = page.locator('#mag-spell-panel');
|
||
|
||
await panel.locator('mark.mag-spell-edit').click(); // Escape first: nothing is sent
|
||
const input = panel.locator('.mag-spell-input');
|
||
await expect(input).toHaveValue('dasich');
|
||
await expect(input).toBeFocused();
|
||
await page.keyboard.press('Escape');
|
||
await expect(panel.locator('.mag-spell-input')).toHaveCount(0);
|
||
expect(fixed).toBeNull();
|
||
|
||
await panel.getByRole('button', { name: 'Edit…' }).click(); // the button opens the same editor
|
||
await panel.locator('.mag-spell-input').fill('das ich');
|
||
await page.keyboard.press('Enter');
|
||
await expect.poll(() => fixed).toMatchObject({ block_id: 'b3', token: 'dasich', start: 0, end: 6, replacement: 'das ich' });
|
||
});
|
||
|
||
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/ }).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: undo and redo', () => {
|
||
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/ }).click();
|
||
await page.waitForSelector('#mag-undo-btn');
|
||
}
|
||
|
||
// A tiny stand-in for the server's history: one change that can be undone, then redone.
|
||
async function mockHistory(page, calls) {
|
||
let state = { can_undo: true, can_redo: false, undo_label: 'Deleted 2 regions', redo_label: '', undo_depth: 1, redo_depth: 0 };
|
||
await page.route(`**/api/magazine/${DOC_ID}/history`, route => route.fulfill({ json: state }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/undo`, (route) => {
|
||
calls.push('undo');
|
||
state = { can_undo: false, can_redo: true, undo_label: '', redo_label: 'Deleted 2 regions', undo_depth: 0, redo_depth: 1 };
|
||
return route.fulfill({ json: { ok: true, label: 'Deleted 2 regions', ...state } });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/redo`, (route) => {
|
||
calls.push('redo');
|
||
state = { can_undo: true, can_redo: false, undo_label: 'Deleted 2 regions', redo_label: '', undo_depth: 1, redo_depth: 0 };
|
||
return route.fulfill({ json: { ok: true, label: 'Deleted 2 regions', ...state } });
|
||
});
|
||
}
|
||
|
||
test('Undo and Redo lead the second row of the sticky bar, on the left, and are disabled with nothing to do', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1400, height: 900 });
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
const contents = await page.locator('#mag-review-contents-btn').boundingBox();
|
||
const undo = await page.locator('#mag-undo-btn').boundingBox();
|
||
const redo = await page.locator('#mag-redo-btn').boundingBox();
|
||
const fixOrder = await page.locator('#mag-reading-order-btn').boundingBox();
|
||
const rowsAbove = await page.evaluate(() => {
|
||
const bar = document.querySelector('#mag-review .mag-review-pagenav');
|
||
const tops = [...new Set([...bar.children].filter((c) => c.id !== 'mag-undo-group' && !c.classList.contains('mag-pagenav-break')).map((c) => Math.round(c.getBoundingClientRect().top / 4)))];
|
||
const undoTop = Math.round(document.getElementById('mag-undo-group').getBoundingClientRect().top / 4);
|
||
return tops.filter((t) => t < undoTop).length; // how many rows lie above the group
|
||
});
|
||
expect(rowsAbove).toBe(1); // exactly one row above: it is the second row
|
||
expect(undo.y).toBeGreaterThan(contents.y + 10);
|
||
expect(Math.abs(undo.x - contents.x)).toBeLessThan(4); // at the left edge of the bar
|
||
expect(redo.x).toBeGreaterThan(undo.x);
|
||
expect(Math.abs(redo.y - undo.y)).toBeLessThan(4);
|
||
expect(fixOrder.y).toBeGreaterThan(contents.y + 10);
|
||
await expect(page.locator('#mag-undo-btn')).toBeDisabled();
|
||
await expect(page.locator('#mag-redo-btn')).toBeDisabled();
|
||
await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', 'Nothing to undo');
|
||
});
|
||
|
||
test('Undo, then Redo, call the server and swap which button is enabled', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const calls = [];
|
||
await mockHistory(page, calls);
|
||
await openReview(page);
|
||
await expect(page.locator('#mag-undo-btn')).toBeEnabled();
|
||
await expect(page.locator('#mag-undo-btn')).toHaveAttribute('title', /Undo: Deleted 2 regions/);
|
||
await page.locator('#mag-undo-btn').click();
|
||
await expect.poll(() => calls).toEqual(['undo']);
|
||
await expect(page.locator('#mag-redo-btn')).toBeEnabled();
|
||
await expect(page.locator('#mag-undo-btn')).toBeDisabled();
|
||
await page.locator('#mag-redo-btn').click();
|
||
await expect.poll(() => calls).toEqual(['undo', 'redo']);
|
||
await expect(page.locator('#mag-undo-btn')).toBeEnabled();
|
||
});
|
||
|
||
test('Ctrl+Z and Ctrl+Shift+Z work in review but leave typing in a text field alone', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const calls = [];
|
||
await mockHistory(page, calls);
|
||
await openReview(page);
|
||
await page.locator('#mag-rev-title').click();
|
||
await page.keyboard.press('Control+z');
|
||
await page.waitForTimeout(300);
|
||
expect(calls).toEqual([]); // native undo of typing, not a magazine undo
|
||
await page.locator('#mag-review .mag-review-page').click({ position: { x: 5, y: 5 } });
|
||
await page.keyboard.press('Control+z');
|
||
await expect.poll(() => calls).toEqual(['undo']);
|
||
await expect(page.locator('#mag-redo-btn')).toBeEnabled(); // the undo has finished reloading
|
||
await page.keyboard.press('Control+Shift+z');
|
||
await expect.poll(() => calls).toEqual(['undo', 'redo']);
|
||
});
|
||
});
|
||
|
||
test('the Contents button is the first item of the sticky review bar and goes back', 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/ }).click();
|
||
const bar = page.locator('#mag-review .mag-review-pagenav');
|
||
await expect(bar.locator('> :first-child')).toHaveId('mag-review-contents-btn');
|
||
await expect(page.locator('#mag-view-review > .mag-bar').getByRole('button', { name: 'Contents' })).toHaveCount(0);
|
||
await bar.getByRole('button', { name: 'Contents' }).click();
|
||
await expect(page.locator('#mag-view-contents')).toBeVisible();
|
||
});
|
||
|
||
test.describe('Magazine: thumbnail as the Review target', () => {
|
||
async function openGrid(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();
|
||
return page.locator('.mag-card-wrap').filter({ hasText: 'Page 3' });
|
||
}
|
||
|
||
test('a page with one article has a single Review target: its thumbnail', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const card = await openGrid(page);
|
||
await expect(card.getByRole('button', { name: /Review/ })).toHaveCount(1);
|
||
await card.getByRole('button', { name: 'Review page 3' }).click();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 3');
|
||
});
|
||
|
||
test('a page shared by two articles keeps one labelled Review button per article', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const second = { ...ARTICLE_DETECTED, id: 'article-002', title: 'A Second Piece', pages: [3], blocks: [] };
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
|
||
json: { articles: [{ ...ARTICLE_DETECTED, has_audio: false }, second], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
const card = await openGrid(page);
|
||
await expect(card.getByRole('button', { name: 'Review page 3' })).toHaveCount(1);
|
||
await expect(card.getByRole('button', { name: 'Review A Second Piece' })).toHaveCount(1);
|
||
await expect(card.getByRole('button', { name: 'Review The Future of Batteries' })).toHaveCount(1);
|
||
});
|
||
});
|
||
|
||
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/ }).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/ }).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/ }).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('the draw panel stays fully inside the window even for a box drawn at the bottom of the page', async ({ page }) => {
|
||
await page.setViewportSize({ width: 1300, height: 700 });
|
||
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();
|
||
await page.getByRole('button', { name: /^Review/ }).click();
|
||
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
|
||
await scrollPageImageBelowToolbar(page, 300);
|
||
// now scroll further so the bottom of the page image is at the bottom edge of the window
|
||
await page.locator('#mag-review-page-wrap').evaluate(el => window.scrollBy(0, el.getBoundingClientRect().bottom - window.innerHeight + 4));
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
const footerTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
|
||
const bottom = Math.min(wrap.y + wrap.height, footerTop) - 6; // lowest point of the image not under the footer
|
||
await page.mouse.move(wrap.x + 40, bottom - 60);
|
||
await page.mouse.down();
|
||
await page.mouse.move(wrap.x + 240, bottom, { steps: 5 });
|
||
await page.mouse.up();
|
||
const panel = page.locator('.mag-draw-panel');
|
||
await expect(panel).toBeVisible();
|
||
const box = await panel.boundingBox();
|
||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||
expect(box.y + box.height).toBeLessThanOrEqual(700);
|
||
expect(box.x + box.width).toBeLessThanOrEqual(1300);
|
||
await expect(page.getByRole('button', { name: 'Add region', exact: true })).toBeInViewport();
|
||
await page.getByRole('button', { name: 'Cancel', exact: true }).click(); // a real click: fails if something covers it
|
||
await expect(page.locator('.mag-draw-panel')).toHaveCount(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/ }).click();
|
||
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
|
||
await scrollPageImageBelowToolbar(page, 400);
|
||
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/ }).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('order by clicking: Save order stays on screen however far the page is scrolled', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
|
||
const save = page.getByRole('button', { name: 'Save order', exact: true });
|
||
await expect(save).toBeVisible();
|
||
await expect(save).toBeDisabled(); // nothing numbered yet
|
||
await page.locator('#mag-review-page-wrap [data-block="b2"]').click();
|
||
await expect(save).toBeEnabled();
|
||
// The app scrolls inside its own container, not the window. Find it, make the review long, and scroll that.
|
||
const scroller = await page.evaluateHandle(() => {
|
||
let el = document.getElementById('mag-review');
|
||
while (el && el !== document.documentElement) {
|
||
const oy = getComputedStyle(el).overflowY;
|
||
if ((oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight - 1) return el;
|
||
el = el.parentElement;
|
||
}
|
||
return document.scrollingElement;
|
||
});
|
||
await scroller.evaluate((el) => { const f = document.createElement('div'); f.style.height = '6000px'; f.id = 'test-filler'; (el === document.scrollingElement ? document.body : el).appendChild(f); });
|
||
const scrollBy = async (top) => scroller.evaluate((el, y) => { el.scrollTo(0, y); return el.scrollTop; }, top);
|
||
expect(await scrollBy(4000)).toBeGreaterThan(1000); // proves the container really scrolled
|
||
for (const y of [0, 1500, 4000, 6500]) {
|
||
await scrollBy(y);
|
||
await expect(save).toBeInViewport({ ratio: 1 });
|
||
}
|
||
// It floats in a corner: a proper touch target, inside the viewport, clear of the toolbar.
|
||
const box = await save.boundingBox();
|
||
const vp = page.viewportSize();
|
||
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
|
||
expect(box.height).toBeGreaterThanOrEqual(44);
|
||
await scrollBy(0);
|
||
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toBeInViewport();
|
||
});
|
||
|
||
test('order by clicking: progress, a ready state when every box is numbered, and Esc to cancel', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
|
||
const bar = page.locator('#mag-order-bar');
|
||
await expect(bar).toHaveClass(/is-floating/);
|
||
await expect(bar.locator('progress')).toHaveAttribute('value', '0');
|
||
for (const id of ['b1', 'b2', 'b3']) await page.locator(`#mag-review-page-wrap [data-block="${id}"]`).click();
|
||
await expect(bar.locator('progress')).toHaveAttribute('value', '3');
|
||
await expect(bar).toContainText('Every box is numbered');
|
||
await expect(page.getByRole('button', { name: 'Save order', exact: true })).toHaveClass(/is-ready/);
|
||
await page.keyboard.press('Escape');
|
||
await expect(bar).not.toHaveClass(/is-floating/);
|
||
await expect(bar).toBeEmpty();
|
||
await expect(page.getByRole('button', { name: 'Order by clicking', exact: true })).toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
test('order by clicking: Esc inside a position field does not cancel the numbering', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await openReview(page);
|
||
await page.getByRole('button', { name: 'Order by clicking', exact: true }).click();
|
||
await page.locator('#mag-review-page-wrap [data-block="b1"]').click();
|
||
await page.getByRole('spinbutton', { name: /Reading position of region 2/ }).focus();
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.locator('#mag-order-bar')).toHaveClass(/is-floating/);
|
||
});
|
||
|
||
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 scrollPageImageBelowToolbar(page, 400);
|
||
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);
|
||
});
|
||
|
||
test('the contents list shows synthesis state with the MP3 length and plays inline', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: 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();
|
||
const row = page.locator('.mag-toc-row').first();
|
||
await expect(row).toContainText('Synthesised · 0:02');
|
||
const play = row.getByRole('button', { name: /^Play / });
|
||
await expect(play).toHaveAttribute('aria-pressed', 'false');
|
||
await play.click();
|
||
await expect(row.getByRole('button', { name: /^Pause / })).toHaveAttribute('aria-pressed', 'true');
|
||
});
|
||
|
||
test('the contents list marks an approved article without audio as not synthesised', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
|
||
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();
|
||
const row = page.locator('.mag-toc-row').first();
|
||
await expect(row).toContainText('Not synthesised');
|
||
await expect(row.getByRole('button', { name: /^Play / })).toHaveCount(0);
|
||
});
|
||
|
||
test('finding an issue online: search, licence shown, import starts ingestion', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const imported = [];
|
||
await page.route('**/api/magazine-sources', route => route.fulfill({
|
||
json: { sources: [{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' }] } }));
|
||
await page.route('**/api/magazine-sources/internet-archive/search*', route => route.fulfill({
|
||
json: { source: 'internet-archive', items: [
|
||
{ id: 'sci-am-1923', title: 'Scientific American 1923', author: 'Munn', date: '1923-01-01', summary: 'Vol 128',
|
||
license: 'https://creativecommons.org/publicdomain/mark/1.0/', page_url: 'https://archive.org/details/sci-am-1923' },
|
||
{ id: 'other', title: '<img src=x onerror=alert(1)>', license: '' }] } }));
|
||
await page.route('**/api/magazine-sources/import', route => {
|
||
imported.push(route.request().postDataJSON());
|
||
route.fulfill({ json: { id: DOC_ID, job_id: 'job1' } });
|
||
});
|
||
await page.route('**/api/jobs/job1', route => route.fulfill({ json: { done: true, error: null, detail: 'Downloaded' } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
|
||
await page.locator('#mag-find-query').fill('scientific american');
|
||
await page.getByRole('button', { name: 'Search', exact: true }).click();
|
||
await expect(page.locator('#mag-find-status')).toContainText('2 results');
|
||
await expect(page.locator('.mag-find-item').first()).toContainText('publicdomain');
|
||
await expect(page.locator('.mag-find-item').nth(1)).toContainText('Licence not stated');
|
||
// Titles from a remote source are text, never markup.
|
||
await expect(page.locator('.mag-find-item img')).toHaveCount(0);
|
||
await page.getByRole('button', { name: 'Import Scientific American 1923' }).click();
|
||
await expect.poll(() => imported.length).toBe(1);
|
||
expect(imported[0]).toMatchObject({ source: 'internet-archive', item_id: 'sci-am-1923' });
|
||
});
|
||
|
||
const TINY_PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
|
||
|
||
async function openMagazineLibrary(page) {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await expect(page.locator('#mag-new-file')).toBeAttached();
|
||
}
|
||
|
||
test('several page images are uploaded together, in filename order', async ({ page }) => {
|
||
await openMagazineLibrary(page);
|
||
const posted = [];
|
||
await page.route('**/api/magazine/docs', route => route.fulfill({ json: { id: DOC_ID } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/source-images`, route => {
|
||
posted.push(route.request().postDataBuffer().toString('latin1'));
|
||
route.fulfill({ json: { ok: true, pages: 3 } });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/ingest`, route => route.fulfill({ json: { job_id: 'ing1' } }));
|
||
await page.locator('#mag-new-file').setInputFiles([
|
||
{ name: 'page10.png', mimeType: 'image/png', buffer: TINY_PNG },
|
||
{ name: 'page2.png', mimeType: 'image/png', buffer: TINY_PNG },
|
||
{ name: 'page1.png', mimeType: 'image/png', buffer: TINY_PNG },
|
||
]);
|
||
await expect.poll(() => posted.length).toBe(1);
|
||
const names = [...posted[0].matchAll(/filename="([^"]+)"/g)].map(m => m[1]);
|
||
expect(names).toEqual(['page1.png', 'page2.png', 'page10.png']);
|
||
});
|
||
|
||
test('mixing a PDF with images, or choosing another type, is refused before anything is created', async ({ page }) => {
|
||
await openMagazineLibrary(page);
|
||
let created = 0;
|
||
await page.route('**/api/magazine/docs', route => { created++; route.fulfill({ json: { id: DOC_ID } }); });
|
||
await page.locator('#mag-new-file').setInputFiles([
|
||
{ name: 'a.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4') },
|
||
{ name: 'b.png', mimeType: 'image/png', buffer: TINY_PNG },
|
||
]);
|
||
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: /PDF|images/ }).first()).toBeVisible();
|
||
await page.locator('#mag-new-file').setInputFiles([{ name: 'c.gif', mimeType: 'image/gif', buffer: Buffer.from('GIF89a') }]);
|
||
await page.waitForTimeout(300);
|
||
expect(created).toBe(0);
|
||
});
|
||
|
||
test.describe('Edited text in review', () => {
|
||
async function openReviewWithEditedBlock(page) {
|
||
await mockBackend(page);
|
||
const edited = { ...LAYOUT, blocks: LAYOUT.blocks.map(b => b.id === 'b3'
|
||
? { ...b, ocr_text: 'Battery technolgy is entering a new phase. It will take years to mature.' } : b) };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({ json: edited }));
|
||
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/ }).click();
|
||
await expect(page.locator('[data-block-row="b3"]')).toBeVisible();
|
||
}
|
||
|
||
test('a hand-edited region is marked and shows a word diff against the recognised text', async ({ page }) => {
|
||
await openReviewWithEditedBlock(page);
|
||
const row = page.locator('[data-block-row="b3"]');
|
||
await expect(row).toContainText('Edited');
|
||
await expect(page.locator('[data-block-row="b1"]')).not.toContainText('Edited');
|
||
const toggle = row.locator('.mag-edited button').first();
|
||
await expect(toggle).toHaveText('Show changes');
|
||
await toggle.click();
|
||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||
await expect(row.locator('.mag-diff del')).toHaveText('technolgy');
|
||
await expect(row.locator('.mag-diff ins')).toHaveText('technology');
|
||
await row.getByRole('button', { name: 'Hide changes' }).click();
|
||
await expect(row.locator('.mag-diff')).toHaveCount(0);
|
||
});
|
||
|
||
test('restoring puts the recognised text back through the region route', async ({ page }) => {
|
||
await openReviewWithEditedBlock(page);
|
||
const patches = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => {
|
||
patches.push(route.request().postDataJSON());
|
||
route.fulfill({ json: { ok: true } });
|
||
});
|
||
await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Restore recognised text' }).click();
|
||
await expect.poll(() => patches.length).toBe(1);
|
||
expect(patches[0]).toEqual({ text: 'Battery technolgy is entering a new phase. It will take years to mature.' });
|
||
});
|
||
|
||
test('the word diff handles insertions, deletions, escaping and oversize input', 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');
|
||
const r = await page.evaluate(() => ({
|
||
same: window.magWordDiff('a b c', 'a b c'),
|
||
ins: window.magWordDiff('a c', 'a b c'),
|
||
del: window.magWordDiff('a b c', 'a c'),
|
||
html: window.magWordDiff('x <b>y</b>', 'x y'),
|
||
big: window.magWordDiff('w '.repeat(2000), 'v '.repeat(2000)).startsWith('<del>'),
|
||
}));
|
||
expect(r.same).toBe('a b c');
|
||
expect(r.ins).toBe('a <ins>b</ins> c');
|
||
expect(r.del).toBe('a <del>b</del> c');
|
||
expect(r.html).not.toContain('<b>');
|
||
expect(r.big).toBe(true);
|
||
});
|
||
});
|
||
|
||
test('sidebars appear in the reading view in place, labelled, and are not sentences', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({
|
||
json: { article: ARTICLE_APPROVED, blocks: LAYOUT.blocks, asides: [
|
||
{ id: 's1', page: 3, text: 'Box: <b>Did you know?</b> Lithium is light.', after: 'b2' },
|
||
{ id: 's2', page: 3, text: 'Glossary of terms', after: 'b3' },
|
||
{ id: 's0', page: 3, text: 'Before everything', after: null },
|
||
] } }));
|
||
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-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect(page.locator('#mag-view-read')).toBeVisible();
|
||
|
||
await expect(page.locator('#mag-article [data-i]')).toHaveCount(4); // asides add no sentences
|
||
const asides = page.locator('#mag-article aside.mag-aside');
|
||
await expect(asides).toHaveCount(3);
|
||
await expect(asides.first()).toContainText('Before everything');
|
||
await expect(asides.first()).toHaveAttribute('aria-label', 'Sidebar, not read aloud');
|
||
await expect(asides.nth(1)).toContainText('Did you know?');
|
||
await expect(page.locator('#mag-article aside b')).toHaveCount(0); // text, never markup
|
||
// Order in the DOM: before-all aside, title/byline sentences, aside after the byline, body sentences, aside last.
|
||
const order = await page.locator('#mag-article').evaluate(el => Array.from(el.children).map(c => c.matches('aside') ? 'A' : 'S').join(''));
|
||
expect(order).toBe('ASSASSA');
|
||
await asides.nth(1).click();
|
||
await expect(page.locator('#mag-article [data-i].is-current')).toHaveCount(1);
|
||
});
|
||
|
||
test('the EPUB link appears only when an approved article has audio', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: 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();
|
||
const link = page.getByRole('link', { name: /EPUB with audio/ });
|
||
await expect(link).toBeVisible();
|
||
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub?provenance=1`);
|
||
// Provenance tags: on by default, voice opt-in, choice is carried by the export links and saved as the default.
|
||
await expect(page.locator('#mag-prov-tags')).toBeChecked();
|
||
await page.locator('#mag-prov-voice').evaluate(el => el.click());
|
||
await expect(link).toHaveAttribute('href', `/api/magazine/${DOC_ID}/epub?provenance=1&voice=1`);
|
||
await page.locator('#mag-prov-tags').evaluate(el => el.click());
|
||
await expect(page.locator('#mag-audio-zip-link')).toHaveAttribute('href', `/api/magazine/${DOC_ID}/audio.zip?provenance=0&voice=1`);
|
||
});
|
||
|
||
test('no EPUB link for an approved article without audio', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: false });
|
||
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.waitForSelector('.mag-toc-row');
|
||
await expect(page.locator('#mag-epub-link')).toBeHidden();
|
||
});
|
||
|
||
test.describe('Hands-free listening', () => {
|
||
async function startPlayback(page) {
|
||
await page.addInitScript(() => {
|
||
window.__ms = { actions: {}, positions: 0 };
|
||
const ms = navigator.mediaSession;
|
||
const set = ms.setActionHandler.bind(ms);
|
||
ms.setActionHandler = (name, fn) => { window.__ms.actions[name] = fn; return set(name, fn); };
|
||
const pos = ms.setPositionState.bind(ms);
|
||
ms.setPositionState = (s) => { window.__ms.positions++; return pos(s); };
|
||
window.__wl = { requested: 0, released: 0 };
|
||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: {
|
||
request: async () => { window.__wl.requested++; return { addEventListener() {}, release: async () => { window.__wl.released++; } }; } } });
|
||
});
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: 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.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect.poll(() => page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); return !!a && !a.paused; })).toBe(true);
|
||
}
|
||
|
||
test('lock screen shows the article and offers play, pause, next, previous and seek', async ({ page }) => {
|
||
await startPlayback(page);
|
||
const meta = await page.evaluate(() => ({ title: navigator.mediaSession.metadata?.title, album: navigator.mediaSession.metadata?.album,
|
||
state: navigator.mediaSession.playbackState, actions: Object.keys(window.__ms.actions).sort() }));
|
||
expect(meta.title).toBe('The Future of Batteries');
|
||
expect(meta.album).toContain('Scientific American');
|
||
expect(meta.state).toBe('playing');
|
||
expect(meta.actions).toEqual(['nexttrack', 'pause', 'play', 'previoustrack', 'seekbackward', 'seekforward', 'seekto', 'stop']);
|
||
await expect.poll(() => page.evaluate(() => window.__ms.positions)).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('pause and play from the lock screen control the real player', async ({ page }) => {
|
||
await startPlayback(page);
|
||
await page.evaluate(() => window.__ms.actions.pause());
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||
await expect.poll(() => page.evaluate(() => navigator.mediaSession.playbackState)).toBe('paused');
|
||
await page.evaluate(() => window.__ms.actions.play());
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').paused)).toBe(false);
|
||
});
|
||
|
||
test('seekto and seekforward move the playhead', async ({ page }) => {
|
||
await startPlayback(page);
|
||
await page.evaluate(() => window.__ms.actions.seekto({ seekTime: 1.2 }));
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThan(1);
|
||
});
|
||
|
||
test('driving-mode text follows the recorded sentence times and gives captions no slot', async ({ page }) => {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||
const blocks = [
|
||
{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'title', text: 'Heading Here', reading_order: 0 },
|
||
{ id: 'b3', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'First one. Second one.', reading_order: 1 },
|
||
{ id: 'c1', page: 3, bbox: [0, 0, 1, 1], type: 'caption', text: 'A caption here.', reading_order: 2 },
|
||
{ id: 'b4', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'Last one.', reading_order: 3 },
|
||
];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
|
||
// Recorded times are far from what text length would suggest (the estimate puts 0.25 s in the heading).
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact', sentences: [
|
||
{ text: 'Heading Here', block: 0, start: 0, end: 0.2 }, { text: 'First one.', block: 1, start: 0.2, end: 0.3 },
|
||
{ text: 'Second one.', block: 1, start: 0.3, end: 1.9 }, { text: 'Last one.', block: 2, start: 1.9, end: 2.0 }] } }));
|
||
// Seeking needs a server that answers Range requests, like the real audio route does.
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
|
||
const wav = silentWav(2000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
|
||
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.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.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(5);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line')).toHaveCount(5); // all built, three shown
|
||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line:not([hidden])')).toHaveCount(3);
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
|
||
const at = async (t) => {
|
||
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeCloseTo(t, 1);
|
||
await page.evaluate(() => window.magPlaylistSync());
|
||
return page.evaluate(() => ({
|
||
driving: document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i,
|
||
list: document.querySelector('#mag-playlist-transcript .is-current')?.dataset.playlistSentence,
|
||
}));
|
||
};
|
||
// sentence index 3 is the caption: it never gets the highlight, and "Last one." is index 4
|
||
expect(await at(0.25)).toEqual({ driving: '1', list: '1' });
|
||
expect(await at(1.0)).toEqual({ driving: '2', list: '2' });
|
||
expect(await at(1.95)).toEqual({ driving: '4', list: '4' });
|
||
expect(await at(0.05)).toEqual({ driving: '0', list: '0' }); // seeking back works the same way
|
||
});
|
||
|
||
test('the screen is kept awake only while driving mode is open and playing', async ({ page }) => {
|
||
await startPlayback(page);
|
||
expect(await page.evaluate(() => window.__wl.requested)).toBe(0); // reading view: no lock
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(1);
|
||
await page.evaluate(() => window.__ms.actions.pause()); // paused: let the screen sleep
|
||
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(1);
|
||
await page.evaluate(() => window.__ms.actions.play());
|
||
await expect.poll(() => page.evaluate(() => window.__wl.requested)).toBe(2);
|
||
await page.evaluate(() => window.magToggleDriving()); // leaving driving mode releases it
|
||
await expect.poll(() => page.evaluate(() => window.__wl.released)).toBe(2);
|
||
});
|
||
|
||
test('a refused wake lock never stops playback', async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request: async () => { throw new Error('denied'); } } });
|
||
});
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: 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.locator('.mag-toc-row.is-approved').getByRole('button', { name: /Listen|Continue/ }).click();
|
||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||
});
|
||
});
|
||
|
||
test.describe('Share link and deep link into driving mode', () => {
|
||
const LINK = `/#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`;
|
||
|
||
async function openLink(page, url = LINK) {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
}
|
||
|
||
test('opening a shared link goes straight to driving mode for that article and plays', async ({ page }) => {
|
||
await openLink(page);
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-title')).toHaveText('The Future of Batteries');
|
||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line').first()).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||
await expect(page.locator('#mag-driving-start')).toBeHidden();
|
||
});
|
||
|
||
test('a blocked autoplay shows a big Play prompt that starts the audio', async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
const real = HTMLMediaElement.prototype.play;
|
||
window.__blockPlay = true;
|
||
HTMLMediaElement.prototype.play = function () {
|
||
return window.__blockPlay ? Promise.reject(new DOMException('blocked', 'NotAllowedError')) : real.call(this);
|
||
};
|
||
});
|
||
await openLink(page);
|
||
const prompt = page.locator('#mag-driving-start');
|
||
await expect(prompt).toBeVisible();
|
||
const box = await prompt.boundingBox();
|
||
expect(box.height).toBeGreaterThanOrEqual(64);
|
||
await page.evaluate(() => { window.__blockPlay = false; });
|
||
await prompt.click();
|
||
await expect(prompt).toBeHidden();
|
||
await expect.poll(() => page.evaluate(() => !document.getElementById('mag-playlist-audio').paused)).toBe(true);
|
||
});
|
||
|
||
test('a malformed or hostile link is ignored: no driving mode, nothing injected', async ({ page }) => {
|
||
const bad = `/#s-magazine?doc=${DOC_ID}&article=%22%3E%3Cimg%20src%3Dx%20onerror%3Dwindow.__xss%3D1%3E&mode=drive`;
|
||
await openLink(page, bad);
|
||
await navTo(page, 's-magazine');
|
||
await expect(page.locator('#mag-driving')).toBeHidden();
|
||
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
|
||
const { parse } = await page.evaluate(() => ({ parse: [
|
||
window.magParseDeepLink('#s-magazine?doc=../etc&mode=drive'),
|
||
window.magParseDeepLink('#s-magazine?doc=abc&article=a/b&mode=drive'),
|
||
window.magParseDeepLink('#s-magazine?doc=abc'),
|
||
window.magParseDeepLink('#s-magazine?doc=abc&article=article-001&mode=drive'),
|
||
] }));
|
||
expect(parse).toEqual([null, null, null, { doc: 'abc', article: 'article-001' }]);
|
||
});
|
||
|
||
test('Share uses the system share sheet with a deep link, at 375 px wide', async ({ page }) => {
|
||
await page.setViewportSize({ width: 375, height: 700 });
|
||
await page.addInitScript(() => { navigator.share = (data) => { window.__shared = data; return Promise.resolve(); }; });
|
||
await openLink(page, '/');
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').click();
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
const share = page.locator('#mag-driving .mag-driving-share');
|
||
await expect(share).toHaveAttribute('aria-label', /Share/);
|
||
const box = await share.boundingBox();
|
||
expect(box.width).toBeGreaterThanOrEqual(44);
|
||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||
expect(box.x + box.width).toBeLessThanOrEqual(375);
|
||
await share.focus();
|
||
await page.keyboard.press('Enter');
|
||
const shared = await page.evaluate(() => window.__shared);
|
||
const url = new URL(shared.url);
|
||
expect(url.hash).toBe(`#s-magazine?doc=${DOC_ID}&article=article-001&mode=drive`);
|
||
expect(url.search).toBe(''); // no tokens or keys anywhere in the link
|
||
});
|
||
|
||
test('Share without the share sheet copies the link and says so (aria-live toast)', async ({ page }) => {
|
||
await page.addInitScript(() => {
|
||
Object.defineProperty(navigator, 'share', { value: undefined, configurable: true });
|
||
Object.defineProperty(navigator, 'clipboard', { value: { writeText: (t) => { window.__copied = t; return Promise.resolve(); } }, configurable: true });
|
||
});
|
||
await openLink(page, '/');
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-library-list .mag-card').first().click();
|
||
await page.locator('#mag-share-book').click();
|
||
await expect(page.locator('#toast')).toContainText('Link copied');
|
||
await expect(page.locator('#toast')).toHaveAttribute('aria-live', 'polite');
|
||
expect(await page.evaluate(() => window.__copied)).toBe(`${new URL(page.url()).origin}/#s-magazine?doc=${DOC_ID}&mode=drive`);
|
||
});
|
||
});
|
||
|
||
test.describe('Page cards say what to do', () => {
|
||
const guide = (page, args) => page.evaluate((a) => window.magPageGuide(a), args);
|
||
|
||
async function openGrid(page, articles, quality) {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({
|
||
json: { articles, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
if (quality) await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { pages: quality, summary: {} } }));
|
||
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.locator('#mag-contents-view-toggle').click();
|
||
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
|
||
}
|
||
|
||
const art = (id, title, pages, status = 'detected', extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
|
||
|
||
test('the guide names the state and the next step for every kind of page', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
const a = (status, has_audio = false) => ({ id: 'x', title: 'T', pages: [3], status, has_audio });
|
||
const flagged = await guide(page, { page: 3, articles: [a('detected')], quality: { needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [['a', 'b'], ['c', 'd']], clipped: [], suspect_blocks: ['s'], duplicates: [] }, stateName: 'flagged', hasText: true });
|
||
expect(flagged.needs).toBe(true);
|
||
expect(flagged.label).toBe('Problems found');
|
||
expect(flagged.todo[0]).toMatch(/^1 text area lies outside every box: use "Find missed text"/);
|
||
expect(flagged.todo).toContain('2 pairs of boxes overlap: merge, shrink or delete one');
|
||
expect(flagged.todo.at(-1)).toBe('Then approve the page');
|
||
const detected = await guide(page, { page: 3, articles: [a('detected')], quality: null, stateName: 'detected', hasText: true });
|
||
expect(detected.todo[0]).toMatch(/approve it/);
|
||
const done = await guide(page, { page: 3, articles: [a('approved', true)], quality: null, stateName: 'audio', hasText: true });
|
||
expect([done.needs, done.label]).toEqual([false, 'Done: audio is ready']);
|
||
const noAudio = await guide(page, { page: 3, articles: [a('approved')], quality: null, stateName: 'approved', hasText: true });
|
||
expect(noAudio.todo[0]).toMatch(/Generate the audio/);
|
||
const ad = await guide(page, { page: 3, articles: [], quality: null, stateName: 'ad', hasText: false });
|
||
expect([ad.needs, ad.label]).toEqual([false, 'Advertisement: not read']);
|
||
const orphan = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: true });
|
||
expect(orphan.needs).toBe(true);
|
||
expect(orphan.todo[0]).toMatch(/belongs to no article/);
|
||
const empty = await guide(page, { page: 3, articles: [], quality: null, stateName: 'blank', hasText: false });
|
||
expect([empty.needs, empty.label]).toEqual([false, 'No article on this page']);
|
||
const shared = await guide(page, { page: 3, articles: [a('detected'), a('approved')], quality: null, stateName: 'detected', hasText: true });
|
||
expect(shared.todo.join(' ')).toMatch(/1 of the 2 articles here still wait/);
|
||
const hidden = await guide(page, { page: 3, articles: [a('detected'), a('approved', true)], quality: null, stateName: 'audio', hasText: true });
|
||
expect([hidden.label, hidden.needs]).toEqual(['Partly done: some audio is ready', true]); // colour says audio, one article still waits
|
||
expect(hidden.todo.join(' ')).toMatch(/1 of the 2 articles here still wait for approval/);
|
||
const noted = await guide(page, { page: 3, articles: [a('approved', true)], quality: { needs_review: true, uncovered: [], overlaps: [['a', 'b']], clipped: [], suspect_blocks: [], duplicates: [] }, stateName: 'audio', hasText: true });
|
||
expect(noted.needs).toBe(false);
|
||
expect(noted.note).toMatch(/You approved this page, but the quality check had found/);
|
||
});
|
||
|
||
test('how an article flows between pages is computed from its page list', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
const r = await page.evaluate(() => ({
|
||
first: window.magArticleFlow({ pages: [10, 11, 25] }, 10), mid: window.magArticleFlow({ pages: [10, 11, 25] }, 11),
|
||
last: window.magArticleFlow({ pages: [25, 10, 11] }, 25), single: window.magArticleFlow({ pages: [4] }, 4), absent: window.magArticleFlow({ pages: [4] }, 9) }));
|
||
expect(r.first).toEqual({ from: null, to: 11 });
|
||
expect(r.mid).toEqual({ from: 10, to: 25 });
|
||
expect(r.last).toEqual({ from: 11, to: null });
|
||
expect(r.single).toEqual({ from: null, to: null });
|
||
expect(r.absent).toEqual({ from: null, to: null });
|
||
});
|
||
|
||
test('every card shows its status, article count, page flow and to-do in words', async ({ page }) => {
|
||
await openGrid(page, [
|
||
art('a1', 'Long feature', [3, 4], 'detected'),
|
||
art('a2', 'Short piece', [4], 'approved', { has_audio: true }),
|
||
], [{ page: 3, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }]);
|
||
const p3 = page.locator('.mag-card-wrap[data-page="3"]');
|
||
await expect(p3.locator('.mag-card-count')).toHaveText('1 article');
|
||
await expect(p3.locator('.mag-page-status')).toContainText('Problems found');
|
||
await expect(p3.locator('.mag-flow')).toHaveText(/continues on p\.4/);
|
||
await expect(p3.locator('.mag-page-todo')).toContainText('Find missed text');
|
||
const p4 = page.locator('.mag-card-wrap[data-page="4"]');
|
||
await expect(p4.locator('.mag-card-count')).toHaveText('2 articles');
|
||
await expect(p4.locator('.mag-flow').first()).toHaveText(/continues from p\.3/);
|
||
await expect(p4.locator('.mag-page-todo')).toContainText('1 of the 2 articles here still wait for approval');
|
||
const p6 = page.locator('.mag-card-wrap[data-page="6"]');
|
||
await expect(p6.locator('.mag-card-count')).toHaveText('no article');
|
||
await expect(p6.locator('.mag-page-status')).toContainText('No article on this page');
|
||
});
|
||
|
||
test('the summary counts pages that need you and the filter hides the rest', async ({ page }) => {
|
||
await openGrid(page, [art('a1', 'Done piece', [3], 'approved', { has_audio: true }), art('a2', 'Open piece', [4], 'detected')], null);
|
||
const summary = page.locator('.mag-pages-summary');
|
||
await expect(summary).toContainText(/pages need you/);
|
||
const total = await page.locator('.mag-pages-grid .mag-card-wrap').count();
|
||
await page.getByLabel('Show only pages that need me').check();
|
||
const shown = await page.locator('.mag-pages-grid .mag-card-wrap').count();
|
||
expect(shown).toBeLessThan(total);
|
||
await expect(page.locator('.mag-card-wrap[data-page="4"]')).toBeVisible();
|
||
await expect(page.locator('.mag-card-wrap[data-page="3"]')).toHaveCount(0);
|
||
await page.getByLabel('Show only pages that need me').uncheck();
|
||
await expect(page.locator('.mag-pages-grid .mag-card-wrap')).toHaveCount(total);
|
||
});
|
||
|
||
test('the legend is large enough to read', async ({ page }) => {
|
||
await openGrid(page, [art('a1', 'X', [3])], null);
|
||
const sizes = await page.evaluate(() => ({
|
||
font: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-item')).fontSize),
|
||
swatch: parseFloat(getComputedStyle(document.querySelector('.mag-pages-help .mag-pages-legend-swatch')).width) }));
|
||
expect(sizes.font).toBeGreaterThanOrEqual(14);
|
||
expect(sizes.swatch).toBeGreaterThanOrEqual(18);
|
||
});
|
||
});
|
||
|
||
test('catalogues can be added and removed, and the source list follows', async ({ page }) => {
|
||
await mockBackend(page);
|
||
let cats = [];
|
||
const puts = [];
|
||
await page.route('**/api/magazine-sources/catalogues', route => {
|
||
if (route.request().method() === 'PUT') {
|
||
cats = route.request().postDataJSON().catalogues.map(c => ({ ...c, password_set: !!c.password_env }));
|
||
puts.push(route.request().postDataJSON());
|
||
return route.fulfill({ json: { ok: true, count: cats.length } });
|
||
}
|
||
return route.fulfill({ json: { catalogues: cats } });
|
||
});
|
||
await page.route('**/api/magazine-sources', route => route.fulfill({ json: { sources: [
|
||
{ key: 'internet-archive', label: 'Internet Archive', kind: 'archive' },
|
||
...cats.map((c, i) => ({ key: `opds-${i + 1}`, label: c.name, kind: 'opds' }))] } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-cat summary').click();
|
||
await expect(page.locator('#mag-cat-list')).toContainText('None yet');
|
||
await page.locator('#mag-cat-name').fill('My NAS');
|
||
await page.locator('#mag-cat-url').fill('http://nas.local:8083/opds/');
|
||
await page.locator('#mag-cat-user').fill('me');
|
||
await page.locator('#mag-cat-env').fill('NAS_PW');
|
||
await page.getByRole('button', { name: /Add catalogue/ }).click();
|
||
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue added.');
|
||
await expect(page.locator('#mag-cat-list')).toContainText('My NAS');
|
||
// The form never carries a password, only the name of the variable that holds it.
|
||
expect(JSON.stringify(puts[0])).not.toMatch(/"password"/);
|
||
expect(puts[0].catalogues[0]).toEqual({ name: 'My NAS', url: 'http://nas.local:8083/opds/', username: 'me', password_env: 'NAS_PW' });
|
||
await expect(page.locator('#mag-find-source option')).toHaveText(['Internet Archive', 'My NAS']);
|
||
await page.getByRole('button', { name: 'Remove My NAS' }).click();
|
||
await expect(page.locator('#mag-cat-status')).toHaveText('Catalogue removed.');
|
||
await expect(page.locator('#mag-find-source option')).toHaveCount(1);
|
||
});
|
||
|
||
test('a refused catalogue shows the server reason and keeps the form', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.route('**/api/magazine-sources/catalogues', route => route.request().method() === 'PUT'
|
||
? route.fulfill({ status: 400, json: { detail: 'Put the login in the username field, not in the address' } })
|
||
: route.fulfill({ json: { catalogues: [] } }));
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
await page.locator('#mag-cat summary').click();
|
||
await page.locator('#mag-cat-url').fill('http://me:pw@nas.local/opds');
|
||
await page.getByRole('button', { name: /Add catalogue/ }).click();
|
||
await expect(page.locator('#mag-cat-status')).toContainText('Put the login in the username field');
|
||
await expect(page.locator('#mag-cat-url')).toHaveValue('http://me:pw@nas.local/opds');
|
||
});
|
||
|
||
test.describe('Correcting flagged words in the review list', () => {
|
||
const ISSUES = [
|
||
{ token: 'technology', raw: 'technology', block_id: 'b3', page: 3, start: 8, end: 18, language: 'en', suggestions: ['technologie', 'technologies'], context: 'Battery technology is entering' },
|
||
{ token: 'mature', raw: 'mature', block_id: 'b3', page: 3, start: 67, end: 73, language: 'en', suggestions: [], context: 'years to mature.' },
|
||
];
|
||
|
||
async function open(page) {
|
||
await mockBackend(page);
|
||
const posts = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling/dictionary`, route => { posts.push({ keep: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true } }); });
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling/fix`, route => { posts.push({ fix: route.request().postDataJSON() }); return route.fulfill({ json: { ok: true, undo: 'tok', text: 'x' } }); });
|
||
await page.route(`**/api/magazine/${DOC_ID}/spelling?**`, route => route.fulfill({ json: { issues: posts.some(p => p.fix || p.keep) ? [] : ISSUES, 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/ }).click();
|
||
await page.waitForSelector('#mag-undo-btn');
|
||
await expect(page.locator('[data-block-row="b3"] mark.mag-spell')).toHaveCount(2);
|
||
return posts;
|
||
}
|
||
const fontOf = (page, sel) => page.locator(sel).first().evaluate(el => { const c = getComputedStyle(el); return `${c.fontSize}/${c.lineHeight}/${c.fontFamily}`; });
|
||
|
||
test('clicking a flagged word edits only that word, in the paragraph\'s own type, and leaves the other marks', async ({ page }) => {
|
||
await open(page);
|
||
const before = await fontOf(page, '[data-block-row="b3"] .mag-block-text');
|
||
const box = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
|
||
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
|
||
const input = page.locator('[data-block-row="b3"] .mag-word-input');
|
||
await expect(input).toBeFocused();
|
||
await expect(input).toHaveValue('technology');
|
||
await expect(page.locator('[data-block-row="b3"] .mag-row-editor')).toHaveCount(0); // not the whole-text editor
|
||
await expect(page.locator('[data-block-row="b3"] mark.mag-spell:not(.is-editing)')).toHaveCount(1); // the other word stays underlined
|
||
expect(await fontOf(page, '[data-block-row="b3"] .mag-block-text')).toBe(before); // nothing restyled
|
||
expect(await fontOf(page, '[data-block-row="b3"] .mag-word-input')).toBe(before);
|
||
const after = await page.locator('[data-block-row="b3"] .mag-block-text').boundingBox();
|
||
expect(Math.abs(after.width - box.width)).toBeLessThan(2); // the column did not change width
|
||
await expect(page.locator('[data-block-row="b3"] input[type="checkbox"]')).toBeChecked(); // the click did not untick the region
|
||
});
|
||
|
||
test('a suggestion chip corrects the word with one click', async ({ page }) => {
|
||
const posts = await open(page);
|
||
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
|
||
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'technologie', exact: true }).click();
|
||
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ block_id: 'b3', start: 8, end: 18, token: 'technology', replacement: 'technologie' });
|
||
});
|
||
|
||
test('typing a correction and pressing Enter applies it; Escape puts the word back and sends nothing', async ({ page }) => {
|
||
const posts = await open(page);
|
||
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
|
||
await mark.click();
|
||
const input = page.locator('[data-block-row="b3"] .mag-word-input');
|
||
await input.fill('Technologie');
|
||
await input.press('Escape');
|
||
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
|
||
await expect(page.locator('[data-block-row="b3"] mark.mag-spell').first()).toHaveText('technology');
|
||
expect(posts).toEqual([]);
|
||
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
|
||
await page.locator('[data-block-row="b3"] .mag-word-input').fill('Technologie');
|
||
await page.locator('[data-block-row="b3"] .mag-word-input').press('Enter');
|
||
await expect.poll(() => posts.find(p => p.fix)?.fix).toMatchObject({ token: 'technology', replacement: 'Technologie', start: 8, end: 18 });
|
||
});
|
||
|
||
test('an empty or unchanged word is not sent', async ({ page }) => {
|
||
const posts = await open(page);
|
||
await page.locator('[data-block-row="b3"] mark.mag-spell').first().click();
|
||
const input = page.locator('[data-block-row="b3"] .mag-word-input');
|
||
await input.fill(' ');
|
||
await input.press('Enter');
|
||
await expect(input).toBeVisible(); // still editing: nothing to apply
|
||
await input.fill('technology');
|
||
await input.press('Enter');
|
||
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toHaveCount(0);
|
||
expect(posts).toEqual([]);
|
||
});
|
||
|
||
test('Keep adds the word to the issue dictionary', async ({ page }) => {
|
||
const posts = await open(page);
|
||
await page.locator('[data-block-row="b3"] mark.mag-spell').nth(1).click();
|
||
await page.locator('[data-block-row="b3"] .mag-word-pop').getByRole('button', { name: 'Keep' }).click();
|
||
await expect.poll(() => posts.find(p => p.keep)?.keep).toEqual({ word: 'mature' });
|
||
});
|
||
|
||
test('a flagged word can be reached and opened with the keyboard', async ({ page }) => {
|
||
await open(page);
|
||
const mark = page.locator('[data-block-row="b3"] mark.mag-spell').first();
|
||
await mark.focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(page.locator('[data-block-row="b3"] .mag-word-input')).toBeFocused();
|
||
});
|
||
|
||
test('the whole-text editor keeps the paragraph\'s type, width and the flagged words underlined', async ({ page }) => {
|
||
await open(page);
|
||
const textSel = '[data-block-row="b3"] .mag-block-text';
|
||
const before = await fontOf(page, textSel);
|
||
const box = await page.locator(textSel).boundingBox();
|
||
// Click plain text, away from any flagged word.
|
||
await page.locator(textSel).click({ position: { x: box.width - 4, y: 4 } });
|
||
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
|
||
await expect(area).toBeFocused();
|
||
expect(await fontOf(page, '[data-block-row="b3"] .mag-hl-area')).toBe(before);
|
||
const editorBox = await area.boundingBox();
|
||
expect(Math.abs(editorBox.width - box.width)).toBeLessThan(3); // same column, same wrapping
|
||
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(2);
|
||
// Fix one word by typing: its underline goes, the other stays.
|
||
await area.fill('Battery technologie is entering a new phase. It will take years to mature.');
|
||
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveCount(1);
|
||
await expect(page.locator('[data-block-row="b3"] .mag-hl-back mark.mag-spell')).toHaveText('mature');
|
||
});
|
||
|
||
test('the editor grows with its text instead of scrolling inside a small box', async ({ page }) => {
|
||
await open(page);
|
||
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
|
||
const area = page.locator('[data-block-row="b3"] .mag-hl-area');
|
||
const h1 = (await area.boundingBox()).height;
|
||
await area.fill(Array.from({ length: 40 }, (_, i) => 'Zeile ' + i + ' mit etwas mehr Text').join(' '));
|
||
await expect.poll(async () => (await area.boundingBox()).height).toBeGreaterThan(h1 * 3);
|
||
expect(await area.evaluate(el => el.scrollHeight <= el.clientHeight + 1)).toBe(true);
|
||
});
|
||
|
||
test('Save text still saves the edited text', async ({ page }) => {
|
||
await open(page);
|
||
const patches = [];
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/regions/b3`, route => { patches.push(route.request().postDataJSON()); route.fulfill({ json: { ok: true } }); });
|
||
await page.locator('[data-block-row="b3"] .mag-block-text').click({ position: { x: 4, y: 4 } });
|
||
await page.locator('[data-block-row="b3"] .mag-hl-area').fill('Neuer Text.');
|
||
await page.locator('[data-block-row="b3"]').getByRole('button', { name: 'Save text' }).click();
|
||
await expect.poll(() => patches[0]).toEqual({ text: 'Neuer Text.' });
|
||
});
|
||
});
|
||
|
||
test.describe('Turning a page scanned sideways', () => {
|
||
async function open(page) {
|
||
await mockBackend(page);
|
||
const calls = { rotate: [], layout: 0, jobs: 0 };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => { calls.layout++; return route.fulfill({ json: LAYOUT }); });
|
||
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/ }).click();
|
||
await page.waitForSelector('#mag-undo-btn');
|
||
return calls;
|
||
}
|
||
const routeRotate = (page, calls, { status = 200, job = { done: true, error: null } } = {}) => Promise.all([
|
||
page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
|
||
calls.rotate.push(route.request().postDataJSON());
|
||
return status === 200 ? route.fulfill({ json: { job_id: 'rot1', degrees: calls.rotate.at(-1).degrees } })
|
||
: route.fulfill({ status, json: { detail: 'No vision OCR engine configured -- set one in Settings (turning re-reads the page)' } });
|
||
}),
|
||
page.route('**/api/jobs/rot1', route => { calls.jobs++; return route.fulfill({ json: job }); }),
|
||
]);
|
||
|
||
test('three labelled turn buttons sit together in the toolbar', async ({ page }) => {
|
||
await open(page);
|
||
const group = page.getByRole('group', { name: 'Turn this page' });
|
||
await expect(group.getByRole('button')).toHaveText([/Turn left/, /Turn right/, /Turn 180°/]);
|
||
await expect(group.getByRole('button', { name: /Turn right/ })).toHaveAttribute('title', /clockwise/);
|
||
});
|
||
|
||
test('turning right asks first and, once confirmed, sends 90 and reloads the page', async ({ page }) => {
|
||
const calls = await open(page);
|
||
await routeRotate(page, calls);
|
||
const layoutBefore = calls.layout;
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
const dialog = page.getByRole('alertdialog');
|
||
await expect(dialog).toContainText('Turn page 3 90° clockwise?');
|
||
await expect(dialog).toContainText('corrections you made to this page by hand are lost');
|
||
expect(calls.rotate).toEqual([]); // nothing is sent before the answer
|
||
await dialog.getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect.poll(() => calls.rotate).toEqual([{ degrees: 90 }]);
|
||
await expect.poll(() => calls.jobs).toBeGreaterThan(0);
|
||
await expect.poll(() => calls.layout).toBeGreaterThan(layoutBefore); // the layout is fetched again
|
||
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
||
});
|
||
|
||
test('while a turn is read the old picture and text are dimmed and labelled as old, then the notice goes', async ({ page }) => {
|
||
const calls = await open(page);
|
||
let polls = 0;
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => { calls.rotate.push(route.request().postDataJSON()); return route.fulfill({ json: { job_id: 'rot1', degrees: 90 } }); });
|
||
await page.route('**/api/jobs/rot1', route => { polls++; return route.fulfill({ json: polls < 3 ? { done: false, detail: 'Re-reading page 3' } : { done: true, error: null } }); });
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
const note = page.locator('#mag-turn-busy');
|
||
await expect(note).toBeVisible();
|
||
await expect(note).toContainText('Turning page 3 and reading it again');
|
||
await expect(note).toContainText('from before');
|
||
await expect(note).toHaveAttribute('role', 'status');
|
||
await expect(page.locator('.mag-review-split')).toHaveClass(/is-stale/);
|
||
// what is stale cannot be clicked into by accident
|
||
expect(await page.locator('.mag-review-split').evaluate(el => getComputedStyle(el).pointerEvents)).toBe('none');
|
||
await expect(page.locator('#mag-turn-busy')).toHaveCount(0, { timeout: 15_000 });
|
||
await expect(page.locator('.mag-review-split')).not.toHaveClass(/is-stale/);
|
||
});
|
||
|
||
test('cancelling the question turns nothing', async ({ page }) => {
|
||
const calls = await open(page);
|
||
await routeRotate(page, calls);
|
||
await page.getByRole('button', { name: /Turn left/ }).click();
|
||
await expect(page.getByRole('alertdialog')).toContainText('Turn page 3 90° counter-clockwise?');
|
||
await page.getByRole('alertdialog').getByRole('button', { name: /Cancel/ }).click();
|
||
await page.waitForTimeout(300);
|
||
expect(calls.rotate).toEqual([]);
|
||
});
|
||
|
||
test('left sends -90 and half a turn sends 180', async ({ page }) => {
|
||
const calls = await open(page);
|
||
await routeRotate(page, calls);
|
||
await page.getByRole('button', { name: /Turn left/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect.poll(() => calls.rotate.length).toBe(1);
|
||
await page.getByRole('button', { name: /Turn 180°/ }).click();
|
||
await expect(page.getByRole('alertdialog')).toContainText('upside down');
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect.poll(() => calls.rotate).toEqual([{ degrees: -90 }, { degrees: 180 }]);
|
||
});
|
||
|
||
test('a failed read says the page was left as it was and the buttons work again', async ({ page }) => {
|
||
const calls = await open(page);
|
||
await routeRotate(page, calls, { job: { done: true, error: 'The OCR engine returned nothing usable for this page' } });
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'The page was left as it was' }).first()).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
||
});
|
||
|
||
test('a refusal to start (no OCR engine) is explained', async ({ page }) => {
|
||
const calls = await open(page);
|
||
await routeRotate(page, calls, { status: 400 });
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'No vision OCR engine configured' }).first()).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
||
});
|
||
});
|
||
|
||
test.describe('Draw region panel layering', () => {
|
||
async function openDrawing(page, height = 640) {
|
||
await page.setViewportSize({ width: 1300, height });
|
||
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();
|
||
await page.getByRole('button', { name: /^Review/ }).click();
|
||
await page.getByRole('button', { name: 'Draw region', exact: true }).click();
|
||
await scrollPageImageBelowToolbar(page, 300);
|
||
}
|
||
// Drag a rectangle whose bottom edge is at yEnd (window coordinates) over the page image.
|
||
async function drawEndingAt(page, yEnd, height = 40) {
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
await page.mouse.move(wrap.x + 40, yEnd - height);
|
||
await page.mouse.down();
|
||
await page.mouse.move(wrap.x + 200, yEnd, { steps: 4 });
|
||
await page.mouse.up();
|
||
}
|
||
// What the user would actually hit at the centre of each button: it must be that button, not a bar laid over it.
|
||
const topmostIsOwn = (page, name) => page.locator('.mag-draw-panel').getByRole('button', { name, exact: true }).evaluate((btn) => {
|
||
const r = btn.getBoundingClientRect();
|
||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
|
||
return !!hit && (hit === btn || btn.contains(hit));
|
||
});
|
||
async function geometry(page) {
|
||
const vp = page.viewportSize();
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
const footTop = await page.locator('.mag-review-foot').evaluate(el => el.getBoundingClientRect().top);
|
||
await drawEndingAt(page, wrap.y + 80); // a first rectangle, only to measure the panel
|
||
const height = (await page.locator('.mag-draw-panel').boundingBox()).height;
|
||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
return { vp, wrap, footTop, height };
|
||
}
|
||
|
||
test('a panel opened where the sticky bottom bar lies is on top of it, so every button can be clicked', async ({ page }) => {
|
||
await openDrawing(page);
|
||
const { vp, wrap, footTop, height } = await geometry(page);
|
||
// A rectangle that leaves room for the panel below it *only just*: it fits in the window, but reaches into the
|
||
// band the sticky Back / Approve bar occupies -- the case that hid Cancel / Add region.
|
||
const yEnd = Math.round(Math.min(vp.height - 24 - height, footTop - 8));
|
||
expect(yEnd - 40).toBeGreaterThan(wrap.y); // the rectangle is on the page image
|
||
expect(yEnd + 6 + height).toBeGreaterThan(footTop); // and the panel below it does overlap the bar
|
||
await drawEndingAt(page, yEnd);
|
||
const panel = page.locator('.mag-draw-panel');
|
||
await expect(panel).toBeVisible();
|
||
const box = await panel.boundingBox();
|
||
expect(box.y + box.height).toBeLessThanOrEqual(vp.height); // inside the window...
|
||
expect(box.y + box.height).toBeGreaterThan(footTop); // ...and over the bar's band
|
||
expect(await topmostIsOwn(page, 'Add region')).toBe(true);
|
||
expect(await topmostIsOwn(page, 'Cancel')).toBe(true);
|
||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
|
||
});
|
||
|
||
test('the panel is mounted on the page body, outside the sticky stacking contexts', async ({ page }) => {
|
||
await openDrawing(page);
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
await drawEndingAt(page, wrap.y + 80);
|
||
const info = await page.locator('.mag-draw-panel').evaluate((el) => ({ parent: el.parentElement.tagName, z: getComputedStyle(el).zIndex }));
|
||
expect(info.parent).toBe('BODY');
|
||
expect(Number(info.z)).toBeGreaterThan(100); // above the sticky bars (30-100)
|
||
expect(Number(info.z)).toBeLessThan(1000); // below dialogs
|
||
});
|
||
|
||
test('the region type list stays inside the window and its options can be clicked', async ({ page }) => {
|
||
await openDrawing(page);
|
||
const { vp, footTop, height } = await geometry(page);
|
||
await drawEndingAt(page, Math.round(Math.min(vp.height - 24 - height, footTop - 8)));
|
||
await page.locator('#mag-draw-kind-search').focus();
|
||
const list = page.locator('#mag-draw-kind-list');
|
||
await expect(list).toBeVisible();
|
||
const r = await list.boundingBox();
|
||
expect(r.y).toBeGreaterThanOrEqual(0);
|
||
expect(r.y + r.height).toBeLessThanOrEqual(vp.height);
|
||
const reachable = await list.locator('.mag-type-picker-option').first().evaluate((el) => {
|
||
const b = el.getBoundingClientRect();
|
||
const hit = document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2);
|
||
return !!hit && el.contains(hit);
|
||
});
|
||
expect(reachable).toBe(true);
|
||
await list.locator('.mag-type-picker-option').nth(2).click(); // a real click on an option
|
||
await expect(list).toBeHidden();
|
||
});
|
||
|
||
test('panels are removed when the review is left', async ({ page }) => {
|
||
await openDrawing(page);
|
||
const wrap = await page.locator('#mag-review-page-wrap').boundingBox();
|
||
await drawEndingAt(page, wrap.y + 80);
|
||
await expect(page.locator('.mag-draw-panel')).toHaveCount(1);
|
||
await page.evaluate(() => window.magBackToContents());
|
||
await expect(page.locator('.mag-draw-panel')).toHaveCount(0);
|
||
});
|
||
|
||
test('the save-new-position panel is mounted and placed the same way', async ({ page }) => {
|
||
await openDrawing(page);
|
||
await page.evaluate(() => {
|
||
const box = document.querySelector('#mag-review-page-wrap .mag-review-box');
|
||
window.magReviewOpenEditPanel(box.dataset.block, box, { origLeft: 0, origTop: 0, origW: 10, origH: 10 });
|
||
});
|
||
const panel = page.locator('.mag-edit-panel');
|
||
await expect(panel).toBeVisible();
|
||
expect(await panel.evaluate(el => el.parentElement.tagName)).toBe('BODY');
|
||
const box = await panel.boundingBox();
|
||
const vp = page.viewportSize();
|
||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||
expect(box.y + box.height).toBeLessThanOrEqual(vp.height);
|
||
expect(box.x + box.width).toBeLessThanOrEqual(vp.width);
|
||
});
|
||
});
|
||
|
||
test.describe('Unbreakable text cannot collapse the review', () => {
|
||
const HUGE = 'Giro + 2.' + '1.'.repeat(2500);
|
||
async function openWith(page, width = 1600) {
|
||
await page.setViewportSize({ width, height: 900 });
|
||
await mockBackend(page);
|
||
const layout = { ...LAYOUT, blocks: [...LAYOUT.blocks,
|
||
{ id: 'b9', page: 3, bbox: [50, 500, 200, 40], type: 'body', text: HUGE, reading_order: 3, confidence: 0.5, font_size: 10, font_name: '', bold: false, column: 0, source: 'vision_ocr' }] };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({ json: layout }));
|
||
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/ }).click();
|
||
await page.waitForSelector('#mag-undo-btn');
|
||
await expect(page.locator('[data-block-row="b9"]')).toBeVisible();
|
||
}
|
||
|
||
test('the page picture keeps its column and the list stays inside the window', async ({ page }) => {
|
||
await openWith(page);
|
||
const m = await page.evaluate(() => {
|
||
const wrap = document.getElementById('mag-review-page-wrap').getBoundingClientRect();
|
||
const split = document.querySelector('.mag-review-split');
|
||
const cols = getComputedStyle(split).gridTemplateColumns.split(' ').map(parseFloat);
|
||
const list = document.querySelector('.mag-blocks').getBoundingClientRect();
|
||
return { wrapW: wrap.width, cols, listRight: list.right, vw: window.innerWidth, docW: document.documentElement.scrollWidth };
|
||
});
|
||
expect(m.wrapW).toBeGreaterThan(250); // the page picture is not squeezed to nothing
|
||
expect(Math.min(...m.cols)).toBeGreaterThan(250); // both columns have real width
|
||
expect(m.listRight).toBeLessThanOrEqual(m.vw + 1); // the list does not run off the window
|
||
expect(m.docW).toBeLessThanOrEqual(m.vw + 1); // and the page gets no sideways scroll
|
||
});
|
||
|
||
test('the same holds on a phone, where the columns stack', async ({ page }) => {
|
||
await openWith(page, 390);
|
||
const m = await page.evaluate(() => ({
|
||
wrapW: document.getElementById('mag-review-page-wrap').getBoundingClientRect().width,
|
||
docW: document.documentElement.scrollWidth, vw: window.innerWidth }));
|
||
expect(m.wrapW).toBeGreaterThan(200);
|
||
expect(m.docW).toBeLessThanOrEqual(m.vw + 1);
|
||
});
|
||
|
||
test('the huge token is still shown, wrapped, so it can be fixed or deleted', async ({ page }) => {
|
||
await openWith(page);
|
||
const row = page.locator('[data-block-row="b9"] .mag-block-text');
|
||
await expect(row).toContainText('Giro + 2.1.1.1');
|
||
const box = await row.boundingBox();
|
||
expect(box.width).toBeLessThan(1600);
|
||
expect(box.height).toBeGreaterThan(100); // wrapped onto many lines instead of one 20,000 px line
|
||
await expect(page.locator('[data-block-row="b9"]').getByRole('button', { name: /Delete region/ })).toBeVisible();
|
||
});
|
||
});
|
||
|
||
test.describe('A turned page shows at once, and its net turn is visible', () => {
|
||
async function open(page, { rotation = 0 } = {}) {
|
||
await mockBackend(page);
|
||
const state = { v: 100, rotation, rotates: [] };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout*`, route => route.fulfill({
|
||
json: { ...LAYOUT, pages: LAYOUT.pages.map(p => ({ ...p, image_v: state.v, ...(state.rotation ? { rotation: state.rotation } : {}) })) } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => {
|
||
state.rotates.push(route.request().postDataJSON());
|
||
state.v += 50; state.rotation = (state.rotation + ((state.rotates.at(-1).degrees % 360) + 360)) % 360; // what the server would now report
|
||
return route.fulfill({ json: { job_id: 'rot1', degrees: state.rotates.at(-1).degrees } });
|
||
});
|
||
await page.route('**/api/jobs/rot1', route => route.fulfill({ json: { done: true, error: null, detail: 'done' } }));
|
||
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/ }).click();
|
||
await page.waitForSelector('#mag-undo-btn');
|
||
return state;
|
||
}
|
||
|
||
test('every picture of a page carries its version, so a changed picture is a new URL', async ({ page }) => {
|
||
await open(page);
|
||
await expect(page.locator('#mag-review-page-wrap img')).toHaveAttribute('src', new RegExp(`/pages/3\\?v=100$`));
|
||
});
|
||
|
||
test('after a turn the page picture is requested again under its new version', async ({ page }) => {
|
||
const state = await open(page);
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect.poll(() => state.rotates.length).toBe(1);
|
||
await expect(page.locator('#mag-review-page-wrap img')).toHaveAttribute('src', /\/pages\/3\?v=150$/);
|
||
});
|
||
|
||
test('the page grid thumbnails use the version too', async ({ page }) => {
|
||
await open(page);
|
||
await page.getByRole('button', { name: /^Contents$/ }).click();
|
||
await expect(page.locator('.mag-card-wrap[data-page="3"] img.mag-card-thumb')).toHaveAttribute('src', /\/pages\/3\?v=100$/);
|
||
});
|
||
|
||
test('a page that has not been turned shows no turn state', async ({ page }) => {
|
||
await open(page);
|
||
await expect(page.locator('#mag-turn-state')).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: /Undo turns/ })).toHaveCount(0);
|
||
});
|
||
|
||
test('a turned page says by how much, and Undo turns sends what returns it to the PDF orientation', async ({ page }) => {
|
||
const state = await open(page, { rotation: 270 });
|
||
await expect(page.locator('#mag-turn-state')).toHaveText('Turned 270° clockwise from the scan');
|
||
await page.getByRole('button', { name: /Undo turns/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect.poll(() => state.rotates).toEqual([{ degrees: 90 }]); // 270 + 90 = a full circle
|
||
await expect(page.locator('#mag-turn-state')).toHaveCount(0); // and the state line is gone once it is back to 0
|
||
});
|
||
|
||
test('the server refusing a second turn while one runs is explained and the buttons come back', async ({ page }) => {
|
||
await open(page);
|
||
await page.route(`**/api/magazine/${DOC_ID}/pages/3/rotate`, route => route.fulfill({
|
||
status: 409, json: { detail: 'Page 3 is still being turned or read; wait for it to finish before turning it again' } }));
|
||
await page.getByRole('button', { name: /Turn right/ }).click();
|
||
await page.getByRole('alertdialog').getByRole('button', { name: 'Turn and read again' }).click();
|
||
await expect(page.locator('.toast, #toast, [role="status"]').filter({ hasText: 'still being turned or read' }).first()).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Turn right/ })).toBeEnabled();
|
||
});
|
||
});
|
||
|
||
test.describe('Filtering the page grid by status', () => {
|
||
const art = (id, title, pages, status, extra = {}) => ({ id, title, author: '', pages, blocks: [], images: [], status, confidence: 0.6, section: '', ...extra });
|
||
async function openGrid(page) {
|
||
await mockBackend(page, { articleStatus: 'approved' });
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: [
|
||
art('a1', 'Done piece', [3], 'approved', { has_audio: true }),
|
||
art('a2', 'Open piece', [4], 'detected'),
|
||
art('a3', 'Approved piece', [5], 'approved'),
|
||
art('a4', 'Problem piece', [6], 'detected'),
|
||
], progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/quality/report`, route => route.fulfill({ json: { summary: {}, pages: [
|
||
{ page: 6, needs_review: true, uncovered: [[1, 1, 1, 1]], overlaps: [], clipped: [], suspect_blocks: [], duplicates: [] }] } }));
|
||
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.locator('#mag-contents-view-toggle').click();
|
||
await expect(page.locator('.mag-pages-grid .mag-card-wrap').first()).toBeVisible();
|
||
}
|
||
const shownPages = (page) => page.locator('.mag-pages-grid .mag-card-wrap').evaluateAll(els => els.map(e => Number(e.dataset.page)));
|
||
const chip = (page, status) => page.locator(`.mag-legend-filter[data-status="${status}"]`);
|
||
|
||
test('the filter logic: nothing chosen shows everything, several choices are "any of", and it combines with needs-me', async ({ page }) => {
|
||
await mockBackend(page);
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached' });
|
||
await navTo(page, 's-magazine');
|
||
const v = (st, needs, f) => page.evaluate(([a, b, c]) => window.magPageVisible(a, b, c), [st, needs, f]);
|
||
expect(await v('audio', false, {})).toBe(true);
|
||
expect(await v('audio', false, { status: [] })).toBe(true);
|
||
expect(await v('audio', false, { status: ['flagged'] })).toBe(false);
|
||
expect(await v('audio', false, { status: ['flagged', 'audio'] })).toBe(true);
|
||
expect(await v('flagged', true, { onlyTodo: true, status: ['flagged'] })).toBe(true);
|
||
expect(await v('flagged', false, { onlyTodo: true, status: ['flagged'] })).toBe(false); // both filters must agree
|
||
expect(await v('audio', true, { onlyTodo: true, status: ['flagged'] })).toBe(false);
|
||
expect(await v('audio', false, { onlyTodo: true })).toBe(false);
|
||
});
|
||
|
||
test('each legend entry is a toggle button with the number of pages in that status', async ({ page }) => {
|
||
await openGrid(page);
|
||
const group = page.getByRole('group', { name: 'Filter the pages by status' });
|
||
await expect(group.getByRole('button', { pressed: false }).first()).toBeVisible();
|
||
await expect(chip(page, 'audio')).toContainText('1');
|
||
await expect(chip(page, 'detected')).toContainText('1');
|
||
await expect(chip(page, 'flagged')).toContainText('1');
|
||
await expect(chip(page, 'blank')).toContainText('4');
|
||
await expect(chip(page, 'ad')).toBeDisabled(); // none of those: nothing to filter to
|
||
await expect(chip(page, 'audio')).toHaveAttribute('aria-pressed', 'false');
|
||
});
|
||
|
||
test('choosing a status shows only those pages, and the summary says how many', async ({ page }) => {
|
||
await openGrid(page);
|
||
expect((await shownPages(page)).length).toBe(8);
|
||
await chip(page, 'blank').click();
|
||
expect(await shownPages(page)).toEqual([1, 2, 7, 8]);
|
||
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'true');
|
||
await expect(page.locator('.mag-pages-shown')).toHaveText(/Showing 4 of 8 pages/);
|
||
});
|
||
|
||
test('several statuses add up, and choosing one again takes it off', async ({ page }) => {
|
||
await openGrid(page);
|
||
await chip(page, 'audio').click();
|
||
await chip(page, 'flagged').click();
|
||
expect(await shownPages(page)).toEqual([3, 6]);
|
||
await chip(page, 'audio').click();
|
||
expect(await shownPages(page)).toEqual([6]);
|
||
await chip(page, 'flagged').click();
|
||
expect((await shownPages(page)).length).toBe(8);
|
||
await expect(page.locator('.mag-pages-shown')).toHaveCount(0);
|
||
});
|
||
|
||
test('it combines with "Show only pages that need me"', async ({ page }) => {
|
||
await openGrid(page);
|
||
await page.getByLabel('Show only pages that need me').check();
|
||
const needing = await shownPages(page);
|
||
expect(needing).toEqual(expect.arrayContaining([4, 6]));
|
||
await chip(page, 'detected').click();
|
||
expect(await shownPages(page)).toEqual([4]); // needs me AND detected
|
||
await chip(page, 'detected').click();
|
||
await chip(page, 'audio').click();
|
||
expect(await shownPages(page)).toEqual([]); // page 3 is done: it does not need me
|
||
await expect(page.locator('.mag-pages-grid + *, .mag-pages-grid')).toContainText('No page has that status and needs you');
|
||
});
|
||
|
||
test('"Show all pages" clears every filter and only appears while one is on', async ({ page }) => {
|
||
await openGrid(page);
|
||
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
|
||
await chip(page, 'blank').click();
|
||
await page.getByLabel('Show only pages that need me').check();
|
||
await page.getByRole('button', { name: 'Show all pages' }).click();
|
||
expect((await shownPages(page)).length).toBe(8);
|
||
await expect(page.getByLabel('Show only pages that need me')).not.toBeChecked();
|
||
await expect(chip(page, 'blank')).toHaveAttribute('aria-pressed', 'false');
|
||
await expect(page.getByRole('button', { name: 'Show all pages' })).toHaveCount(0);
|
||
});
|
||
|
||
test('the filter survives a change of the column count and works from the keyboard', async ({ page }) => {
|
||
await openGrid(page);
|
||
await chip(page, 'flagged').focus();
|
||
await page.keyboard.press('Enter');
|
||
expect(await shownPages(page)).toEqual([6]);
|
||
await page.evaluate(() => window.magSetPagesCols(5));
|
||
expect(await shownPages(page)).toEqual([6]);
|
||
await expect(chip(page, 'flagged')).toHaveAttribute('aria-pressed', 'true');
|
||
});
|
||
});
|
||
|
||
|
||
// ── Audio/text sync: the highlight is always the sentence containing audio.currentTime ──────────────────────
|
||
test.describe('Driving mode: three-line text and robust audio/text sync', () => {
|
||
// Four 3 s sentences. Each has 0.7 s of silent lead-in (speech), so a sentence lights up 0.15 s before its
|
||
// words: 0, 3.55, 6.55, 9.55 (the first always from 0).
|
||
const EFF = [0, 3.55, 6.55, 9.55];
|
||
const expectedAt = (t) => EFF.reduce((n, e, i) => (e <= t ? i : n), 0);
|
||
|
||
async function openDriving(page, { speed } = {}) {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
|
||
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
|
||
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'S' + i, block: 0, start: s, end: s + 3, speech: s + 0.7 })) } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
|
||
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
|
||
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.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.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
|
||
if (speed) await page.evaluate((v) => { const a = document.getElementById('mag-playlist-audio'); a.defaultPlaybackRate = v; a.playbackRate = v; }, speed);
|
||
}
|
||
const state = (page) => page.evaluate(() => {
|
||
const a = document.getElementById('mag-playlist-audio');
|
||
const cur = document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current');
|
||
return { t: a.currentTime, current: cur ? Number(cur.dataset.i) : -1,
|
||
shown: [...document.querySelectorAll('#mag-driving-lyrics .mag-driving-line:not([hidden])')].map((e) => Number(e.dataset.i)) };
|
||
});
|
||
|
||
test('shows only previous, current and next sentence, and the window moves with the audio', async ({ page }) => {
|
||
await openDriving(page);
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
|
||
for (const [t, shown] of [[1, [0, 1]], [4, [0, 1, 2]], [7, [1, 2, 3]], [11, [2, 3]]]) {
|
||
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
|
||
await expect.poll(async () => (await state(page)).shown).toEqual(shown);
|
||
const s = await state(page);
|
||
expect(s.current).toBe(expectedAt(s.t));
|
||
}
|
||
// the box is a fixed height: it does not grow or shrink as the lines change
|
||
const h1 = await page.locator('#mag-driving-lyrics').evaluate((e) => e.getBoundingClientRect().height);
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 1; });
|
||
await expect.poll(async () => (await state(page)).current).toBe(0);
|
||
expect(await page.locator('#mag-driving-lyrics').evaluate((e) => e.getBoundingClientRect().height)).toBe(h1);
|
||
// not a live region: sentences are never announced one by one
|
||
expect(await page.locator('#mag-driving-lyrics').getAttribute('aria-live')).toBe('off');
|
||
});
|
||
|
||
test('tapping the previous or next line jumps to that sentence', async ({ page }) => {
|
||
await openDriving(page);
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
|
||
await expect.poll(async () => (await state(page)).current).toBe(1);
|
||
await page.locator('#mag-driving-lyrics .mag-driving-line.is-next').click();
|
||
await expect.poll(async () => (await state(page)).current).toBe(2);
|
||
expect((await state(page)).t).toBeGreaterThanOrEqual(6.5);
|
||
await page.locator('#mag-driving-lyrics .mag-driving-line.is-prev').click();
|
||
await expect.poll(async () => (await state(page)).current).toBe(1);
|
||
});
|
||
|
||
test('reduced motion turns the line transition off', async ({ page }) => {
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await openDriving(page);
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
|
||
await expect.poll(async () => (await state(page)).current).toBe(1);
|
||
expect(await page.locator('#mag-driving-lyrics .mag-driving-line.is-current').evaluate((e) => getComputedStyle(e).animationName)).toBe('none');
|
||
});
|
||
|
||
test('375 px wide: three lines fit the box and the page does not scroll sideways', async ({ page }) => {
|
||
await page.setViewportSize({ width: 375, height: 700 });
|
||
await openDriving(page);
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 4; });
|
||
await expect.poll(async () => (await state(page)).shown).toEqual([0, 1, 2]);
|
||
const box = await page.locator('#mag-driving-lyrics').boundingBox();
|
||
expect(box.width).toBeLessThanOrEqual(375);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
|
||
});
|
||
|
||
test('seeking back and forward lands on the sentence containing the time, also in the silent lead-in', async ({ page }) => {
|
||
await openDriving(page);
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
|
||
// 3.2 s is inside sentence 1's silent lead-in (it lights up at 3.55): still the sentence just spoken
|
||
for (const t of [10.5, 2, 7.5, 3.2, 3.6, 0.1, 6.54, 6.56, 11.9, 0]) {
|
||
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
|
||
await expect.poll(async () => (await state(page)).current, { message: 'seek to ' + t }).toBe(expectedAt(t));
|
||
}
|
||
});
|
||
|
||
for (const speed of [1.5, 2]) {
|
||
test(`playing at ${speed}x: highlight equals the sentence containing currentTime, and media time runs at the rate`, async ({ page }) => {
|
||
await openDriving(page, { speed });
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 1.2; a.play(); });
|
||
const samples = [];
|
||
const wall0 = Date.now();
|
||
let t0 = null;
|
||
while (Date.now() - wall0 < 4500) {
|
||
const s = await state(page);
|
||
if (t0 === null) t0 = { t: s.t, wall: Date.now() };
|
||
const nearEdge = EFF.some((e) => Math.abs(s.t - e) < 0.25);
|
||
if (!nearEdge) samples.push(s);
|
||
if (s.t > 11.5) break;
|
||
await page.waitForTimeout(60);
|
||
}
|
||
const rate = (await state(page)).t - t0.t;
|
||
expect(rate / ((Date.now() - t0.wall) / 1000)).toBeGreaterThan(speed * 0.7); // currentTime is media time
|
||
expect(samples.length).toBeGreaterThan(5);
|
||
for (const s of samples) expect(s.current, 'at ' + s.t).toBe(expectedAt(s.t));
|
||
});
|
||
}
|
||
|
||
test('changing the speed mid-sentence does not move the highlight', async ({ page }) => {
|
||
await openDriving(page);
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 4; a.play(); });
|
||
await expect.poll(async () => (await state(page)).current).toBe(1);
|
||
for (const v of [2, 0.75, 1.5]) {
|
||
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').playbackRate = x; }, v);
|
||
const s = await state(page);
|
||
if (!EFF.some((e) => Math.abs(s.t - e) < 0.2)) expect(s.current).toBe(expectedAt(s.t));
|
||
}
|
||
});
|
||
|
||
test('rapid repeated seeks settle on the final position with no stale highlight', async ({ page }) => {
|
||
await openDriving(page, { speed: 2 });
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {}));
|
||
const finals = [];
|
||
for (let round = 0; round < 4; round++) {
|
||
const last = await page.evaluate(() => {
|
||
const a = document.getElementById('mag-playlist-audio');
|
||
let x = 0;
|
||
for (let i = 0; i < 30; i++) { x = Math.random() * 11.5; a.currentTime = x; }
|
||
return x;
|
||
});
|
||
await page.waitForTimeout(250);
|
||
const s = await state(page);
|
||
finals.push(last);
|
||
expect(s.t).toBeGreaterThan(last - 0.1);
|
||
if (!EFF.some((e) => Math.abs(s.t - e) < 0.25)) expect(s.current, 'after seek to ' + last).toBe(expectedAt(s.t));
|
||
}
|
||
// a seek while paused is shown without any playback frame running
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = 7; });
|
||
await expect.poll(async () => (await state(page)).current).toBe(2);
|
||
});
|
||
});
|
||
|
||
// ── Driving mode layout: top bar / content / dock; views, whole-text lyrics, picture zoom ──────────────
|
||
test.describe('Driving mode layout and views', () => {
|
||
const LONG_TITLE = 'Donaudampfschifffahrtsgesellschaftskapitän und die außergewöhnlich lange Überschrift über Batterien';
|
||
const EFF = [0, 3.55, 6.55, 9.55];
|
||
const expectedAt = (t) => EFF.reduce((n, e, i) => (e <= t ? i : n), 0);
|
||
const IMAGE = { id: 'img-1', page: 3, bbox: [0, 0, 1, 1], filename: 'x.png', reading_order: 0, type: 'figure', description: 'Ein sehr langes Bild mit einer langen Beschreibung, die umbrechen muss.' };
|
||
|
||
async function setup(page) {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||
const article = { ...ARTICLE_APPROVED, title: LONG_TITLE, has_audio: true, audio_seconds: 12, images: [IMAGE.id] };
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, route => route.fulfill({ json: { ...LAYOUT, images: [IMAGE] } }));
|
||
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}/articles/article-001`, route => route.fulfill({ json: article }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/images/*`, route => route.fulfill({
|
||
body: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64'), contentType: 'image/png' }));
|
||
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
|
||
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
|
||
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'Sentence number ' + i + ' is spoken here.', block: 0, start: s, end: s + 3, speech: s + 0.7 })) } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
|
||
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
|
||
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
|
||
});
|
||
}
|
||
async function open(page) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await navTo(page, 's-magazine');
|
||
const card = page.locator('#mag-library-list .mag-card').first();
|
||
await page.locator('#mag-library-list .mag-card:visible, button:has-text("Playlist"):visible').first().waitFor();
|
||
if (await card.isVisible()) await card.click(); // after a reload the app may already have reopened the magazine
|
||
await page.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').first().click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').pause());
|
||
}
|
||
const view = (page, v) => page.locator(`#mag-driving-menu [data-view="${v}"]`);
|
||
const openMenu = async (page) => { if ((await page.locator('#mag-driving-menubtn').getAttribute('aria-expanded')) !== 'true') await page.locator('#mag-driving-menubtn').click(); };
|
||
const pick = async (page, v) => { await openMenu(page); await view(page, v).click(); };
|
||
const toggleCollapse = async (page) => { await openMenu(page); await page.locator('#mag-driving-collapse').click(); };
|
||
|
||
// Everything is measured from the DOM: the dock is fully on screen, round buttons, nothing covers it or the share button.
|
||
async function measure(page) {
|
||
return page.evaluate(() => {
|
||
const r = (el) => { const b = el.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
|
||
const q = (s) => document.querySelector(s);
|
||
const hit = (a, b) => a.l < b.r - 0.5 && b.l < a.r - 0.5 && a.t < b.b - 0.5 && b.t < a.b - 0.5;
|
||
const dock = r(q('.mag-driving-dock')), share = r(q('.mag-driving-share')), content = r(q('.mag-driving-content')), top = r(q('.mag-driving-top'));
|
||
const title = q('#mag-driving-title');
|
||
const inContent = [...q('.mag-driving-content').querySelectorAll('*')].filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height > 0);
|
||
const buttons = [...q('.mag-driving-controls').querySelectorAll('.mag-driving-btn')].map((b) => r(b));
|
||
const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]];
|
||
return {
|
||
vw: innerWidth, vh: innerHeight, dock, share, content, top,
|
||
buttonsRound: buttons.every((b) => Math.abs(b.w - b.h) < 0.5),
|
||
buttonsInside: buttons.every((b) => b.l >= 0 && b.r <= innerWidth && b.t >= dock.t && b.b <= dock.b),
|
||
contentAboveDock: inContent.every((e) => e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption')))),
|
||
offenders: inContent.filter((e) => !(e.getBoundingClientRect().bottom <= dock.t + 0.5 || (e.closest('.mag-driving-lyrics, .mag-driving-full, .mag-driving-stage') || (e.parentElement && e.parentElement.closest('figcaption'))))).map((e) => e.id || e.className || e.tagName),
|
||
contentBoxAboveDock: content.b <= dock.t + 0.5,
|
||
shareHitsContent: hit(share, content), shareHitsDock: hit(share, dock),
|
||
dockCovered: pts.some(([x, y]) => !q('.mag-driving-dock').contains(document.elementFromPoint(x, y))),
|
||
titleClipped: title.scrollWidth > title.clientWidth + 1,
|
||
titleBox: r(title),
|
||
scrollX: document.documentElement.scrollWidth,
|
||
buttonSizes: [...document.querySelectorAll('.mag-driving-top > button, #mag-driving-menubtn, #mag-driving-indexbtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)),
|
||
};
|
||
});
|
||
}
|
||
|
||
const sizes = [[320, 568], [375, 667], [414, 896], [667, 375], [1280, 720]];
|
||
for (const [w, h] of sizes) {
|
||
for (const v of ['split', 'picture', 'text']) {
|
||
test(`${w}x${h}, ${v} view: dock fully visible, round buttons, nothing overlaps`, async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: w, height: h });
|
||
await open(page);
|
||
await pick(page, v);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
|
||
const m = await measure(page);
|
||
expect(m.dock.l).toBeGreaterThanOrEqual(0); expect(m.dock.r).toBeLessThanOrEqual(m.vw + 0.5);
|
||
expect(m.dock.t).toBeGreaterThanOrEqual(0); expect(m.dock.b).toBeLessThanOrEqual(m.vh + 0.5);
|
||
expect(m.buttonsRound).toBe(true); expect(m.buttonsInside).toBe(true);
|
||
expect(m.offenders).toEqual([]); expect(m.contentBoxAboveDock, JSON.stringify([m.content, m.dock])).toBe(true); expect(m.contentAboveDock).toBe(true);
|
||
expect(m.shareHitsContent).toBe(false); expect(m.shareHitsDock).toBe(false);
|
||
expect(m.dockCovered).toBe(false);
|
||
expect(m.content.t).toBeGreaterThanOrEqual(m.top.b - 0.5); // title lives below the top bar
|
||
expect(m.titleBox.l).toBeGreaterThanOrEqual(0); expect(m.titleBox.r).toBeLessThanOrEqual(m.vw + 0.5);
|
||
expect(m.titleBox.t).toBeGreaterThanOrEqual(m.top.b - 0.5);
|
||
expect(m.scrollX).toBeLessThanOrEqual(m.vw);
|
||
for (const s of m.buttonSizes) expect(s).toBeGreaterThanOrEqual(43.5); // 44 px touch targets
|
||
// minimized: still fully visible, and the current line stays
|
||
await toggleCollapse(page);
|
||
await expect(page.locator('#mag-driving-collapse')).toHaveAttribute('aria-checked', 'true');
|
||
const c = await measure(page);
|
||
expect(c.dock.b).toBeLessThanOrEqual(c.vh + 0.5); expect(c.contentBoxAboveDock).toBe(true);
|
||
expect(c.content.h).toBeLessThan(m.content.h + 1);
|
||
});
|
||
}
|
||
}
|
||
|
||
test('each view renders its own content', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await expect(view(page, 'split')).toHaveAttribute('aria-checked', 'true'); // the default
|
||
await expect(page.locator('#mag-driving-visual')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-full')).toBeHidden();
|
||
await pick(page, 'picture');
|
||
await expect(page.locator('#mag-driving-visual')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
|
||
await expect(page.locator('#mag-driving-full')).toBeHidden();
|
||
await pick(page, 'text');
|
||
await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(4);
|
||
await expect(page.locator('#mag-driving-visual')).toBeHidden();
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
|
||
// keyboard: the menu button opens the flyout, arrows move, Enter selects and closes
|
||
await page.locator('#mag-driving-menubtn').focus();
|
||
await page.keyboard.press('Enter');
|
||
await expect(page.locator('#mag-driving-menu')).toBeVisible();
|
||
await page.keyboard.press('ArrowDown');
|
||
await page.keyboard.press('Enter');
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'split'); // lyrics -> next item (compact)
|
||
await expect(page.locator('#mag-driving-menu')).toBeHidden();
|
||
// no live region spam
|
||
expect(await page.locator('#mag-driving-full').getAttribute('aria-live')).toBe('off');
|
||
});
|
||
|
||
test('the chosen view and the minimized state survive a reload', async ({ page }) => {
|
||
await setup(page);
|
||
await open(page);
|
||
await pick(page, 'text');
|
||
await page.reload();
|
||
await open(page);
|
||
await expect(view(page, 'text')).toHaveAttribute('aria-checked', 'true');
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'text');
|
||
await toggleCollapse(page);
|
||
await page.reload();
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true');
|
||
await toggleCollapse(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'false');
|
||
});
|
||
|
||
// ── flyout menu, moved dock toggles, speed beside the slider ──
|
||
test('flyout: one menu button opens a menu with the views, closes on Escape / outside tap / choice, keeps focus right', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
const btn = page.locator('#mag-driving-menubtn'), menu = page.locator('#mag-driving-menu');
|
||
await expect(page.locator('#mag-driving-views')).toHaveCount(0); // the three-icon switch is gone
|
||
await expect(btn).not.toHaveAttribute('aria-haspopup', /.+/);
|
||
await expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(menu).toBeHidden();
|
||
await btn.click();
|
||
await expect(menu).toBeVisible();
|
||
await expect(btn).toHaveAttribute('aria-expanded', 'true');
|
||
await expect(menu).toHaveAttribute('role', 'group');
|
||
expect(await page.locator('#mag-driving-menu [data-view]').count()).toBe(3);
|
||
for (const id of ['#mag-driving-text-toggle', '#mag-driving-image-toggle', '#mag-driving-collapse', '#mag-driving-autonext'])
|
||
await expect(page.locator(id)).toHaveAttribute('role', 'switch');
|
||
await expect(page.locator('#mag-driving-dock .x, .mag-driving-dock #mag-driving-text-toggle, .mag-driving-dock #mag-driving-image-toggle')).toHaveCount(0);
|
||
for (const b of await page.locator('#mag-driving-menu button').all()) {
|
||
const box = await b.boundingBox(); expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(43.5);
|
||
}
|
||
await page.keyboard.press('Escape'); // closes, focus back on the button, overlay stays
|
||
await expect(menu).toBeHidden();
|
||
await expect(btn).toBeFocused();
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await btn.click(); await expect(menu).toBeVisible();
|
||
await page.mouse.click(373, 300); // outside tap
|
||
await expect(menu).toBeHidden();
|
||
await btn.click();
|
||
await view(page, 'text').click(); // choosing closes it and applies the view
|
||
await expect(menu).toBeHidden();
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'text');
|
||
await expect(btn).toHaveAttribute('aria-label', /Text/);
|
||
for (const v of ['picture', 'split', 'text']) {
|
||
await pick(page, v);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', v);
|
||
await expect(view(page, v)).toHaveAttribute('aria-checked', 'true');
|
||
}
|
||
// keyboard: arrows, Home/End, Enter
|
||
await btn.focus(); await page.keyboard.press('ArrowDown');
|
||
await expect(menu).toBeVisible();
|
||
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'text');
|
||
await page.keyboard.press('ArrowDown');
|
||
await page.keyboard.press('ArrowUp'); await page.keyboard.press('ArrowUp');
|
||
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-view', 'picture');
|
||
await page.keyboard.press('End');
|
||
await expect(page.locator('#mag-driving-menu :focus')).toHaveAttribute('data-theme-choice', 'dark');
|
||
await page.keyboard.press('Home'); await page.keyboard.press('Enter');
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'picture');
|
||
});
|
||
|
||
test('flyout: text and pictures toggles live in the menu and keep their state; speed sits after the time in the slider row', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'split');
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
|
||
await openMenu(page);
|
||
await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'true');
|
||
await page.locator('#mag-driving-text-toggle').click();
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
|
||
await openMenu(page);
|
||
await expect(page.locator('#mag-driving-text-toggle')).toHaveAttribute('aria-checked', 'false');
|
||
await page.locator('#mag-driving-image-toggle').click();
|
||
await expect(page.locator('#mag-driving-visual')).toBeHidden();
|
||
await openMenu(page); await page.locator('#mag-driving-text-toggle').click();
|
||
await openMenu(page); await page.locator('#mag-driving-image-toggle').click();
|
||
await expect(page.locator('#mag-driving-visual')).toBeVisible();
|
||
// speed: last item of the slider row, 44px, cycles
|
||
const row = await page.evaluate(() => {
|
||
const r = (s) => document.querySelector(s).getBoundingClientRect();
|
||
const sp = r('#mag-driving-speed'), tm = r('#mag-driving-time'), sk = r('#mag-driving-seek'), dock = r('.mag-driving-dock');
|
||
return { speedAfterTime: sp.left >= tm.right - 0.5, sameRow: Math.abs((sp.top + sp.bottom) / 2 - (tm.top + tm.bottom) / 2) < 6,
|
||
h: sp.height, w: sp.width, slider: sk.width, inDock: sp.bottom <= dock.bottom && sp.right <= innerWidth,
|
||
dockOptions: document.querySelectorAll('.mag-driving-dock .mag-driving-options').length };
|
||
});
|
||
expect(row.speedAfterTime).toBe(true); expect(row.sameRow).toBe(true); expect(row.inDock).toBe(true);
|
||
expect(row.h).toBeGreaterThanOrEqual(43.5); expect(row.w).toBeGreaterThanOrEqual(43.5); expect(row.dockOptions).toBe(0);
|
||
await expect(page.locator('#mag-driving-speed')).toHaveText('1×');
|
||
await page.locator('#mag-driving-speed').click();
|
||
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
|
||
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').playbackRate)).toBe(1.25);
|
||
await page.locator('#mag-driving-speed').click();
|
||
await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click(); await page.locator('#mag-driving-speed').click();
|
||
await page.locator('#mag-driving-speed').click(); // 6 speeds: back to 1x
|
||
await expect(page.locator('#mag-driving-speed')).toHaveText('1×');
|
||
});
|
||
|
||
// ── Redesigned flyout: switches, segmented theme control, view options, contrast ──
|
||
const PREFS_KEY = 'ttsvc_mag_driving_prefs';
|
||
const prefsOf = (page) => page.evaluate((k) => { try { return JSON.parse(localStorage.getItem(k) || '{}'); } catch (_) { return null; } }, PREFS_KEY);
|
||
// WCAG contrast helpers, run in the page against computed styles (alpha and ancestors' backgrounds composited).
|
||
const CONTRAST_JS = `(() => {
|
||
const parse = (c) => { const m = c.match(/[\\d.]+/g).map(Number); return [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
|
||
const over = (t, u) => { const a = t[3]; return [0, 1, 2].map((i) => t[i] * a + u[i] * (1 - a)).concat([1]); };
|
||
const bgOf = (el) => { const st = []; for (let e = el; e; e = e.parentElement) st.push(parse(getComputedStyle(e).backgroundColor)); let c = [0, 0, 0, 1]; for (let i = st.length - 1; i >= 0; i--) c = over(st[i], c); return c; };
|
||
const lum = (c) => { const f = c.slice(0, 3).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; };
|
||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return Math.round((Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05) * 100) / 100; };
|
||
return { parse, over, bgOf, ratio };
|
||
})()`;
|
||
|
||
test('switches: role=switch, label left / switch right, aria-checked toggles by click and Space, persists across reload', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await openMenu(page);
|
||
const ids = { text: '#mag-driving-text-toggle', images: '#mag-driving-image-toggle', collapsed: '#mag-driving-collapse', autonext: '#mag-driving-autonext' };
|
||
const geo = await page.evaluate((ids) => Object.fromEntries(Object.entries(ids).map(([k, s]) => {
|
||
const b = document.querySelector(s), l = b.querySelector('.mag-menu-label').getBoundingClientRect(), t = b.querySelector('.mag-sw').getBoundingClientRect();
|
||
const on = b.getAttribute('aria-checked') === 'true';
|
||
return [k, { role: b.getAttribute('role'), labelLeft: l.right <= t.left + 0.5, track: [t.width, t.height], h: b.getBoundingClientRect().height, on,
|
||
thumb: b.querySelector('.mag-sw-thumb').getBoundingClientRect().left - t.left }];
|
||
})), ids);
|
||
for (const g of Object.values(geo)) { expect(g.role).toBe('switch'); expect(g.labelLeft).toBe(true); expect(g.h).toBeGreaterThanOrEqual(43.5); expect(g.track[0]).toBeGreaterThan(g.track[1]); }
|
||
expect(geo.text.on).toBe(true); expect(geo.images.on).toBe(true); expect(geo.collapsed.on).toBe(false); expect(geo.autonext.on).toBe(true);
|
||
expect(geo.text.thumb).toBeGreaterThan(geo.collapsed.thumb + 10); // on = thumb on the right, off = on the left (not colour alone)
|
||
// click flips; the menu stays open so the effect is visible
|
||
for (const k of ['text', 'images', 'collapsed', 'autonext']) {
|
||
const before = await page.locator(ids[k]).getAttribute('aria-checked');
|
||
await page.locator(ids[k]).click();
|
||
await expect(page.locator(ids[k])).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true');
|
||
await expect(page.locator('#mag-driving-menu')).toBeVisible();
|
||
}
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-collapsed', 'true');
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
|
||
// Space toggles from the keyboard
|
||
await page.locator(ids.autonext).focus();
|
||
await page.keyboard.press('Space');
|
||
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'true');
|
||
await page.keyboard.press('Space');
|
||
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'false');
|
||
await page.keyboard.press('Enter');
|
||
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'true');
|
||
await page.keyboard.press('Space'); // leave it off to prove persistence
|
||
const saved = await prefsOf(page);
|
||
expect(saved).toMatchObject({ text: false, images: false, collapsed: true, autonext: false });
|
||
await page.reload();
|
||
await open(page);
|
||
await openMenu(page);
|
||
await expect(page.locator(ids.text)).toHaveAttribute('aria-checked', 'false');
|
||
await expect(page.locator(ids.images)).toHaveAttribute('aria-checked', 'false');
|
||
await expect(page.locator(ids.collapsed)).toHaveAttribute('aria-checked', 'true');
|
||
await expect(page.locator(ids.autonext)).toHaveAttribute('aria-checked', 'false');
|
||
});
|
||
|
||
test('theme: one segmented Light | Auto | Dark control; Dark is plainly visible and works from the light theme (regression: it looked disabled)', async ({ page }) => {
|
||
await setup(page);
|
||
await page.emulateMedia({ colorScheme: 'light' });
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await openMenu(page);
|
||
const seg = page.locator('#mag-driving-menu .mag-seg:not(.mag-seg-amount)');
|
||
await expect(seg).toHaveAttribute('role', 'radiogroup');
|
||
await expect(seg).toHaveAttribute('aria-labelledby', 'mag-menu-sec-theme');
|
||
await expect(page.locator('#mag-menu-sec-theme')).toHaveText('Theme');
|
||
await expect(seg.locator('button')).toHaveText(['Light', 'Auto', 'Dark']);
|
||
const r = await page.evaluate((js) => {
|
||
const { bgOf, parse, ratio } = eval(js);
|
||
const btns = [...document.querySelectorAll('#mag-driving-menu .mag-seg button')];
|
||
const info = btns.map((b) => { const cs = getComputedStyle(b), bg = bgOf(b);
|
||
return { t: b.dataset.themeChoice, color: cs.color, opacity: cs.opacity, disabled: b.disabled || b.hasAttribute('aria-disabled'), pe: cs.pointerEvents,
|
||
ratio: ratio(parse(cs.color), bg), h: b.getBoundingClientRect().height, w: b.getBoundingClientRect().width,
|
||
check: getComputedStyle(b.querySelector('.mag-seg-check')).display, checked: b.getAttribute('aria-checked') }; });
|
||
const sg = document.querySelector('#mag-driving-menu .mag-seg').getBoundingClientRect(), mn = document.getElementById('mag-driving-menu').getBoundingClientRect();
|
||
return { info, within: sg.left >= mn.left && sg.right <= mn.right + 0.5 };
|
||
}, CONTRAST_JS);
|
||
expect(r.within).toBe(true);
|
||
const dark = r.info.find((i) => i.t === 'dark'), light = r.info.find((i) => i.t === 'light');
|
||
expect(dark.opacity).toBe('1'); expect(dark.disabled).toBe(false); expect(dark.pe).not.toBe('none');
|
||
expect(dark.color).toBe(light.color); // same text colour as its siblings (it used to get the dark palette's)
|
||
for (const i of r.info) { expect(i.ratio, i.t).toBeGreaterThanOrEqual(4.5); expect(i.h).toBeGreaterThanOrEqual(43.5); }
|
||
const auto = r.info.find((i) => i.t === 'auto');
|
||
expect(auto.checked).toBe('true'); expect(auto.check).not.toBe('none'); // selected: tick mark, not colour only
|
||
expect(dark.check).toBe('none');
|
||
await seg.locator('[data-theme-choice="dark"]').click();
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
|
||
expect(await page.evaluate(() => getComputedStyle(document.getElementById('mag-driving')).backgroundColor)).toBe('rgb(0, 0, 0)');
|
||
await expect(page.locator('#mag-driving-menu')).toBeVisible();
|
||
await expect(seg.locator('[data-theme-choice="dark"]')).toHaveAttribute('aria-checked', 'true');
|
||
await expect(seg.locator('[data-theme-choice="dark"] .mag-seg-check')).toBeVisible();
|
||
await expect(seg.locator('[data-theme-choice="auto"]')).toHaveAttribute('aria-checked', 'false');
|
||
await seg.locator('[data-theme-choice="light"]').click();
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'light');
|
||
// keyboard: arrows move over the segments, Space chooses
|
||
await seg.locator('[data-theme-choice="light"]').focus();
|
||
await page.keyboard.press('ArrowRight'); await page.keyboard.press('ArrowRight'); await page.keyboard.press('Space');
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
|
||
});
|
||
|
||
test('views: Picture / Text / Split radio options with icon, label and indicator; old stored values migrate', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await openMenu(page);
|
||
const group = page.locator('#mag-driving-menu [role="radiogroup"][aria-labelledby="mag-menu-sec-view"]');
|
||
await expect(page.locator('#mag-menu-sec-view')).toHaveText('View');
|
||
await expect(group.locator('[role="radio"]')).toHaveCount(3);
|
||
await expect(group.locator('[role="radio"] .mag-menu-label')).toHaveText([/^Picture/, /^Text/, /^Split/]);
|
||
for (const b of await group.locator('[role="radio"]').all()) {
|
||
await expect(b.locator('.mdi').first()).toBeVisible();
|
||
await expect(b.locator('.mag-radio')).toBeVisible();
|
||
}
|
||
const dot = (v) => page.evaluate((v) => getComputedStyle(document.querySelector(`#mag-driving-menu [data-view="${v}"] .mag-radio`), '::after').content, v);
|
||
expect(await dot('split')).toBe('""'); expect(await dot('picture')).toBe('none'); // selected indicator is a dot, not only a fill
|
||
// mapping of the three options
|
||
await view(page, 'picture').click();
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-view', 'picture');
|
||
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeHidden(); await expect(page.locator('#mag-driving-full')).toBeHidden();
|
||
await expect(page.locator('#mag-driving-menubtn')).toHaveAttribute('aria-label', /Picture/);
|
||
await pick(page, 'text');
|
||
await expect(page.locator('#mag-driving-full')).toBeVisible(); await expect(page.locator('#mag-driving-visual')).toBeHidden();
|
||
await expect(page.locator('#mag-driving-menulabel')).toHaveText('Text');
|
||
await pick(page, 'split');
|
||
await expect(page.locator('#mag-driving-visual')).toBeVisible(); await expect(page.locator('#mag-driving-lyrics')).toBeVisible();
|
||
expect(await page.locator('#mag-driving-lyrics .mag-driving-line:not([hidden])').count()).toBeGreaterThanOrEqual(2); // previous (if any), current, next
|
||
expect((await prefsOf(page)).view).toBe('split');
|
||
// migration of values stored by older builds
|
||
for (const [old, now] of [['image', 'picture'], ['lyrics', 'text'], ['compact', 'split'], ['picture', 'picture'], ['text', 'text'], ['split', 'split'], ['bogus', 'split']]) {
|
||
await page.evaluate(([k, v]) => localStorage.setItem(k, JSON.stringify({ view: v })), [PREFS_KEY, old]);
|
||
await page.reload();
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving'), old + ' -> ' + now).toHaveAttribute('data-view', now);
|
||
await openMenu(page);
|
||
await expect(view(page, now)).toHaveAttribute('aria-checked', 'true');
|
||
}
|
||
});
|
||
|
||
test('keyboard: arrows move over every control, Space/Enter activate, Escape closes and returns focus to the menu button', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
const btn = page.locator('#mag-driving-menubtn');
|
||
await btn.focus(); await page.keyboard.press('Enter');
|
||
const order = ['picture', 'text', 'split', 'sentence', 'context', 'paragraph', 'mag-driving-text-toggle', 'mag-driving-image-toggle', 'mag-driving-collapse', 'mag-driving-autonext', 'light', 'auto', 'dark'];
|
||
const cur = () => page.evaluate(() => { const a = document.activeElement; return a.dataset.view || a.dataset.amount || a.dataset.themeChoice || a.id; });
|
||
expect(await cur()).toBe('split'); // focus starts on the selected view
|
||
await page.keyboard.press('Home'); expect(await cur()).toBe('picture');
|
||
for (const next of order.slice(1)) { await page.keyboard.press('ArrowDown'); expect(await cur()).toBe(next); }
|
||
await page.keyboard.press('ArrowDown'); expect(await cur()).toBe('picture'); // wraps
|
||
await page.keyboard.press('ArrowUp'); expect(await cur()).toBe('dark');
|
||
await page.keyboard.press('ArrowLeft'); expect(await cur()).toBe('auto');
|
||
await page.keyboard.press('End'); expect(await cur()).toBe('dark');
|
||
await page.keyboard.press('Space');
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.locator('#mag-driving-menu')).toBeHidden();
|
||
await expect(btn).toBeFocused();
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
});
|
||
|
||
for (const theme of ['light', 'dark']) {
|
||
test(`contrast: flyout controls meet WCAG AA in the ${theme} theme (text 4.5:1, switch / segment / radio boundaries and focus ring 3:1)`, async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await openMenu(page);
|
||
await page.locator(`#mag-driving-menu [data-theme-choice="${theme}"]`).click();
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
const res = await page.evaluate((js) => {
|
||
const { bgOf, parse, over, ratio } = eval(js);
|
||
const q = (s) => document.querySelector(s), menu = q('#mag-driving-menu'), surface = bgOf(menu);
|
||
const text = (el) => ratio(parse(getComputedStyle(el).color), bgOf(el));
|
||
const edge = (c, under) => ratio(over(parse(c), under), under);
|
||
const out = { sections: ['#mag-menu-sec-view', '#mag-menu-sec-show', '#mag-menu-sec-play', '#mag-menu-sec-theme'].map((s) => text(q(s))),
|
||
labels: [...menu.querySelectorAll('.mag-menu-label')].map(text), hints: [...menu.querySelectorAll('.mag-menu-label small')].map(text) };
|
||
const off = q('#mag-driving-collapse'), on = q('#mag-driving-text-toggle'); // collapse is off by default, text is on
|
||
const offTrack = off.querySelector('.mag-sw'), onTrack = on.querySelector('.mag-sw');
|
||
const panelOff = bgOf(offTrack);
|
||
out.offTrackEdge = edge(getComputedStyle(offTrack).borderTopColor, surface);
|
||
out.offThumb = ratio(parse(getComputedStyle(off.querySelector('.mag-sw-thumb')).backgroundColor), panelOff);
|
||
out.onTrackEdge = ratio(parse(getComputedStyle(onTrack).backgroundColor), surface);
|
||
out.onThumb = ratio(parse(getComputedStyle(on.querySelector('.mag-sw-thumb')).backgroundColor), parse(getComputedStyle(onTrack).backgroundColor));
|
||
out.onIcon = ratio(parse(getComputedStyle(on.querySelector('.mag-sw-on')).color), parse(getComputedStyle(on.querySelector('.mag-sw-thumb')).backgroundColor));
|
||
const seg = q('#mag-driving-menu .mag-seg'), sel = seg.querySelector('[aria-checked="true"]'), un = seg.querySelector('[aria-checked="false"]');
|
||
out.segEdge = edge(getComputedStyle(seg).borderTopColor, surface);
|
||
out.segDivider = edge(getComputedStyle(seg.querySelectorAll('button')[1]).borderLeftColor, surface);
|
||
out.segSelected = text(sel); out.segUnselected = text(un);
|
||
const radio = q('#mag-driving-menu [data-view]:not([aria-checked="true"]) .mag-radio');
|
||
out.radioEdge = edge(getComputedStyle(radio).borderTopColor, bgOf(radio));
|
||
out.radioOn = ratio(parse(getComputedStyle(q('#mag-driving-menu [data-view][aria-checked="true"] .mag-radio')).borderTopColor), bgOf(q('#mag-driving-menu [data-view][aria-checked="true"] .mag-radio')));
|
||
return out;
|
||
}, CONTRAST_JS);
|
||
for (const [k, v] of Object.entries(res)) {
|
||
const vals = Array.isArray(v) ? v : [v];
|
||
const min = /Edge|Thumb|Divider|radio|Icon/.test(k) ? 3 : 4.5;
|
||
for (const x of vals) expect(x, `${theme} ${k}`).toBeGreaterThanOrEqual(min);
|
||
}
|
||
// focus ring: visible outline of >= 3:1 against what it sits on (list switch and segment)
|
||
for (const sel of ['#mag-driving-autonext', '#mag-driving-menu [data-view="split"]', `#mag-driving-menu [data-theme-choice="${theme}"]`, '#mag-driving-menu [data-theme-choice="auto"]']) {
|
||
await page.locator(sel).focus();
|
||
await page.keyboard.press('ArrowDown'); await page.keyboard.press('ArrowUp'); // keyboard focus, so :focus-visible applies
|
||
const ring = await page.evaluate(([js, sel]) => {
|
||
const { bgOf, parse, ratio } = eval(js); const el = document.querySelector(sel), cs = getComputedStyle(el);
|
||
return { w: parseFloat(cs.outlineWidth), style: cs.outlineStyle, ratio: ratio(parse(cs.outlineColor), bgOf(el)) };
|
||
}, [CONTRAST_JS, sel]);
|
||
expect(ring.style).toBe('solid'); expect(ring.w).toBeGreaterThanOrEqual(2); expect(ring.ratio, `${theme} focus ring ${sel}`).toBeGreaterThanOrEqual(3);
|
||
}
|
||
});
|
||
}
|
||
|
||
test('reduced motion: the switch thumb does not slide', async ({ page }) => {
|
||
await setup(page);
|
||
await open(page);
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
expect(await page.evaluate(() => getComputedStyle(document.querySelector('.mag-sw-thumb')).transitionDuration)).toBe('0s');
|
||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||
expect(await page.evaluate(() => getComputedStyle(document.querySelector('.mag-sw-thumb')).transitionDuration)).not.toBe('0s');
|
||
});
|
||
|
||
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
|
||
test(`${w}x${h}: new flyout stays inside the viewport and above the dock, controls reachable`, async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: w, height: h });
|
||
await open(page);
|
||
await openMenu(page);
|
||
const g = await page.evaluate(() => {
|
||
const m = document.getElementById('mag-driving-menu').getBoundingClientRect(), d = document.querySelector('.mag-driving-dock').getBoundingClientRect();
|
||
const btns = [...document.querySelectorAll('#mag-driving-menu button')];
|
||
return { l: m.left, r: m.right, t: m.top, b: m.bottom, vw: document.documentElement.clientWidth, vh: innerHeight, dockTop: d.top,
|
||
minH: Math.min(...btns.map((b) => b.getBoundingClientRect().height)), minW: Math.min(...btns.map((b) => b.getBoundingClientRect().width)),
|
||
hOverflow: document.getElementById('mag-driving-menu').scrollWidth > document.getElementById('mag-driving-menu').clientWidth + 1 };
|
||
});
|
||
expect(g.l).toBeGreaterThanOrEqual(0); expect(g.r).toBeLessThanOrEqual(g.vw + 0.5); expect(g.t).toBeGreaterThanOrEqual(0);
|
||
expect(g.b).toBeLessThanOrEqual(g.dockTop + 0.5); expect(g.b).toBeLessThanOrEqual(g.vh);
|
||
expect(g.minH).toBeGreaterThanOrEqual(43.5); expect(g.minW).toBeGreaterThanOrEqual(43.5); expect(g.hOverflow).toBe(false);
|
||
await page.locator('#mag-driving-menu [data-theme-choice="dark"]').scrollIntoViewIfNeeded(); // the menu scrolls when short
|
||
await page.locator('#mag-driving-menu [data-theme-choice="dark"]').click();
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-dtheme', 'dark');
|
||
});
|
||
}
|
||
|
||
// ── Auto-advance to the next article ──
|
||
const CHAIN = (fourth) => [
|
||
{ id: 'article-001', title: 'First', pages: [1], has_audio: true, audio_seconds: 12 },
|
||
{ id: 'article-002', title: 'Silent', pages: [3], has_audio: false },
|
||
{ id: 'article-003', title: 'Third', pages: [5], has_audio: true, audio_seconds: 3 },
|
||
...(fourth ? [{ id: 'article-004', title: 'Fourth', pages: [7], has_audio: true, audio_seconds: 3 }] : []),
|
||
];
|
||
async function setupChain(page, { fourth = true, fourthAudio = 'ok' } = {}) {
|
||
await setup(page);
|
||
const base = { ...ARTICLE_APPROVED, status: 'approved', images: [] };
|
||
const arts = CHAIN(fourth).map((a) => ({ ...base, ...a }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
const hits = { audio: [], progress: [] };
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00[234]/*`, route => route.fulfill({ status: 404, json: { error: 'none' } }));
|
||
for (const [id, secs] of [['article-003', 3000], ['article-004', 3000]]) {
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/${id}/audio`, (route) => {
|
||
hits.audio.push(id);
|
||
if (id === 'article-004' && fourthAudio === '404') return route.fulfill({ status: 404, json: { error: 'missing' } });
|
||
const wav = silentWav(secs), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
|
||
});
|
||
}
|
||
await page.route(`**/api/magazine/${DOC_ID}/progress`, (route) => { hits.progress.push(route.request().postDataJSON()); return route.fulfill({ json: { ok: true } }); });
|
||
return hits;
|
||
}
|
||
const title = (page) => page.locator('#mag-driving-title');
|
||
// Jump close to the end of the current track and play it out.
|
||
const playOut = (page) => page.evaluate(async () => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = Math.max(0, a.duration - 0.4); await a.play(); });
|
||
const playing = (page) => page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); return !a.paused && !a.ended; });
|
||
|
||
test('auto-advance: at the end of an article the next one with audio plays (no-audio rows skipped), speed and state carry over', async ({ page }) => {
|
||
const hits = await setupChain(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await expect(title(page)).toHaveText('First');
|
||
await page.locator('#mag-driving-speed').click(); // 1.25x
|
||
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
|
||
await playOut(page);
|
||
await expect(title(page)).toHaveText('Third'); // 'Silent' has no audio and is skipped
|
||
await expect.poll(() => playing(page)).toBe(true);
|
||
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').playbackRate)).toBe(1.25);
|
||
await expect(page.locator('#mag-driving-speed')).toHaveText('1.25×');
|
||
await expect(page.locator('#mag-driving-announce')).toHaveText('Now playing: Third');
|
||
expect(hits.audio).not.toContain('article-002');
|
||
expect(hits.progress.some((p) => p.article === 'article-001' && p.completed === true)).toBe(true); // finished is saved
|
||
await expect(page.locator('#mag-driving-endstate')).toBeHidden();
|
||
// the index highlights the new article
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
await expect(page.locator('#mag-driving-index-list [aria-current="true"] .ix-title')).toHaveText('Third');
|
||
// media session metadata follows
|
||
if (await page.evaluate(() => 'mediaSession' in navigator && !!navigator.mediaSession.metadata)) {
|
||
expect(await page.evaluate(() => navigator.mediaSession.metadata.title)).toBe('Third');
|
||
}
|
||
});
|
||
|
||
test('auto-advance: stops at the last article with an End of magazine state (no wrap-around)', async ({ page }) => {
|
||
const hits = await setupChain(page, { fourth: false });
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await playOut(page);
|
||
await expect(title(page)).toHaveText('Third');
|
||
await expect.poll(() => playing(page)).toBe(true);
|
||
const before = hits.audio.length;
|
||
await playOut(page);
|
||
await expect(page.locator('#mag-driving-endstate')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-endstate')).toContainText('End of magazine');
|
||
await expect(page.locator('#mag-driving-announce')).toHaveText('End of magazine');
|
||
await page.waitForTimeout(700);
|
||
await expect(title(page)).toHaveText('Third');
|
||
expect(await playing(page)).toBe(false);
|
||
expect(hits.audio.length).toBe(before + 0);
|
||
expect(hits.progress.some((p) => p.article === 'article-003' && p.completed === true)).toBe(true);
|
||
// choosing another article clears the state
|
||
await page.evaluate(() => window.magPlaylistPlay('article-001', { fromStart: true }));
|
||
await expect(page.locator('#mag-driving-endstate')).toBeHidden();
|
||
});
|
||
|
||
test('auto-advance: the switch turns it off (stops as before) and the choice persists', async ({ page }) => {
|
||
await setupChain(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'true'); // default on
|
||
await expect(page.locator('#mag-driving-autonext .mag-menu-label')).toHaveText('Play next article automatically');
|
||
await openMenu(page);
|
||
await page.locator('#mag-driving-autonext').click();
|
||
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'false');
|
||
expect((await prefsOf(page)).autonext).toBe(false);
|
||
await page.keyboard.press('Escape');
|
||
await playOut(page);
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').ended)).toBe(true);
|
||
await page.waitForTimeout(700);
|
||
await expect(title(page)).toHaveText('First');
|
||
expect(await playing(page)).toBe(false);
|
||
await page.reload();
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving-autonext')).toHaveAttribute('aria-checked', 'false');
|
||
});
|
||
|
||
test('auto-advance: manual Next, a seek and duplicate ended events never advance twice', async ({ page }) => {
|
||
await setupChain(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
// manual Next right before the natural end: exactly one step
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = a.duration - 0.5; return a.play(); });
|
||
await page.evaluate(() => window.magDrivingNext());
|
||
await expect(title(page)).toHaveText('Third');
|
||
await page.waitForTimeout(900);
|
||
await expect(title(page)).toHaveText('Third');
|
||
// duplicate / stale ended events while the track is still playing are ignored
|
||
await page.evaluate(async () => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 0.3; await a.play(); a.dispatchEvent(new Event('ended')); a.dispatchEvent(new Event('ended')); });
|
||
await page.waitForTimeout(300);
|
||
await expect(title(page)).toHaveText('Third');
|
||
// a real end followed by duplicates: one advance only
|
||
await playOut(page);
|
||
await expect(title(page)).toHaveText('Fourth');
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.dispatchEvent(new Event('ended')); a.dispatchEvent(new Event('ended')); });
|
||
await page.waitForTimeout(400);
|
||
await expect(title(page)).toHaveText('Fourth');
|
||
});
|
||
|
||
test('auto-advance: a next article whose audio is missing is skipped with a brief message and playback never gets stuck', async ({ page }) => {
|
||
await setupChain(page, { fourthAudio: '404' });
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await playOut(page);
|
||
await expect(title(page)).toHaveText('Third');
|
||
await expect.poll(() => playing(page)).toBe(true);
|
||
await playOut(page); // Fourth cannot be loaded: it is skipped, nothing follows
|
||
await expect(page.locator('#toast')).toContainText('Fourth');
|
||
await expect(page.locator('#toast')).toContainText('skipped');
|
||
await expect(page.locator('#mag-driving-endstate')).toBeVisible();
|
||
expect(await playing(page)).toBe(false);
|
||
});
|
||
|
||
const THREE = [
|
||
{ id: 'article-001', title: 'The Future of Batteries', pages: [1], has_audio: true, audio_seconds: 12 },
|
||
{ id: 'article-002', title: 'Second story with a very long headline that has to wrap over several lines on a small phone', pages: [3], has_audio: true, audio_seconds: 3725 },
|
||
{ id: 'article-003', title: 'Unvoiced', pages: [5], has_audio: false },
|
||
];
|
||
async function setupIndex(page, count, extra = {}) {
|
||
await setup(page);
|
||
const base = { ...ARTICLE_APPROVED, status: 'approved' };
|
||
const arts = count ? Array.from({ length: count }, (_, i) => ({ ...base, id: i === 0 ? 'article-001' : 'article-' + String(i + 1).padStart(3, '0'),
|
||
title: 'Article number ' + (i + 1), pages: [i + 1], has_audio: i % 10 !== 9, audio_seconds: i === 0 ? 12 : 60 + i }))
|
||
: THREE.map((a) => ({ ...base, ...a, ...(extra.authors && a.id in extra.authors ? { author: extra.authors[a.id] } : {}) }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: extra.progress || {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
}
|
||
test('index: shows the derived title with its page label and keeps 44px hit areas and 1:1 circles', async ({ page }) => {
|
||
await setupIndex(page);
|
||
const base = { ...ARTICLE_APPROVED, status: 'approved', has_audio: true, audio_seconds: 30 };
|
||
const arts = [
|
||
{ ...base, id: 'article-001', title: 'The Future of Batteries', display_title: 'The Future of Batteries', page_label: 'Page 1', pages: [1] },
|
||
{ ...base, id: 'article-002', title: 'Page 33', display_title: 'Anzeige', title_source: 'suggested', page_label: 'Seite 33', pages: [33] },
|
||
];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, route => route.fulfill({ json: { articles: arts, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
await page.setViewportSize({ width: 320, height: 568 });
|
||
await open(page);
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
const row = page.locator('#mag-driving-index-list .ix-item').nth(1);
|
||
await expect(row.locator('.ix-title')).toHaveText('Anzeige');
|
||
await expect(row.locator('.ix-page')).toContainText('Seite 33');
|
||
const g = await page.evaluate(() => ({
|
||
plays: [...document.querySelectorAll('#mag-driving-index-list .ix-play')].map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }),
|
||
rows: [...document.querySelectorAll('#mag-driving-index-list .ix-row')].map((e) => e.getBoundingClientRect().height),
|
||
overflow: document.documentElement.scrollWidth > document.documentElement.clientWidth,
|
||
}));
|
||
for (const [w, h] of g.plays) { expect(w).toBeGreaterThanOrEqual(44); expect(Math.abs(w - h)).toBeLessThan(1); }
|
||
for (const h of g.rows) expect(h).toBeGreaterThanOrEqual(44);
|
||
expect(g.overflow).toBe(false);
|
||
});
|
||
test('index: lists every article with title and duration, highlights the current one, jumps and keeps driving mode', async ({ page }) => {
|
||
await setupIndex(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
const btn = page.locator('#mag-driving-indexbtn'), panel = page.locator('#mag-driving-index');
|
||
await expect(btn).toHaveAttribute('aria-expanded', 'false');
|
||
await expect(panel).toBeHidden();
|
||
await btn.click();
|
||
await expect(panel).toBeVisible();
|
||
await expect(btn).toHaveAttribute('aria-expanded', 'true');
|
||
const rows = page.locator('#mag-driving-index-list .ix-row');
|
||
await expect(rows).toHaveCount(3);
|
||
await expect(rows.nth(0)).toContainText('The Future of Batteries');
|
||
await expect(rows.nth(0)).toContainText('0:12');
|
||
await expect(rows.nth(0)).toHaveAttribute('aria-current', 'true');
|
||
await expect(rows.nth(1)).toContainText('1:02:05');
|
||
await expect(rows.nth(1)).not.toHaveAttribute('aria-current', 'true');
|
||
await expect(rows.nth(2)).toBeDisabled();
|
||
await expect(rows.nth(2)).toContainText('no audio');
|
||
expect(await page.evaluate(() => document.querySelector('.mag-driving-dock').getBoundingClientRect().top - document.getElementById('mag-driving-index').getBoundingClientRect().bottom)).toBeGreaterThanOrEqual(-0.5);
|
||
await expect(page.locator('#mag-driving-index-close')).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await expect(panel).toBeHidden(); await expect(btn).toBeFocused();
|
||
await btn.click();
|
||
await expect(rows.nth(0)).toBeFocused(); // focus lands on the current article
|
||
await page.keyboard.press('ArrowDown');
|
||
await expect(rows.nth(1)).toBeFocused();
|
||
await page.keyboard.press('ArrowDown'); // disabled row is skipped
|
||
await expect(rows.nth(1)).toBeFocused();
|
||
await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-close')).toBeFocused(); // trapped: wraps to the first control
|
||
await page.keyboard.press('Tab'); await expect(page.locator('#mag-driving-index-list .ix-play').first()).toBeFocused();
|
||
await page.keyboard.press('Tab'); await expect(rows.nth(0)).toBeFocused();
|
||
await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter');
|
||
await expect(panel).toBeHidden();
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await btn.click();
|
||
await expect(rows.nth(1)).toHaveAttribute('aria-current', 'true');
|
||
await page.locator('#mag-driving-index-close').click();
|
||
await expect(panel).toBeHidden();
|
||
await btn.click(); await expect(panel).toBeVisible();
|
||
await page.mouse.click(230, 25); // tap outside: the top bar beside the buttons
|
||
await expect(panel).toBeHidden();
|
||
// the exact top-bar list button, while audio plays
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').play().catch(() => {}));
|
||
await page.locator('.mag-driving-top #mag-driving-indexbtn').click();
|
||
await expect(page.locator('#mag-driving-index-list .ix-row').filter({ hasText: /\d+:\d\d/ })).toHaveCount(2);
|
||
await expect(panel).toBeVisible();
|
||
});
|
||
|
||
test('index rows: play/resume/replay buttons, three-state indicator with text, author line, live update', async ({ page }) => {
|
||
await setupIndex(page, 0, {
|
||
authors: { 'article-001': '', 'article-002': 'A very long author name that must be shortened with an ellipsis instead of overflowing the sheet' },
|
||
progress: { 'article-001': { seconds: 5, duration: 12, completed: false }, 'article-002': { seconds: 3725, duration: 3725, completed: true } },
|
||
});
|
||
await page.setViewportSize({ width: 320, height: 568 });
|
||
await open(page);
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
const item = (n) => page.locator('#mag-driving-index-list .ix-item').nth(n);
|
||
await expect(item(0).locator('.ix-status')).toHaveAttribute('aria-label', 'Partly played');
|
||
await expect(item(1).locator('.ix-status')).toHaveAttribute('aria-label', 'Played');
|
||
await expect(item(2).locator('.ix-status')).toHaveAttribute('aria-label', 'Not played');
|
||
await expect(item(0).locator('.ix-play .mdi')).toHaveClass(/mdi-restore/);
|
||
await expect(item(0).locator('.ix-play')).toHaveAttribute('aria-label', /^Resume: /);
|
||
await expect(item(1).locator('.ix-play .mdi')).toHaveClass(/mdi-replay/);
|
||
await expect(item(2).locator('.ix-play')).toBeDisabled(); // no audio stays disabled
|
||
await expect(item(2).locator('.ix-row')).toBeDisabled();
|
||
await expect(item(0).locator('.ix-author')).toHaveCount(0); // no author: no line
|
||
await expect(item(1).locator('.ix-author')).toContainText('by A very long');
|
||
const g = await page.evaluate(() => {
|
||
const els = [...document.querySelectorAll('#mag-driving-index-list .ix-play')];
|
||
const a = document.querySelector('#mag-driving-index-list .ix-author'), li = a.closest('.ix-item'), list = document.getElementById('mag-driving-index-list');
|
||
const author = document.querySelectorAll('#mag-driving-index-list .ix-author')[0];
|
||
return { circles: els.map((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; }), over: author.scrollWidth > author.clientWidth,
|
||
liInside: li.getBoundingClientRect().right <= list.getBoundingClientRect().right + 0.5, scrollX: document.documentElement.scrollWidth,
|
||
fs: parseFloat(getComputedStyle(author).fontSize), tfs: parseFloat(getComputedStyle(document.querySelector('#mag-driving-index-list .ix-title')).fontSize) };
|
||
});
|
||
for (const [w, h] of g.circles) { expect(w).toBeGreaterThanOrEqual(43.5); expect(Math.abs(w - h)).toBeLessThan(0.5); }
|
||
expect(g.over).toBe(true); expect(g.liInside).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(320); expect(g.fs).toBeLessThan(g.tfs);
|
||
// the resume button starts that article without leaving driving mode
|
||
const current = () => page.evaluate(() => document.getElementById('mag-playlist-audio').dataset.article);
|
||
await item(0).locator('.ix-play').click();
|
||
await expect(page.locator('#mag-driving-index')).toBeHidden();
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(current).toBe('article-001');
|
||
// live: when the current article ends, its indicator flips to Played and the next one becomes current
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').dispatchEvent(new Event('ended')));
|
||
await expect.poll(current).toBe('article-002');
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
await expect(item(0).locator('.ix-status')).toHaveAttribute('aria-label', 'Played');
|
||
await expect(item(0)).toHaveClass(/is-complete/);
|
||
await expect(item(0).locator('.ix-play .mdi')).toHaveClass(/mdi-replay/);
|
||
await expect(item(1).locator('.ix-row')).toHaveAttribute('aria-current', 'true');
|
||
// the sheet stays live while open: finishing article 002 updates its row in place
|
||
await page.evaluate(() => document.getElementById('mag-playlist-audio').dispatchEvent(new Event('ended')));
|
||
await expect(item(1)).toHaveClass(/is-complete/);
|
||
await item(0).locator('.ix-play').click();
|
||
await expect.poll(current).toBe('article-001');
|
||
});
|
||
|
||
test('index rows are separated: divider line and 12-14 px padding per row, current row highlighted without divider', async ({ page }) => {
|
||
await setupIndex(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
const g = await page.evaluate(() => {
|
||
const items = [...document.querySelectorAll('#mag-driving-index-list .ix-item')];
|
||
const cs = (e) => getComputedStyle(e);
|
||
return items.map((li) => { const c = cs(li), row = li.querySelector('.ix-row');
|
||
return { pt: parseFloat(c.paddingTop), pb: parseFloat(c.paddingBottom), bb: parseFloat(c.borderBottomWidth), bc: c.borderBottomColor,
|
||
current: row.getAttribute('aria-current') === 'true', rowH: row.getBoundingClientRect().height, play: li.querySelector('.ix-play').getBoundingClientRect().height }; });
|
||
});
|
||
expect(g.length).toBeGreaterThan(1);
|
||
for (const it of g) { expect(it.pt).toBeGreaterThanOrEqual(12); expect(it.pt).toBeLessThanOrEqual(14); expect(it.pb).toBeGreaterThanOrEqual(12); expect(it.rowH).toBeGreaterThanOrEqual(43.5); expect(it.play).toBeGreaterThanOrEqual(43.5); }
|
||
const plain = g.find((it) => !it.current && it !== g[g.length - 1]);
|
||
expect(plain.bb).toBeGreaterThanOrEqual(1);
|
||
expect(g.find((it) => it.current).bc).toBe('rgba(0, 0, 0, 0)');
|
||
});
|
||
|
||
// ── Driving mode colour theme ──────────────────────────────────────────────────────────────────────
|
||
const themeItem = (page, t) => page.locator(`#mag-driving-menu [data-theme-choice="${t}"]`);
|
||
const chooseTheme = async (page, t) => { await openMenu(page); await themeItem(page, t).click(); };
|
||
const dtheme = (page) => page.evaluate(() => document.getElementById('mag-driving').dataset.dtheme);
|
||
const stored = (page) => page.evaluate(() => { try { return JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs') || '{}').theme; } catch (_) { return 'err'; } });
|
||
|
||
test('theme: Auto (default) follows the emulated system colour scheme live; Light and Dark override it', async ({ page }) => {
|
||
await setupIndex(page);
|
||
await page.emulateMedia({ colorScheme: 'dark' });
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
expect(await dtheme(page)).toBe('dark');
|
||
await openMenu(page);
|
||
await expect(themeItem(page, 'auto')).toHaveAttribute('aria-checked', 'true');
|
||
await expect(themeItem(page, 'dark')).toHaveAttribute('aria-checked', 'false');
|
||
await page.keyboard.press('Escape');
|
||
await page.emulateMedia({ colorScheme: 'light' });
|
||
await expect.poll(() => dtheme(page)).toBe('light');
|
||
await page.emulateMedia({ colorScheme: 'dark' });
|
||
await expect.poll(() => dtheme(page)).toBe('dark');
|
||
await chooseTheme(page, 'light'); // explicit Light wins over a dark system
|
||
expect(await dtheme(page)).toBe('light');
|
||
expect(await page.evaluate(() => getComputedStyle(document.getElementById('mag-driving')).backgroundColor)).toBe('rgb(245, 243, 238)');
|
||
await page.emulateMedia({ colorScheme: 'light' });
|
||
await chooseTheme(page, 'dark'); // explicit Dark wins over a light system
|
||
expect(await dtheme(page)).toBe('dark');
|
||
await expect(page.locator('html')).toHaveAttribute('data-mag-night', '');
|
||
await chooseTheme(page, 'auto');
|
||
expect(await dtheme(page)).toBe('light');
|
||
await expect(page.locator('html')).not.toHaveAttribute('data-mag-night', '');
|
||
});
|
||
|
||
test('theme: the choice persists across a reload (localStorage) and survives unavailable storage', async ({ page }) => {
|
||
await setupIndex(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
expect(await dtheme(page)).toBe('light');
|
||
await chooseTheme(page, 'dark');
|
||
expect(await stored(page)).toBe('dark');
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
await open(page);
|
||
expect(await dtheme(page)).toBe('dark');
|
||
await openMenu(page);
|
||
await expect(themeItem(page, 'dark')).toHaveAttribute('aria-checked', 'true');
|
||
await expect(themeItem(page, 'auto')).toHaveAttribute('aria-checked', 'false');
|
||
// keyboard reachable: End goes to the last radio, Enter chooses it
|
||
await page.keyboard.press('Escape');
|
||
await page.locator('#mag-driving-menubtn').focus(); await page.keyboard.press('ArrowDown');
|
||
await page.keyboard.press('End'); await page.keyboard.press('ArrowUp'); await page.keyboard.press('Enter');
|
||
expect(await dtheme(page)).toBe('light');
|
||
// storage that throws must not break the setting for the session
|
||
await page.evaluate(() => { Storage.prototype.setItem = () => { throw new Error('blocked'); }; });
|
||
await chooseTheme(page, 'dark');
|
||
expect(await dtheme(page)).toBe('dark');
|
||
});
|
||
|
||
test('theme: dark overlay is dark at the first frame, theme-color follows and is restored, images are dimmed', async ({ page }) => {
|
||
await setupIndex(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await chooseTheme(page, 'dark');
|
||
await page.evaluate(() => window.magToggleDriving()); // close
|
||
await expect(page.locator('#mag-driving')).toBeHidden();
|
||
const r = await page.evaluate(() => {
|
||
const meta = () => document.querySelector('meta[name="theme-color"]').getAttribute('content');
|
||
const before = meta();
|
||
window.magToggleDriving(); // open, measured in the same task (no frame in between)
|
||
const o = document.getElementById('mag-driving');
|
||
const sync = { bg: getComputedStyle(o).backgroundColor, theme: meta(), visible: !o.hidden };
|
||
const img = document.createElement('img'); document.getElementById('mag-driving-pz').appendChild(img);
|
||
sync.filter = getComputedStyle(img).filter; img.remove();
|
||
window.magToggleDriving(); // close again
|
||
return { before, sync, after: meta(), night: document.documentElement.hasAttribute('data-mag-night') };
|
||
});
|
||
expect(r.sync.visible).toBe(true);
|
||
expect(r.sync.bg).toBe('rgb(0, 0, 0)');
|
||
expect(r.sync.theme.toLowerCase()).toBe('#000000');
|
||
expect(r.sync.filter).toBe('brightness(0.85)');
|
||
expect(r.after).toBe(r.before);
|
||
expect(r.night).toBe(false);
|
||
});
|
||
|
||
test('theme: dark text, icon and UI colours meet WCAG AA (4.5:1 text, 3:1 UI) incl. disabled rows and the word highlight', async ({ page }) => {
|
||
await setupIndex(page, 0, { authors: { 'article-001': 'Ada Author', 'article-002': 'Ben Writer' },
|
||
progress: { 'article-001': { seconds: 5, duration: 12, completed: false }, 'article-002': { seconds: 3725, duration: 3725, completed: true } } });
|
||
await page.emulateMedia({ reducedMotion: 'reduce' }); // no fade-in animation while measuring
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await chooseTheme(page, 'dark');
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 4; });
|
||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line.is-current')).toBeVisible();
|
||
await expect(page.locator('#mag-driving-lyrics .mag-dw.is-current')).toHaveCount(1);
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
await expect(page.locator('#mag-driving-index-list .ix-row').first()).toBeVisible();
|
||
const res = await page.evaluate(() => {
|
||
const parse = (c) => { const m = c.match(/[\d.]+/g).map(Number); return [m[0], m[1], m[2], m.length > 3 ? m[3] : 1]; };
|
||
const over = (top, under) => { const a = top[3]; return [0, 1, 2].map((i) => top[i] * a + under[i] * (1 - a)).concat([1]); };
|
||
const bgOf = (el) => { const stack = []; for (let e = el; e; e = e.parentElement) { stack.push(parse(getComputedStyle(e).backgroundColor)); }
|
||
let c = [0, 0, 0, 1]; for (let i = stack.length - 1; i >= 0; i--) c = over(stack[i], c); return c; };
|
||
const opac = (el) => { let o = 1; for (let e = el; e; e = e.parentElement) o *= Number(getComputedStyle(e).opacity); return o; };
|
||
const lum = (c) => { const f = c.slice(0, 3).map((v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }); return 0.2126 * f[0] + 0.7152 * f[1] + 0.0722 * f[2]; };
|
||
const ratio = (a, b) => { const x = lum(a), y = lum(b); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
||
const textRatio = (el) => { const bg = bgOf(el), fgc = parse(getComputedStyle(el).color), o = opac(el);
|
||
const fg = over([fgc[0], fgc[1], fgc[2], fgc[3] * o], bg); return Math.round(ratio(fg, bg) * 100) / 100; };
|
||
const q = (s) => document.querySelector(s), items = [...document.querySelectorAll('#mag-driving-index-list .ix-item')];
|
||
const cur = items.find((li) => li.querySelector('[aria-current="true"]')), plain = items.find((li) => li !== cur && !li.querySelector('.ix-row:disabled'));
|
||
const dis = items.find((li) => li.querySelector('.ix-row:disabled'));
|
||
const border = (el) => { const bg = bgOf(el), b = parse(getComputedStyle(el).borderBottomColor); return Math.round(ratio(over(b, bg), bg) * 100) / 100; };
|
||
// the word highlight: a marked word on the current line
|
||
const w = document.createElement('span'); w.className = 'mag-fw is-current'; w.textContent = 'word'; q('#mag-driving-lyrics .mag-driving-line.is-current').appendChild(w);
|
||
const out = {
|
||
title: textRatio(q('#mag-driving-title')), by: textRatio(q('#mag-driving-by')) ,
|
||
rowTitle: textRatio(plain.querySelector('.ix-title')), rowAuthor: textRatio(plain.querySelector('.ix-author')), rowNum: textRatio(plain.querySelector('.mdi-num')),
|
||
rowDur: textRatio(plain.querySelector('.ix-dur')), rowPlayIcon: textRatio(plain.querySelector('.ix-play')),
|
||
curTitle: textRatio(cur.querySelector('.ix-title')), curAuthor: textRatio(cur.querySelector('.ix-author')), curDur: textRatio(cur.querySelector('.ix-dur')),
|
||
disTitle: textRatio(dis.querySelector('.ix-title')), disDur: textRatio(dis.querySelector('.ix-dur')), disPlay: textRatio(dis.querySelector('.ix-play')),
|
||
statusPlain: textRatio(plain.querySelector('.ix-status')),
|
||
currentLine: textRatio(q('#mag-driving-lyrics .mag-driving-line.is-current')), prevNext: textRatio(q('#mag-driving-lyrics .is-prev, #mag-driving-lyrics .is-next')), liveWord: textRatio(q('#mag-driving-lyrics .mag-dw.is-current')),
|
||
dockIcon: textRatio(q('.mag-driving-btn')), playIcon: textRatio(q('.mag-driving-play')), speed: textRatio(q('#mag-driving-speed')), time: textRatio(q('#mag-driving-time')),
|
||
word: textRatio(w), menuBtn: textRatio(q('#mag-driving-menubtn')), indexBtn: textRatio(q('#mag-driving-indexbtn')), exit: textRatio(q('.mag-driving-exit')),
|
||
divider: border(plain), dockEdge: border(q('.mag-driving-dock')) };
|
||
w.remove();
|
||
// three-state indicators stay distinguishable without colour: different icons
|
||
out.icons = items.slice(0, 3).map((li) => li.querySelector('.ix-status .mdi').className);
|
||
return out;
|
||
});
|
||
const { icons, divider, dockEdge, ...text } = res;
|
||
for (const [k, v] of Object.entries(text)) expect(v, k + ' = ' + v).toBeGreaterThanOrEqual(4.5);
|
||
expect(divider).toBeGreaterThanOrEqual(3); expect(dockEdge).toBeGreaterThanOrEqual(3);
|
||
expect(res.playIcon).toBeGreaterThanOrEqual(4.5);
|
||
expect(new Set(icons).size).toBe(3);
|
||
// the dock transport buttons stay perfect circles in the dark theme
|
||
const circles = await page.evaluate(() => [...document.querySelectorAll('.mag-driving-btn')].map((b) => { const r = b.getBoundingClientRect(); return [r.width, r.height, getComputedStyle(b).borderRadius]; }));
|
||
for (const [w, h, br] of circles) { expect(Math.abs(w - h)).toBeLessThan(0.5); expect(br).toBe('50%'); }
|
||
});
|
||
|
||
test('index with 113 articles renders quickly and scrolls the current one into view', async ({ page }) => {
|
||
await setupIndex(page, 113);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await page.evaluate(() => window.magPlaylistPlay('article-090'));
|
||
const t = await page.evaluate(() => { const t0 = performance.now(); magDrivingIndexOpen(); return performance.now() - t0; });
|
||
expect(t).toBeLessThan(250);
|
||
await expect(page.locator('#mag-driving-index-list .ix-row')).toHaveCount(113);
|
||
const inView = await page.evaluate(() => {
|
||
const l = document.getElementById('mag-driving-index-list').getBoundingClientRect(), c = document.querySelector('#mag-driving-index-list [aria-current="true"]').getBoundingClientRect();
|
||
return c.top >= l.top - 1 && c.bottom <= l.bottom + 1;
|
||
});
|
||
expect(inView).toBe(true);
|
||
await expect(page.locator('#mag-driving-index-list .ix-row:disabled').first()).toContainText('no audio');
|
||
});
|
||
|
||
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
|
||
test(`${w}x${h}: flyout and index stay in the viewport and above the dock; dock stays whole with 1:1 buttons`, async ({ page }) => {
|
||
await setupIndex(page, 113);
|
||
await page.setViewportSize({ width: w, height: h });
|
||
await open(page);
|
||
const geo = () => page.evaluate(() => {
|
||
const r = (el) => { const b = el.getBoundingClientRect(); return { l: b.left, t: b.top, r: b.right, b: b.bottom, w: b.width, h: b.height }; };
|
||
const dock = r(document.querySelector('.mag-driving-dock'));
|
||
const inside = (b) => b.l >= -0.5 && b.r <= innerWidth + 0.5 && b.t >= -0.5 && b.b <= innerHeight + 0.5;
|
||
const menu = document.getElementById('mag-driving-menu'), idx = document.getElementById('mag-driving-index');
|
||
const out = { dock, dockInside: inside(dock), btns: [...document.querySelectorAll('.mag-driving-controls .mag-driving-btn')].map(r).every((b) => Math.abs(b.w - b.h) < 0.5),
|
||
speed: r(document.getElementById('mag-driving-speed')), slider: r(document.getElementById('mag-driving-seek')),
|
||
topBtns: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].map((b) => Math.min(b.getBoundingClientRect().width, b.getBoundingClientRect().height)),
|
||
topInside: [...document.querySelectorAll('.mag-driving-top > button, .mag-driving-menubtn')].every((b) => inside(r(b))), scrollX: document.documentElement.scrollWidth };
|
||
if (!menu.hidden) { const m = r(menu); out.menu = m; out.menuInside = inside(m); out.menuAboveDock = m.b <= dock.t + 0.5;
|
||
out.menuItems = [...menu.querySelectorAll('button')].map((b) => b.getBoundingClientRect().height); out.menuScrolls = menu.scrollHeight > menu.clientHeight + 1; }
|
||
if (!idx.hidden) { const m = r(idx); out.idx = m; out.idxInside = inside(m); out.idxAboveDock = m.b <= dock.t + 0.5; out.idxBelowTop = m.t >= document.querySelector('.mag-driving-top').getBoundingClientRect().bottom - 0.5; }
|
||
const pts = [[dock.l + 5, dock.t + 5], [(dock.l + dock.r) / 2, dock.b - 5]];
|
||
out.dockCovered = pts.some(([x, y]) => !document.querySelector('.mag-driving-dock').contains(document.elementFromPoint(x, y)));
|
||
return out;
|
||
});
|
||
let g = await geo();
|
||
expect(g.dockInside).toBe(true); expect(g.btns).toBe(true); expect(g.topInside).toBe(true);
|
||
expect(g.speed.w).toBeGreaterThanOrEqual(43.5); expect(g.speed.h).toBeGreaterThanOrEqual(43.5);
|
||
expect(g.slider.w).toBeGreaterThanOrEqual(w === 320 ? 100 : 120);
|
||
for (const s of g.topBtns) expect(s).toBeGreaterThanOrEqual(43.5);
|
||
await page.locator('#mag-driving-menubtn').click();
|
||
g = await geo();
|
||
expect(g.menuInside, JSON.stringify(g.menu)).toBe(true); expect(g.menuAboveDock, JSON.stringify([g.menu, g.dock])).toBe(true);
|
||
for (const hh of g.menuItems) expect(hh).toBeGreaterThanOrEqual(43.5);
|
||
expect(g.dockCovered).toBe(false); expect(g.scrollX).toBeLessThanOrEqual(w);
|
||
await page.keyboard.press('Escape');
|
||
await page.locator('#mag-driving-indexbtn').click();
|
||
g = await geo();
|
||
expect(g.idxInside, JSON.stringify(g.idx)).toBe(true); expect(g.idxAboveDock).toBe(true); expect(g.idxBelowTop).toBe(true);
|
||
expect(g.dockInside).toBe(true); expect(g.dockCovered).toBe(false); expect(g.btns).toBe(true); expect(g.scrollX).toBeLessThanOrEqual(w);
|
||
await expect(page.locator('#mag-driving-index-list .ix-row').first()).toBeVisible();
|
||
const rowH = await page.locator('#mag-driving-index-list .ix-row').first().evaluate((e) => e.getBoundingClientRect().height);
|
||
expect(rowH).toBeGreaterThanOrEqual(43.5);
|
||
const circles = await page.locator('#mag-driving-index-list .ix-play').first().evaluate((e) => { const b = e.getBoundingClientRect(); return [b.width, b.height]; });
|
||
expect(circles[0]).toBeGreaterThanOrEqual(43.5); expect(Math.abs(circles[0] - circles[1])).toBeLessThan(0.5);
|
||
expect(await page.evaluate(() => { const l = document.getElementById('mag-driving-index-list'); return l.scrollWidth <= l.clientWidth + 1; })).toBe(true);
|
||
});
|
||
}
|
||
|
||
test('lyrics highlight the sentence containing currentTime after a seek, centered; manual scroll pauses following', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await pick(page, 'text');
|
||
const current = () => page.evaluate(() => Number(document.querySelector('#mag-driving-full .mag-driving-fline.is-current')?.dataset.i ?? -1));
|
||
for (const t of [10.5, 2, 7.5, 3.6, 0.1]) {
|
||
await page.evaluate((x) => { document.getElementById('mag-playlist-audio').currentTime = x; }, t);
|
||
await expect.poll(current, { message: 'seek to ' + t }).toBe(expectedAt(t));
|
||
}
|
||
// the current line ends up near the vertical middle of the scroller
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 7; });
|
||
await expect.poll(current).toBe(2);
|
||
await expect.poll(() => page.evaluate(() => {
|
||
const box = document.getElementById('mag-driving-full').getBoundingClientRect(), el = document.querySelector('#mag-driving-full .mag-driving-fline.is-current').getBoundingClientRect();
|
||
return Math.abs((el.top + el.height / 2) - (box.top + box.height / 2));
|
||
})).toBeLessThan(40);
|
||
// dim neighbours
|
||
expect(Number(await page.evaluate(() => getComputedStyle(document.querySelector('#mag-driving-full [data-i="3"]')).opacity))).toBeLessThan(1);
|
||
// manual scroll: no auto-follow, "Back to current" appears and restores it
|
||
await page.locator('#mag-driving-full').hover();
|
||
await page.mouse.wheel(0, -400);
|
||
await expect(page.locator('#mag-driving-backcur')).toBeVisible();
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 10; });
|
||
await expect.poll(current).toBe(3);
|
||
await page.locator('#mag-driving-backcur').click();
|
||
await expect(page.locator('#mag-driving-backcur')).toBeHidden();
|
||
// tapping a sentence seeks to it
|
||
await page.locator('#mag-driving-full [data-i="1"]').click();
|
||
await expect.poll(current).toBe(1);
|
||
expect(await page.evaluate(() => document.getElementById('mag-playlist-audio').currentTime)).toBeGreaterThanOrEqual(3.5);
|
||
});
|
||
|
||
test('picture zoom: buttons, double tap, keys and Escape', async ({ page }) => {
|
||
await setup(page);
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await open(page);
|
||
await pick(page, 'picture');
|
||
const stage = page.locator('#mag-driving-stage');
|
||
await expect(stage).toBeVisible();
|
||
await expect(stage.locator('img')).toHaveCount(1);
|
||
await expect(stage).toHaveAttribute('data-zoom', '1');
|
||
await expect(page.locator('#mag-driving-zoom-reset')).toBeDisabled();
|
||
await page.locator('#mag-driving-zoom-in').click();
|
||
await expect(stage).toHaveAttribute('data-zoom', '1.5');
|
||
await page.locator('#mag-driving-zoom-reset').click();
|
||
await expect(stage).toHaveAttribute('data-zoom', '1');
|
||
await stage.dblclick();
|
||
await expect(stage).toHaveAttribute('data-zoom', '2.5');
|
||
await stage.dblclick();
|
||
await expect(stage).toHaveAttribute('data-zoom', '1');
|
||
await stage.focus();
|
||
await page.keyboard.press('+');
|
||
await expect(stage).toHaveAttribute('data-zoom', '1.5');
|
||
await page.keyboard.press('Escape');
|
||
await expect(stage).toHaveAttribute('data-zoom', '1');
|
||
await expect(page.locator('#mag-driving')).toBeVisible(); // Escape only reset the zoom
|
||
for (const b of ['#mag-driving-zoom-in', '#mag-driving-zoom-out', '#mag-driving-zoom-reset']) {
|
||
const box = await page.locator(b).boundingBox();
|
||
expect(Math.min(box.width, box.height)).toBeGreaterThanOrEqual(44);
|
||
await expect(page.locator(b)).toHaveAttribute('aria-label', /Zoom/i);
|
||
}
|
||
});
|
||
});
|
||
|
||
|
||
// ── Word-level highlight from measured word times (forced aligner), with fallback to interpolation ──────────
|
||
test.describe('Driving mode: measured word times drive the word highlight', () => {
|
||
const W_START = [0.7, 1.2, 1.6, 2.3], W_END = [1.1, 1.5, 2.2, 2.9]; // seconds into each 3 s sentence
|
||
const EFF = [0, 3.55, 6.55, 9.55];
|
||
const N_WORDS = [4, 4, 3, 4]; // words in 'Alpha one is here.' ... 'Delta four ends it.'
|
||
const expectedWord = (t) => {
|
||
const i = EFF.reduce((n, e, k) => (e <= t ? k : n), 0), rel = t - 3 * i;
|
||
if (rel < W_START[0] - 0.02) return [i, -1];
|
||
return [i, W_START.slice(0, N_WORDS[i]).reduce((n, s, k) => (s <= rel + 0.02 ? k : n), 0)];
|
||
};
|
||
|
||
async function openWords(page, { withWords = true, speed } = {}) {
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true });
|
||
const blocks = [{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body',
|
||
text: 'Alpha one is here. Bravo two is next. Charlie three follows. Delta four ends it.', reading_order: 0 }];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article: ARTICLE_APPROVED, blocks } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
|
||
words: withWords ? 'ready' : 'none',
|
||
sentences: [0, 3, 6, 9].map((s, i) => ({ text: 'S' + i, block: 0, start: s, end: s + 3, speech: s + 0.7,
|
||
...(withWords ? { words: W_START.slice(0, N_WORDS[i]).map((w, k) => [s + w, s + W_END[k]]) } : {}) })) } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
|
||
const wav = silentWav(12000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
|
||
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.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.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(4);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(11);
|
||
if (speed) await page.evaluate((v) => { const a = document.getElementById('mag-playlist-audio'); a.defaultPlaybackRate = v; a.playbackRate = v; }, speed);
|
||
}
|
||
const lit = (page) => page.evaluate(() => {
|
||
const idx = (sel, wsel, attr) => {
|
||
const line = document.querySelector(sel); if (!line) return null;
|
||
const ws = [...line.querySelectorAll(wsel)]; return [Number(line.dataset[attr]), ws.findIndex((w) => w.classList.contains('is-current'))];
|
||
};
|
||
return { t: document.getElementById('mag-playlist-audio').currentTime,
|
||
compact: idx('#mag-driving-lyrics .mag-driving-line.is-current', '.mag-dw', 'i'),
|
||
lyrics: idx('#mag-driving-full .mag-driving-fline.is-current', '.mag-fw', 'i'),
|
||
list: idx('#mag-playlist-transcript .is-current', '.mag-w', 'playlistSentence') };
|
||
});
|
||
const seekPaused = (page, t) => page.evaluate((x) => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = x; }, t);
|
||
|
||
test('word follows the measured times at several positions and seeks, in compact, lyrics and playlist views', async ({ page }) => {
|
||
await openWords(page);
|
||
await page.evaluate(() => window.magDrivingSetView('split'));
|
||
for (const t of [0.3, 0.9, 1.35, 2.5, 3.8, 4.3, 5.0, 3.0, 8.4, 11.2, 0.8, 7.0]) {
|
||
await seekPaused(page, t);
|
||
const want = expectedWord(t);
|
||
await expect.poll(async () => (await lit(page)).compact, { message: 'compact at ' + t }).toEqual(want);
|
||
await expect.poll(async () => (await lit(page)).list, { message: 'playlist at ' + t }).toEqual(want);
|
||
}
|
||
await page.evaluate(() => window.magDrivingSetView('text'));
|
||
for (const t of [1.0, 2.0, 4.0, 9.0, 6.0]) {
|
||
await seekPaused(page, t);
|
||
await expect.poll(async () => (await lit(page)).lyrics, { message: 'lyrics at ' + t }).toEqual(expectedWord(t));
|
||
}
|
||
});
|
||
|
||
test('measured words differ from the length estimate: the long-pause word stays lit until the next starts', async ({ page }) => {
|
||
await openWords(page);
|
||
await seekPaused(page, 1.52 + 0.0); // word 1 ended at 1.5; next starts at 1.6: word 1 stays lit
|
||
await expect.poll(async () => (await lit(page)).list).toEqual([0, 1]);
|
||
await seekPaused(page, 1.7);
|
||
await expect.poll(async () => (await lit(page)).list).toEqual([0, 2]);
|
||
});
|
||
|
||
for (const speed of [1.5, 2]) {
|
||
test(`playing at ${speed}x the lit word matches the measured table`, async ({ page }) => {
|
||
await openWords(page, { speed });
|
||
await page.evaluate(() => { const a = document.getElementById('mag-playlist-audio'); a.currentTime = 0.4; a.play(); });
|
||
const wall0 = Date.now(); let checked = 0;
|
||
while (Date.now() - wall0 < 5000) {
|
||
const s = await lit(page);
|
||
const near = [...EFF, ...W_START.flatMap((w) => [0, 3, 6, 9].map((b) => b + w))].some((e) => Math.abs(s.t - e) < 0.07 * speed + 0.04);
|
||
if (!near && s.compact) { expect(s.compact, 'at ' + s.t).toEqual(expectedWord(s.t)); checked++; }
|
||
if (s.t > 11.5) break;
|
||
await page.waitForTimeout(50);
|
||
}
|
||
expect(checked).toBeGreaterThan(4);
|
||
});
|
||
}
|
||
|
||
test('without measured words the highlight falls back to the interpolation and still moves', async ({ page }) => {
|
||
await openWords(page, { withWords: false });
|
||
await page.evaluate(() => window.magDrivingSetView('split'));
|
||
await seekPaused(page, 1.0);
|
||
await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(0);
|
||
await seekPaused(page, 2.9);
|
||
await expect.poll(async () => (await lit(page)).compact?.[1]).toBeGreaterThanOrEqual(2);
|
||
});
|
||
});
|
||
|
||
// ── Driving mode: text always fits its frame, and how much text is shown is a setting ──────────────────
|
||
test.describe('Driving mode text fit and text amount', () => {
|
||
const LONG = 'Gefährliches Halbwissen: Wenn Berater GPT-4 für komplexe Managementaufgaben einsetzen und dabei die Grenzen des Modells ignorieren, '
|
||
+ 'entstehen Entscheidungsvorlagen, die überzeugend klingen, aber auf frei erfundenen Zahlen beruhen, und niemand im Raum merkt es, bevor das Geld ausgegeben ist';
|
||
const TEXTS = ['Erster kurzer Satz.', LONG + '.', 'Dritter kurzer Satz.', 'Vierter kurzer Satz.'];
|
||
const AT = 5.9; // late inside sentence 1, so the spoken word is near the end of the long sentence
|
||
|
||
async function setup(page, prefs) {
|
||
if (prefs) await page.addInitScript((p) => { try { if (!localStorage.getItem('ttsvc_mag_driving_prefs')) localStorage.setItem('ttsvc_mag_driving_prefs', JSON.stringify(p)); } catch (_) {} }, prefs);
|
||
await mockBackend(page, { articleStatus: 'approved', hasAudio: true, textAmount: null });
|
||
const article = { ...ARTICLE_APPROVED, has_audio: true, audio_seconds: 15 };
|
||
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}/articles/article-001`, route => route.fulfill({ json: article }));
|
||
const blocks = [
|
||
{ id: 'b1', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: TEXTS.join(' '), reading_order: 0 },
|
||
{ id: 'b2', page: 3, bbox: [0, 0, 1, 1], type: 'body', text: 'Fünfter Satz im nächsten Absatz.', reading_order: 1 },
|
||
];
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/text*`, route => route.fulfill({ json: { article, blocks } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/timings`, route => route.fulfill({ json: { available: true, quality: 'exact',
|
||
sentences: [0, 3, 6, 9, 12].map((s, i) => ({ text: 'Satz ' + i, block: i < 4 ? 0 : 1, start: s, end: s + 3, speech: s + 0.7 })) } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001/audio`, route => {
|
||
const wav = silentWav(15000), range = /bytes=(\d+)-(\d*)/.exec(route.request().headers().range || '');
|
||
if (!range) return route.fulfill({ body: wav, contentType: 'audio/wav', headers: { 'Accept-Ranges': 'bytes' } });
|
||
const from = Number(range[1]), to = range[2] ? Number(range[2]) : wav.length - 1;
|
||
return route.fulfill({ status: 206, body: wav.subarray(from, to + 1), contentType: 'audio/wav',
|
||
headers: { 'Accept-Ranges': 'bytes', 'Content-Range': `bytes ${from}-${to}/${wav.length}` } });
|
||
});
|
||
}
|
||
async function open(page, { rootFont } = {}) {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||
if (rootFont) await page.addStyleTag({ content: `html { font-size: ${rootFont} !important; }` });
|
||
await navTo(page, 's-magazine');
|
||
const card = page.locator('#mag-library-list .mag-card').first();
|
||
await page.locator('#mag-library-list .mag-card:visible, button:has-text("Playlist"):visible').first().waitFor();
|
||
if (await card.isVisible()) await card.click();
|
||
await page.getByRole('button', { name: 'Playlist' }).click();
|
||
await page.locator('.mag-playlist-track-play').first().click();
|
||
await expect(page.locator('#mag-playlist-transcript [data-playlist-sentence]')).toHaveCount(5);
|
||
await page.evaluate(() => window.magOpenDriving());
|
||
await expect(page.locator('#mag-driving')).toBeVisible();
|
||
await expect.poll(() => page.evaluate(() => document.getElementById('mag-playlist-audio').duration)).toBeGreaterThan(14);
|
||
await page.evaluate((t) => { const a = document.getElementById('mag-playlist-audio'); a.pause(); a.currentTime = t; }, AT);
|
||
await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1');
|
||
}
|
||
const shown = (page) => page.evaluate(() => [...document.querySelectorAll('#mag-driving-lyrics .mag-driving-line:not([hidden])')].map((e) => Number(e.dataset.i)));
|
||
const setAmount = async (page, a) => {
|
||
await page.evaluate((x) => window.magDrivingSetAmount(x), a);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', a);
|
||
};
|
||
|
||
async function fitReport(page) {
|
||
return page.evaluate(() => {
|
||
const r = (el) => el.getBoundingClientRect();
|
||
const box = document.getElementById('mag-driving-lyrics'), b = r(box);
|
||
const dock = r(document.querySelector('.mag-driving-dock')), top = r(document.querySelector('.mag-driving-top'));
|
||
const stage = r(document.querySelector('.mag-driving-stagewrap'));
|
||
const w = box.querySelector('.mag-dw.is-current'), wr = w && r(w);
|
||
const overflows = box.scrollHeight > box.clientHeight + 1;
|
||
const side = innerWidth > innerHeight && innerHeight <= 500;
|
||
return {
|
||
vh: innerHeight, vw: innerWidth, box: { t: b.top, b: b.bottom, l: b.left, r: b.right, h: b.height }, dock: { t: dock.top }, top: { b: top.bottom },
|
||
overflows, fits: !overflows,
|
||
wordInside: !!wr && wr.top >= b.top - 1 && wr.bottom <= b.bottom + 1,
|
||
scrollsHorizontally: box.scrollWidth > box.clientWidth + 1,
|
||
belowPicture: side ? b.left >= stage.right - 0.5 : b.top >= stage.bottom - 0.5,
|
||
contentBelowDock: r(document.querySelector('.mag-driving-content')).bottom <= dock.top + 0.5,
|
||
pageScrollX: document.documentElement.scrollWidth,
|
||
fontSize: parseFloat(getComputedStyle(box.querySelector('.mag-driving-line.is-current')).fontSize),
|
||
root: parseFloat(getComputedStyle(document.documentElement).fontSize),
|
||
};
|
||
});
|
||
}
|
||
|
||
for (const rootFont of [undefined, '200%']) {
|
||
for (const [w, h] of [[320, 568], [375, 667], [414, 896], [667, 375]]) {
|
||
for (const amount of ['sentence', 'context', 'paragraph']) {
|
||
test(`${w}x${h}${rootFont ? ', root font 200%' : ''}, ${amount}: a 300+ character sentence stays inside its frame`, async ({ page }) => {
|
||
await page.setViewportSize({ width: w, height: h });
|
||
await setup(page);
|
||
await open(page, { rootFont });
|
||
await setAmount(page, amount);
|
||
await expect.poll(async () => (await shown(page)).length).toBeGreaterThan(0);
|
||
await page.waitForTimeout(500); // fit + follow-scroll settle
|
||
const m = await fitReport(page);
|
||
expect(m.box.h, 'the frame has room').toBeGreaterThan(40);
|
||
expect(m.box.t).toBeGreaterThanOrEqual(m.top.b - 0.5); // below the top bar
|
||
expect(m.box.b).toBeLessThanOrEqual(m.dock.t + 0.5); // above the dock
|
||
expect(m.box.b).toBeLessThanOrEqual(m.vh + 0.5);
|
||
expect(m.box.r).toBeLessThanOrEqual(m.vw + 0.5);
|
||
expect(m.belowPicture, 'the text does not overlap the picture').toBe(true);
|
||
expect(m.contentBelowDock).toBe(true);
|
||
expect(m.scrollsHorizontally).toBe(false);
|
||
expect(m.pageScrollX).toBeLessThanOrEqual(m.vw);
|
||
// never clipped silently: it fits, or the frame scrolls and the spoken word is inside the visible frame
|
||
expect(m.fits || m.wordInside, JSON.stringify(m)).toBe(true);
|
||
// font is in rem, so it follows the user's text size and stays within 1rem-1.5rem
|
||
expect(m.fontSize).toBeGreaterThanOrEqual(m.root * 0.99);
|
||
expect(m.fontSize).toBeLessThanOrEqual(m.root * 1.5 + 0.5);
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
test('a sentence that fits is shown at the largest size, a long one shrinks first', async ({ page }) => {
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await setup(page);
|
||
await open(page);
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 1; });
|
||
await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('0');
|
||
await page.waitForTimeout(300);
|
||
const short = await fitReport(page);
|
||
await page.evaluate((t) => { document.getElementById('mag-playlist-audio').currentTime = t; }, AT);
|
||
await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1');
|
||
await page.waitForTimeout(300);
|
||
const long = await fitReport(page);
|
||
expect(short.fontSize).toBeCloseTo(short.root * 1.5, 0);
|
||
expect(long.fontSize).toBeLessThan(short.fontSize);
|
||
});
|
||
|
||
test('each text amount renders the expected lines; the default is the current sentence', async ({ page }) => {
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await setup(page);
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'sentence');
|
||
expect(await shown(page)).toEqual([1]);
|
||
await setAmount(page, 'context');
|
||
expect(await shown(page)).toEqual([0, 1, 2]);
|
||
await expect(page.locator('#mag-driving-lyrics .is-prev')).toHaveCount(1);
|
||
await expect(page.locator('#mag-driving-lyrics .is-next')).toHaveCount(1);
|
||
await setAmount(page, 'paragraph');
|
||
expect(await shown(page)).toEqual([0, 1, 2, 3]); // the whole first paragraph, not the second one
|
||
await expect(page.locator('#mag-driving-lyrics .mag-driving-line.is-current')).toHaveCount(1);
|
||
expect(await page.locator('#mag-driving-lyrics .mag-dw.is-current').count()).toBe(1);
|
||
// the next paragraph starts a new window
|
||
await page.evaluate(() => { document.getElementById('mag-playlist-audio').currentTime = 13; });
|
||
await expect.poll(() => shown(page)).toEqual([4]);
|
||
// text is read out by the audio, not announced sentence by sentence
|
||
expect(await page.locator('#mag-driving-lyrics').getAttribute('aria-live')).toBe('off');
|
||
});
|
||
|
||
test('tapping a neighbouring line seeks to it in context and paragraph amounts', async ({ page }) => {
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await setup(page);
|
||
await open(page);
|
||
for (const amount of ['context', 'paragraph']) {
|
||
await page.evaluate((t) => { document.getElementById('mag-playlist-audio').currentTime = t; }, AT);
|
||
await setAmount(page, amount);
|
||
await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('1');
|
||
await page.locator('#mag-driving-lyrics .mag-driving-line.is-next').first().click();
|
||
await expect.poll(() => page.evaluate(() => document.querySelector('#mag-driving-lyrics .mag-driving-line.is-current')?.dataset.i)).toBe('2');
|
||
}
|
||
});
|
||
|
||
test('the amount is a radio group in the menu, persists across reloads and the menu fits the screen', async ({ page }) => {
|
||
await page.setViewportSize({ width: 320, height: 568 });
|
||
await setup(page);
|
||
await open(page);
|
||
await page.locator('#mag-driving-menubtn').click();
|
||
const group = page.locator('#mag-driving-menu [role="radiogroup"][aria-labelledby="mag-menu-sec-amount"]');
|
||
await expect(group.locator('[role="radio"]')).toHaveCount(3);
|
||
await expect(group.locator('[data-amount="sentence"]')).toHaveAttribute('aria-checked', 'true');
|
||
await group.locator('[data-amount="paragraph"]').click();
|
||
await expect(group.locator('[data-amount="paragraph"]')).toHaveAttribute('aria-checked', 'true');
|
||
await expect(group.locator('[data-amount="sentence"]')).toHaveAttribute('aria-checked', 'false');
|
||
await expect(page.locator('#mag-driving-menu')).toBeVisible(); // stays open so the effect is seen
|
||
const fit = await page.evaluate(() => {
|
||
const m = document.getElementById('mag-driving-menu').getBoundingClientRect(), dock = document.querySelector('.mag-driving-dock').getBoundingClientRect();
|
||
return { l: m.left, r: m.right, t: m.top, b: m.bottom, vw: innerWidth, dockTop: dock.top, minH: Math.min(...[...document.querySelectorAll('#mag-driving-menu button')].map((x) => x.getBoundingClientRect().height)) };
|
||
});
|
||
expect(fit.l).toBeGreaterThanOrEqual(0); expect(fit.r).toBeLessThanOrEqual(fit.vw + 0.5);
|
||
expect(fit.t).toBeGreaterThanOrEqual(0); expect(fit.b).toBeLessThanOrEqual(fit.dockTop + 0.5);
|
||
expect(fit.minH).toBeGreaterThanOrEqual(43.5);
|
||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('ttsvc_mag_driving_prefs')).amount)).toBe('paragraph');
|
||
// keyboard: arrows move over the new radios too
|
||
await group.locator('[data-amount="paragraph"]').focus();
|
||
await page.keyboard.press('ArrowRight');
|
||
await expect(page.locator('#mag-driving-menu [data-theme-choice="light"], #mag-driving-menu #mag-driving-text-toggle').first()).toBeDefined();
|
||
await page.keyboard.press('Escape');
|
||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'paragraph');
|
||
expect(await shown(page)).toEqual([0, 1, 2, 3]);
|
||
});
|
||
|
||
test('a broken or unknown stored amount falls back to the current sentence', async ({ page }) => {
|
||
await setup(page, { amount: 'everything', view: 'split' });
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving')).toHaveAttribute('data-amount', 'sentence');
|
||
expect(await shown(page)).toEqual([1]);
|
||
});
|
||
|
||
test('the Text view stays the whole text whatever the amount is', async ({ page }) => {
|
||
await setup(page, { view: 'text', amount: 'sentence' });
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await open(page);
|
||
await expect(page.locator('#mag-driving-full .mag-driving-fline')).toHaveCount(5);
|
||
await expect(page.locator('#mag-driving-lyrics')).toBeHidden();
|
||
});
|
||
|
||
for (const theme of ['light', 'dark']) {
|
||
test(`${theme} theme: paragraph text and the current sentence keep 4.5:1 contrast`, async ({ page }) => {
|
||
await page.setViewportSize({ width: 414, height: 896 });
|
||
await setup(page, { theme, amount: 'paragraph' });
|
||
await open(page);
|
||
const res = await page.evaluate(() => {
|
||
const rgb = (c) => (c.match(/[\d.]+/g) || []).slice(0, 3).map(Number);
|
||
const lum = ([r, g, b]) => { const f = (v) => { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); };
|
||
const ratio = (a, b) => { const x = lum(rgb(a)), y = lum(rgb(b)); return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05); };
|
||
const o = document.getElementById('mag-driving'), cs = getComputedStyle(o);
|
||
const out = {};
|
||
for (const [name, sel] of [['dim', '.is-next'], ['current', '.is-current']]) {
|
||
const el = document.querySelector('#mag-driving-lyrics .mag-driving-line' + sel), c = getComputedStyle(el);
|
||
out[name] = { opacity: c.opacity, worst: Math.min(ratio(c.color, cs.getPropertyValue('--surface').trim() ? getComputedStyle(o).getPropertyValue('--surface').trim().replace(/^#(..)(..)(..)$/, (m, a, b2, d) => `rgb(${parseInt(a, 16)},${parseInt(b2, 16)},${parseInt(d, 16)})`) : cs.backgroundColor), ratio(c.color, cs.backgroundColor)) };
|
||
}
|
||
return out;
|
||
});
|
||
expect(res.dim.opacity).toBe('1');
|
||
expect(res.dim.worst).toBeGreaterThanOrEqual(4.5);
|
||
expect(res.current.worst).toBeGreaterThanOrEqual(4.5);
|
||
});
|
||
}
|
||
|
||
test('reduced motion: no animation on the current line while following the spoken word', async ({ page }) => {
|
||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||
await page.setViewportSize({ width: 320, height: 568 });
|
||
await setup(page, { amount: 'sentence' });
|
||
await open(page, { rootFont: '200%' });
|
||
expect(await page.locator('#mag-driving-lyrics .mag-driving-line.is-current').evaluate((e) => getComputedStyle(e).animationName)).toBe('none');
|
||
const m = await fitReport(page);
|
||
expect(m.fits || m.wordInside).toBe(true);
|
||
});
|
||
});
|
||
|
||
test.describe('Magazine: approving a multi-page article page by page', () => {
|
||
test('keeps every block of the article and creates no Page N stand-in', async ({ page }) => {
|
||
await mockBackend(page);
|
||
const mk = (id, pg, y) => ({ id, page: pg, bbox: [50, y, 200, 40], type: 'body', text: 'Text ' + id + '. More words here.', reading_order: 0, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' });
|
||
const blocks = [mk('p3a', 3, 60), mk('p3b', 3, 120), mk('p4a', 4, 60), mk('p4b', 4, 120), mk('p5a', 5, 60)];
|
||
const pageOf = (ids) => [...new Set(ids.map((id) => blocks.find((b) => b.id === id).page))].sort();
|
||
let art = { id: 'article-001', title: 'Long Read', author: '', blocks: blocks.map((b) => b.id), images: [], status: 'detected', confidence: 0.9, section: '', pages: [3, 4, 5], has_audio: false };
|
||
const patches = []; let posts = 0;
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 5, pages: [3, 4, 5].map((p) => ({ page: p, width: 595, height: 842 })), blocks } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, r => {
|
||
if (r.request().method() === 'POST') { posts++; return r.fulfill({ json: { article: { id: 'article-page-x', title: 'Page X', pages: [9], blocks: [], images: [], status: 'detected' } } }); }
|
||
return r.fulfill({ json: { articles: [art], progress: {}, default_voice: '', podcast: { hosts: [] } } });
|
||
});
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-001`, r => {
|
||
if (r.request().method() === 'PATCH') {
|
||
const body = r.request().postDataJSON(); patches.push(body);
|
||
art = { ...art, ...body, pages: pageOf(body.blocks) };
|
||
return r.fulfill({ json: art });
|
||
}
|
||
return r.fulfill({ json: art });
|
||
});
|
||
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/ }).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');
|
||
await page.getByRole('button', { name: 'Approve & Next', exact: true }).click();
|
||
await expect(page.getByRole('group', { name: 'Magazine page' })).toContainText('Page 5');
|
||
expect(patches).toHaveLength(2);
|
||
for (const p of patches) expect([...p.blocks].sort()).toEqual(blocks.map((b) => b.id).sort());
|
||
expect(posts).toBe(0);
|
||
});
|
||
});
|
||
|
||
|
||
test.describe('Magazine: untitled entries (labels, merge suggestion, titles, stale audio)', () => {
|
||
const mk = (id, pg, y) => ({ id, page: pg, bbox: [50, y, 200, 40], type: 'body', text: 'Text ' + id + '. More words here.', reading_order: 0, confidence: 0.9, font_size: 10, font_name: '', bold: false, column: 0, source: 'native' });
|
||
const BLOCKS = [mk('t1', 3, 60), mk('c1', 4, 60)];
|
||
const REAL = { id: 'article-001', title: 'Real Story', display_title: 'Real Story', title_source: 'title', page_label: 'Seite 3', author: '', blocks: ['t1'], images: [], status: 'approved', confidence: 0.9, section: '', pages: [3], has_audio: true, audio_seconds: 5, audio_stale: false, untitled_kind: null, merge_suggestion: null };
|
||
const STAND = { id: 'article-002', title: 'Page 4', display_title: 'Weiter im Text', title_source: 'suggested', suggested_title: 'Weiter im Text', page_label: 'Seite 4', author: '', blocks: ['c1'], images: [], status: 'detected', confidence: 0.5, section: '', pages: [4], has_audio: false, audio_stale: false, untitled_kind: 'continuation',
|
||
merge_suggestion: { into: 'article-001', into_title: 'Real Story', level: 'high', confidence: 0.91, evidence: ['starts in lower case', 'previous article ends mid-sentence'] } };
|
||
|
||
async function setup(page, { articles = [REAL, STAND], onMerge, onAccept } = {}) {
|
||
await mockBackend(page);
|
||
let list = articles.map((a) => ({ ...a }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/layout`, r => r.fulfill({ json: { ...LAYOUT, page_count: 4, pages: [3, 4].map((p) => ({ page: p, width: 595, height: 842 })), blocks: BLOCKS } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles`, r => r.fulfill({ json: { articles: list, progress: {}, default_voice: '', podcast: { hosts: [] } } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*`, r => r.fulfill({ json: list[0] }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/merge-into-previous`, r => onMerge ? onMerge(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }));
|
||
await page.route(`**/api/magazine/${DOC_ID}/articles/article-00*/accept-title`, r => onAccept ? onAccept(r, (n) => { list = n; }) : r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }));
|
||
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();
|
||
}
|
||
const reviewStandIn = async (page) => {
|
||
await page.locator('.mag-toc-row').filter({ hasText: 'Weiter im Text' }).getByRole('button', { name: 'Review', exact: true }).click();
|
||
};
|
||
|
||
test('the article index shows display titles with page labels and the stale hint', async ({ page }) => {
|
||
await setup(page, { articles: [{ ...REAL, audio_stale: true }, STAND] });
|
||
const rows = page.locator('.mag-toc-row');
|
||
await expect(rows.nth(1).locator('.mag-toc-title')).toHaveText('Weiter im Text');
|
||
await expect(rows.nth(1).locator('.mag-page-label')).toHaveText('Seite 4');
|
||
await expect(rows.nth(1).locator('.mag-kind-badge')).toBeVisible();
|
||
await expect(rows.nth(0).locator('.mag-stale-badge')).toContainText('Audio is out of date: re-render');
|
||
await expect(rows.nth(1).locator('.mag-stale-badge')).toHaveCount(0);
|
||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth)).toBe(true);
|
||
// the play button of a row with audio keeps its hit area
|
||
const b = await rows.nth(0).locator('.mag-toc-play').boundingBox();
|
||
expect(b.width).toBeGreaterThanOrEqual(30);
|
||
});
|
||
|
||
test('review explains the merge suggestion and posts the proposed target', async ({ page }) => {
|
||
let posted = null;
|
||
await setup(page, { onMerge: (r) => { posted = r.request().postDataJSON(); return r.fulfill({ json: { ok: true, undo: 'tok1', article: { ...REAL, audio_stale: true } } }); } });
|
||
await reviewStandIn(page);
|
||
const box = page.locator('.mag-suggest');
|
||
await expect(box).toContainText('Real Story');
|
||
await expect(box).toContainText('high confidence');
|
||
await box.locator('summary').click();
|
||
await expect(box.locator('li')).toHaveCount(2);
|
||
const btn = page.getByRole('button', { name: 'Merge into previous article' });
|
||
expect((await btn.boundingBox()).height).toBeGreaterThanOrEqual(44);
|
||
await btn.click();
|
||
await expect.poll(() => posted).toEqual({ into: 'article-001' });
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Undo');
|
||
});
|
||
|
||
test('a 409 on merge refreshes the suggestion and merges nothing', async ({ page }) => {
|
||
await setup(page, { onMerge: (r) => r.fulfill({ status: 409, json: { detail: 'The proposal changed: it now merges into article-009' } }) });
|
||
await reviewStandIn(page);
|
||
await page.getByRole('button', { name: 'Merge into previous article' }).click();
|
||
await expect(page.locator('#toast')).toContainText('proposal changed');
|
||
await expect(page.getByRole('button', { name: 'Merge into previous article' })).toBeEnabled();
|
||
});
|
||
|
||
test('Use suggested title posts accept-title and offers undo', async ({ page }) => {
|
||
let hit = 0;
|
||
await setup(page, { onAccept: (r) => { hit++; return r.fulfill({ json: { ok: true, undo: 'tok2', article: { ...STAND, title: 'Weiter im Text' } } }); } });
|
||
await reviewStandIn(page);
|
||
await page.getByRole('button', { name: 'Use suggested title' }).click();
|
||
await expect.poll(() => hit).toBe(1);
|
||
await expect(page.locator('#mag-undo-bar')).toContainText('Title set to');
|
||
});
|
||
|
||
test('review shows the out-of-date audio hint', async ({ page }) => {
|
||
await setup(page, { articles: [{ ...REAL, audio_stale: true, merge_suggestion: null }, STAND] });
|
||
await page.locator('.mag-toc-row').filter({ hasText: 'Real Story' }).getByRole('button', { name: 'Review', exact: true }).click();
|
||
await expect(page.locator('.mag-suggest-stale')).toContainText('Audio is out of date: re-render');
|
||
});
|
||
});
|