feat(magazine): show page labels, display titles and merge suggestions

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-10-03 19:22:55 +02:00
parent 42bb556db9
commit 03ebd9ba35
5 changed files with 234 additions and 12 deletions

View File

@ -7,6 +7,7 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
## [Unreleased]
### Added
- **Magazine UI shows page labels, display titles and merge suggestions.** The article index (list, playlist, driving-mode Index sheet and driving header) shows `display_title` (falls back to `title`) with the `page_label` ("Seite 33") in smaller muted text, plus small kind badges (advert, photo, contents, imprint, continuation) and an "Audio is out of date: re-render" badge when `audio_stale` is set. Review shows a suggestion box: the proposed merge target with confidence and a collapsible plain-language "Why do we think so?" list, **Merge into previous article** (`POST .../merge-into-previous {into}`; a 409 "proposal changed" refreshes the suggestion; undoable) and **Use suggested title** (`POST .../accept-title`; undoable). Controls are labelled, keyboard operable and 44 px high. Tests: `tests/magazine.spec.js` ("untitled entries", driving index).
- **Untitled magazine entries ("Page 33") are explained, merged on request and given readable titles** (`core/magazine_untitled.py`, `scripts/magazine_untitled.py`). Root cause: the review screen creates a `Page N` stand-in for any page no article covers, and *Approve* without a toggled box saved only the visible page's regions, which cut multi-page features down to one page. On *c't KI Wissen 2026* this left 59 stand-ins, most of them continuation pages. A continuation detector scores each untitled entry against the article before it (lower-case/mid-sentence start, hyphenated break, the narration fusion rule, open ending, adjacent page, column width; a signed-off previous article or the page's own headline counts against). An optional local-LLM check runs only for ambiguous scores. Each proposal comes with a confidence and the evidence. Records whose title repeats the headline of the article they continue are proposed as well. Titles: a deterministic fallback (*Anzeige*, *Bildseite*, *Inhalt*, *Impressum*, *Editorial*, *Textfragment*, the page headline, or the first sentence cut to 8 words), plus optional validated and cached LLM titles in the magazine's language (`magazine_llm_titles`, `--no-llm`). Nothing changes silently. `GET /articles` gains derived `display_title`, `title_source`, `suggested_title`, `page_label`, `untitled_kind`, `merge_suggestion` and `audio_stale`, and `GET /untitled` plus `quality/report → untitled_articles` list everything. Changes happen only through the undoable `POST /articles/{id}/merge-into-previous` and `POST /articles/{id}/accept-title` (never over a real title), `POST /titles/suggest` (suggestion cache only), and the CLI's `--apply` (backup first). A merge keeps reading order, marks the article `audio_stale` (a fresh render clears it) and deletes no audio. See architecture/magazine-ingestion.md, "Untitled entries".
- **Distinct, deterministic face profile per character** (`core/face_profile.py`). The cast of one book looked like one family because most sheets name no facial feature and the image model then draws its default face. Each character now gets a compact `face:` clause (face shape, jaw, nose, eyes, brows, mouth, cheekbones, skin, hair colour and style, facial hair, age signs, a mark) in the Subject of every character prompt: traits the sheet states (German or English) are kept, the rest comes from species/age/gender/culture-filtered option pools, chosen so that the characters of a book are spread apart (greedy max-distance in creation order; adding characters never changes an existing profile; relatives named in `relationships` share a few traits). Owner override: a `visual_face` text on the sheet. On the real books 02/03 the mean pairwise face-descriptor distance goes from 0.09/0.04 to 0.91 (44/68 characters had no face trait at all). See `architecture/character-visual-identity.md`.
- **Dry run for faces:** `POST /api/character-face-preview {book}` lists every character's face profile (what came from the sheet, what was chosen) with a similarity matrix and before/after score; `POST /api/character-prompt-preview` also returns `face_clause`. Nothing is generated or saved.

View File

@ -353,6 +353,12 @@ turned into a podcast discussion between two or three voices you pick.
switch which one the drawing and editing tools apply to. Confirm the
boundaries, correct the title, then **Approve**.
**Approve** on one page of a multi-page article keeps the blocks of all its pages: only boxes you untick or add change what the article contains.
**Untitled entries ("Page 33") in review.** An entry without a real title shows its page label (for example "Seite 33") and a suggestion box:
- *Merge into previous article* appears when the page looks like the continuation of the article before it. Open **Why do we think so?** to read the reasons in plain words and the confidence. Press the button to merge. Use **Undo** in the bar above the page to take it back. If the suggestion changed in the meantime nothing is merged and the suggestion is refreshed.
- *Use suggested title* writes the proposed title into the entry (never over a title you set). Undo is offered.
- After a merge the article's audio is marked *Audio is out of date: re-render*; render it again from the contents list. No audio file is deleted.
Page labels and these badges also appear in the contents list, the playlist and the driving-mode index. Mobile: buttons are full width and 44 px high; keyboard: Tab to the buttons and the details summary, Enter or Space to use them. Privacy: nothing leaves the device beyond the local server.
Optionally, click **"Describe"** under a figure, chart, table or code block
to have the configured vision engine generate a one- or two-sentence
description for listeners who cannot see the image — e.g. what a chart

View File

@ -895,6 +895,23 @@ async function magReviewPage(pageNumber) {
magReviewGoMagazinePage(pageNumber);
}
// An entry without a real title ("Page 33") shows the server's derived title; the label says where it is.
function magDisplayTitle(a) { return (a && (a.display_title || a.title)) || 'Untitled'; }
function magPageLabelHtml(a, cls) {
return a && a.page_label ? '<span class="' + cls + '">' + esc(a.page_label) + '</span>' : '';
}
const MAG_KIND_LABELS = { advert: 'Advert', photo: 'Photo page', content: 'Contents', imprint: 'Imprint', continuation: 'Continuation' };
function magKindBadgeHtml(a) {
const k = MAG_KIND_LABELS[a && a.untitled_kind];
return k ? '<span class="mag-pill mag-kind-badge" title="Detected kind of this untitled entry">' + esc(k) + '</span>' : '';
}
function magStaleHtml(a) {
return a && a.audio_stale
? '<span class="mag-pill warn mag-stale-badge" title="Pages were merged into this article after its audio was made">' +
'<span class="mdi mdi-alert-outline" aria-hidden="true"></span> Audio is out of date: re-render</span>'
: '';
}
function renderContents() {
const host = $('mag-contents');
if (!host) return;
@ -969,7 +986,7 @@ function renderContents() {
'onblur="magContentsCommitTitle(this, \'' + a.id + '\')" ' +
'onkeydown="if(event.key===\'Enter\'){event.preventDefault();this.blur();}" ' +
'onclick="event.stopPropagation()" title="Click to edit the title">' +
esc(a.title || 'Untitled') + '</span>';
esc(magDisplayTitle(a)) + '</span>' + magPageLabelHtml(a, 'mag-page-label');
let html = '';
bySection.forEach((arts, section) => {
@ -988,6 +1005,7 @@ function renderContents() {
'<div class="mag-toc-main">' +
titleHtml(a, 'mag-toc-title') +
(a.author ? '<span class="mag-toc-by">by ' + esc(a.author) + '</span>' : '') +
magKindBadgeHtml(a) + magStaleHtml(a) +
'</div>' +
'<div class="mag-toc-meta">' +
'<span>' + fmtPages(a.pages) + '</span>' +
@ -1375,7 +1393,7 @@ async function magContentsCommitTitle(el, id) {
const a = state.articles.find((x) => x.id === id);
if (!a) return;
const value = (el.textContent || '').replace(/\s+/g, ' ').trim();
if (!value || value === (a.title || '')) { el.textContent = a.title || 'Untitled'; return; }
if (!value || value === (a.title || '') || value === magDisplayTitle(a)) { el.textContent = magDisplayTitle(a); return; }
const previous = a.title;
a.title = value;
try {
@ -1385,7 +1403,7 @@ async function magContentsCommitTitle(el, id) {
});
} catch (e) {
a.title = previous;
el.textContent = previous || 'Untitled';
el.textContent = magDisplayTitle(a);
say('Could not save the title: ' + e.message, 'err');
}
}
@ -2066,6 +2084,7 @@ function renderReview() {
'<div id="mag-spell-panel" class="mag-spell-panel" ' + (state.spellOpen ? '' : 'hidden') + '></div>' +
'<div id="mag-issues-panel" class="mag-issues-panel" role="region" aria-label="What is wrong on this page" aria-live="polite"></div>';
html += magReviewSuggestHtml(a);
html += '<div class="mag-review-fields">';
html += '<div class="mag-review-field-row">' +
'<label>Title<span class="mag-review-field-control"><input id="mag-rev-title" type="text" value="' + esc(a.title) + '"><button type="button" class="btn-sm" onclick="magReviewToggleDraw(\'title\')"><span class="mdi mdi-vector-square-edit"></span> Pick from page</button></span></label>' +
@ -3956,6 +3975,81 @@ async function magRefineAllFigures() {
return magRunFigureRefine(null);
}
function magReviewSuggestHtml(a) {
const m = a.merge_suggestion;
const placeholderTitle = a.title_source === 'suggested' && a.suggested_title;
if (!m && !placeholderTitle && !a.audio_stale && !a.untitled_kind) return '';
let h = '<section class="mag-suggest" aria-label="Suggestions for this entry">';
h += '<p class="mag-suggest-head">' + esc(a.page_label || '') + (a.untitled_kind ? ' ' + magKindBadgeHtml(a) : '') + '</p>';
if (a.audio_stale) h += '<p class="mag-suggest-stale" role="status">' + magStaleHtml(a) + '</p>';
if (m) {
const lvl = m.level === 'high' ? 'high' : 'medium';
const pct = Math.round(Number(m.confidence || 0) * 100);
h += '<p>This looks like the continuation of <strong>' + esc(m.into_title || 'the article before it') + '</strong> (' +
lvl + ' confidence, ' + pct + '%).</p>' +
'<details class="mag-suggest-why"><summary>Why do we think so?</summary><ul>' +
(m.evidence || []).map((e) => '<li>' + esc(e) + '</li>').join('') + '</ul></details>' +
'<button type="button" class="btn-sm mag-suggest-btn" id="mag-merge-prev-btn" onclick="magMergeIntoPrevious()">' +
'<span class="mdi mdi-call-merge" aria-hidden="true"></span> Merge into previous article</button> ';
}
if (placeholderTitle) {
h += '<button type="button" class="btn-sm mag-suggest-btn" id="mag-accept-title-btn" onclick="magAcceptSuggestedTitle()">' +
'<span class="mdi mdi-check" aria-hidden="true"></span> Use suggested title</button> ' +
'<span class="muted">&ldquo;' + esc(a.suggested_title) + '&rdquo;</span>';
}
return h + '</section>';
}
async function magReselectArticle(id) {
await magRefreshArticles();
const next = (state.articles || []).find((x) => x.id === id);
if (next) { state.article = next; _magReviewChecked = null; }
return next;
}
async function magMergeIntoPrevious() {
const a = state.article;
if (!a || !a.merge_suggestion) return;
const btn = $('mag-merge-prev-btn');
if (btn) btn.disabled = true;
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/merge-into-previous', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ into: a.merge_suggestion.into }),
});
await magRefreshArticles();
const target = (state.articles || []).find((x) => x.id === (r.article && r.article.id)) || (state.articles || [])[0];
if (target) await magReview(target.id); else magShow('contents');
magOfferUndo(r.undo, 'Merged ' + (a.page_label || 'the page') + ' into ' + magDisplayTitle(target || {}));
say('Merged. Audio of the target is out of date: re-render it.', 'ok');
} catch (e) {
if (btn) btn.disabled = false;
if (/proposal changed|No merge is proposed/i.test(String(e.message))) {
await magReselectArticle(a.id);
renderReview();
return say('The proposal changed, so nothing was merged. The suggestion is refreshed.', 'err');
}
say('Could not merge: ' + e.message, 'err');
}
}
async function magAcceptSuggestedTitle() {
const a = state.article;
if (!a) return;
try {
const r = await api('/api/magazine/' + state.doc.id + '/articles/' + a.id + '/accept-title', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
});
await magReselectArticle(a.id);
renderReview();
magOfferUndo(r.undo, 'Title set to "' + ((r.article && r.article.title) || '') + '"');
} catch (e) {
await magReselectArticle(a.id);
renderReview();
say('Could not use the suggested title: ' + e.message, 'err');
}
}
async function magApprove() {
magOfferUndo(null); // this change records no undo point, so an older Undo no longer applies
const a = state.article;
@ -5082,12 +5176,12 @@ function renderPlaylistTracks() {
const duration = a.audio_seconds || progress.duration || a.estimated_seconds;
return '<li class="mag-playlist-track is-' + status + (active ? ' is-playing' : '') + '">' +
'<button type="button" class="mag-playlist-track-play" onclick="magPlaylistPlay(\'' + a.id + '\')" ' +
'aria-label="Play ' + esc(a.title || 'article') + '"><span class="mdi ' +
'aria-label="Play ' + esc(magDisplayTitle(a)) + '"><span class="mdi ' +
(active ? 'mdi-volume-high' : completed ? 'mdi-replay' : 'mdi-play') + '" aria-hidden="true"></span></button>' +
'<span class="mag-playlist-track-page">' + esc(fmtPages(a.pages)) + '</span>' +
'<span class="mag-playlist-track-main">' +
'<span class="mag-playlist-track-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="mag-playlist-track-sub muted">by ' + esc(a.author) + '</span>' : '') +
'<span class="mag-playlist-track-title">' + esc(magDisplayTitle(a)) + '</span>' +
((a.page_label || a.author) ? '<span class="mag-playlist-track-sub muted">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') +
'</span>' +
'<span class="mag-playlist-track-duration muted">' + fmtDuration(duration) + '</span>' +
'<span class="mag-playlist-track-status" title="' + statusLabel + '"><span class="mdi ' +
@ -5430,7 +5524,7 @@ function magPlaylistPlay(articleId, options) {
}
const titleEl = $('mag-playlist-title');
const subEl = $('mag-playlist-sub');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
if (titleEl) titleEl.textContent = magDisplayTitle(a);
if (subEl) subEl.textContent = (a.author ? 'by ' + a.author + ' · ' : '') + fmtPages(a.pages);
const cover = $('mag-playlist-cover');
if (cover) {
@ -5870,9 +5964,9 @@ function renderDriving() {
const a = state.article;
if (!a) return;
const titleEl = $('mag-driving-title');
if (titleEl) titleEl.textContent = a.title || 'Untitled';
if (titleEl) titleEl.textContent = magDisplayTitle(a);
const byEl = $('mag-driving-by');
if (byEl) byEl.textContent = a.author ? 'By ' + a.author : '';
if (byEl) byEl.textContent = [a.page_label, a.author ? 'By ' + a.author : ''].filter(Boolean).join(' · ');
syncDrivingPlayButton();
const qEl = $('mag-driving-queue');
if (qEl) {
@ -6464,8 +6558,8 @@ function magDrivingIndexRender() {
'<button type="button" class="ix-play" data-id="' + id + '"' + dis + ' aria-label="' + esc(magIndexPlayLabel(ls, a)) + '">' +
'<span class="mdi ' + magIndexPlayIcon(ls) + '" aria-hidden="true"></span></button>' +
'<button type="button" class="ix-row" data-id="' + id + '"' + dis + '>' +
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(a.title || 'Untitled') + '</span>' +
(a.author ? '<span class="ix-author">by ' + esc(a.author) + '</span>' : '') + '</span>' +
'<span class="mdi-num">' + (n + 1) + '</span><span class="ix-main"><span class="ix-title">' + esc(magDisplayTitle(a)) + '</span>' +
((a.page_label || a.author) ? '<span class="ix-author ix-page">' + esc([a.page_label, a.author ? 'by ' + a.author : ''].filter(Boolean).join(' · ')) + '</span>' : '') + '</span>' +
'<span class="ix-dur">' + esc(dur) + '</span>' +
'<span class="ix-status" role="img" aria-label="' + ls.label + '"><span class="mdi ' + ls.statusIcon + '" aria-hidden="true"></span></span></button></li>';
}).join('');
@ -6473,7 +6567,7 @@ function magDrivingIndexRender() {
}
function magIndexPlayIcon(ls) { return ls.completed ? 'mdi-replay' : ls.started ? 'mdi-restore' : 'mdi-play'; }
function magIndexPlayLabel(ls, a) {
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + (a.title || 'article');
return (ls.completed ? 'Play again: ' : ls.started ? 'Resume: ' : 'Play: ') + magDisplayTitle(a);
}
// Live update of the three states (class, icons, accessible text) without re-rendering the 100+ rows.
function magDrivingIndexRefresh() {
@ -6734,6 +6828,8 @@ root.magContentsCommitTitle = magContentsCommitTitle;
root.magGenerateAllAudio = magGenerateAllAudio;
root.magTogglePlaylist = magTogglePlaylist;
root.magReviewPage = magReviewPage;
root.magMergeIntoPrevious = magMergeIntoPrevious;
root.magAcceptSuggestedTitle = magAcceptSuggestedTitle;
root.magCommitDocField = magCommitDocField;
root.magSetPagesCols = magSetPagesCols;
root.magPlaylistPlay = magPlaylistPlay;

View File

@ -5972,3 +5972,20 @@ html[data-mag-night] #toast.success { color: var(--green); } html[data-mag-night
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button:disabled { opacity: 1; color: var(--dim); }
.mag-driving[data-dtheme="dark"] .mag-driving-zoomctl button { background: var(--surface); }
.mag-driving[data-dtheme="dark"] .mag-driving-seek input[type="range"] { accent-color: var(--accent); }
/* Untitled entries: page labels, kind/stale badges and the merge/title suggestion in review */
.mag-page-label { display: block; font-size: 12px; font-weight: 400; color: var(--subtext); }
.mag-kind-badge, .mag-stale-badge { margin-left: 8px; font-size: 12px; }
.mag-stale-badge { white-space: normal; }
.mag-driving-index-list .ix-page { font-variant-numeric: tabular-nums; }
.mag-suggest { border: 1px solid var(--border); border-left: 4px solid var(--accent); border-radius: var(--radius, 8px);
padding: 10px 12px; margin: 0 0 12px; background: var(--surface); color: var(--text); max-width: 100%; overflow-wrap: anywhere; }
.mag-suggest p { margin: 0 0 8px; }
.mag-suggest-head { font-size: 13px; color: var(--subtext); }
.mag-suggest-why { margin: 0 0 8px; }
.mag-suggest-why summary { cursor: pointer; min-height: 44px; display: flex; align-items: center; }
.mag-suggest-why ul { margin: 0 0 4px; padding-left: 20px; }
.mag-suggest .mag-suggest-btn { min-height: 44px; min-width: 44px; margin: 0 8px 8px 0; }
.mag-suggest summary:focus-visible, .mag-suggest .mag-suggest-btn:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
@media (max-width: 480px) { .mag-suggest .mag-suggest-btn { width: 100%; justify-content: center; } }
.mag-stale-badge.mag-pill { color: var(--text); background: rgba(217,119,6,.16); border: 1px solid var(--yellow); }

View File

@ -3365,6 +3365,29 @@ test.describe('Driving mode layout and views', () => {
: 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 });
@ -4119,3 +4142,82 @@ test.describe('Magazine: approving a multi-page article page by page', () => {
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');
});
});