tts-voice-creator-clone-and.../tests/audiobook-spans.spec.js
mARTin-B78 e8c0df027e test: fail any spec that would write to the live API
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:00:25 +02:00

180 lines
12 KiB
JavaScript

// @ts-check
// Browser-level checks for span-mode casting: the modules load with the audiobook
// section, the pure segmenter works in the real page, and the engine switch honours
// localStorage. The LLM-driven flow itself is covered by the unit tests and the
// scored regression runs (architecture/audiobook-attribution.md).
const { test, expect } = require('./_guard');
async function openReader(page) {
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
await page.evaluate(() => window.navTo('s-reader'));
await page.waitForFunction(() => typeof window.audiobookCastSpans === 'function', null, { timeout: 15_000 });
}
test('span-mode modules load and segment quoted speech in the page', async ({ page }) => {
await openReader(page);
const out = await page.evaluate(() => {
const filler = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' ');
const spans = window.TTVC.audiobook.spans.segmentByQuotes(`${filler} Der Zwerg schneuzte sich. »Na los!« Er ging.`);
return spans.slice(-3).map((s) => `${s.kind}:${s.text}`);
});
expect(out.some((s) => s === 'dialogue:Na los!')).toBe(true);
expect(out[out.length - 1].startsWith('narration:')).toBe(true);
});
test('span mode is on by default and can be switched off per browser', async ({ page }) => {
await openReader(page);
expect(await page.evaluate(() => window.audiobookSpanModeEnabled())).toBe(true);
await page.evaluate(() => localStorage.setItem('ab-span-mode', '0'));
expect(await page.evaluate(() => window.audiobookSpanModeEnabled())).toBe(false);
await page.evaluate(() => localStorage.removeItem('ab-span-mode'));
});
// ── Verify / recast for span casts ───────────────────────────────────────────
// A cast made in span mode is verified by a second pass plus candidate-restricted adjudication; these
// tests drive audiobookVerifySpans against a mocked /api/attribute-spans.
const FILLER = Array.from({ length: 6 }, (_, i) => `»Zeile ${i} ist ein Satz.« sagte er.`).join(' ');
const BOOK = `${FILLER} Der Zwerg schneuzte sich. »Na los!« sagte Linosch. »Vielen Dank!« Er ging. »Wirklich?« fragte jemand. »Ist das so?« Stille.`;
async function seedCast(page, firstPass, book = BOOK) {
await page.evaluate(([book, first]) => {
const spans = window.TTVC.audiobook.spans.segmentByQuotes(book);
window._audiobook.lastText = book;
window._audiobook.roster = ['Marcian', 'Linosch', 'Alrik'];
window._audiobook.segments = spans.map((sp) => {
const id = (sp.kind === 'dialogue' ? 'D' : 'N') + sp.index;
return sp.kind === 'dialogue'
? { speaker: first[sp.text] || 'Marcian', type: 'dialogue', text: sp.text, emotion: '', spanId: id }
: { speaker: 'Narrator', type: 'narration', text: sp.text, emotion: '', spanId: id };
});
}, [book, firstPass]);
}
async function mockSpanEndpoint(page, plan) {
const calls = [];
await page.route('**/api/rehearsals*', route => route.fulfill({ json: { id: 'reh-test' } }));
// the warm-up request must never reach the real model server
await page.route('**/api/attribute-dialogue', route => route.fulfill({ json: { segments: [] } }));
await page.route('**/api/attribute-spans', route => {
const body = route.request().postDataJSON();
calls.push({ reasoning: !!body.want_reasoning, only: body.attribute_only || null, ids: body.spans.filter(s => s.kind === 'dialogue').map(s => s.id) });
const table = body.want_reasoning ? plan.reasoning : plan.second;
const speakers = {};
for (const s of body.spans) {
if (s.kind !== 'dialogue') continue;
if (body.attribute_only && !body.attribute_only.includes(s.id)) continue;
const name = table[s.text] ?? (body.want_reasoning ? undefined : 'Marcian'); // the second pass answers every line
if (name !== undefined) speakers[s.id] = { speaker: name, emotion: '' };
}
return route.fulfill({ json: { speakers, missing: [], characters: [] } });
});
return calls;
}
test('verify is advisory: it marks agreement and disputes but never changes a speaker', async ({ page }) => {
await openReader(page);
await seedCast(page, { 'Na los!': 'Linosch', 'Vielen Dank!': 'Alrik', 'Wirklich?': 'Unknown', 'Ist das so?': 'Alrik' });
const calls = await mockSpanEndpoint(page, { second: { 'Na los!': 'Linosch', 'Vielen Dank!': 'Marcian', 'Wirklich?': 'Alrik', 'Ist das so?': 'Linosch' }, reasoning: {} });
const handled = await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {}));
expect(handled).toBe(true);
const rows = await page.evaluate(() => window._audiobook.segments.filter(s => s.type === 'dialogue')
.map(s => [s.text, s.speaker, s.verified, s.altSpeaker || null, s.confWhy]));
const by = Object.fromEntries(rows.map(([t, sp, v, alt, why]) => [t, { sp, v, alt, why }]));
expect(by['Na los!']).toMatchObject({ sp: 'Linosch', v: 'agreed' });
expect(by['Vielen Dank!']).toMatchObject({ sp: 'Alrik', v: 'disputed', alt: 'Marcian' }); // first answer kept
expect(by['Ist das so?']).toMatchObject({ sp: 'Alrik', v: 'disputed', alt: 'Linosch' });
expect(by['Wirklich?']).toMatchObject({ sp: 'Unknown', v: 'unresolved', alt: 'Alrik' }); // never filled in silently
expect(by['Zeile 0 ist ein Satz.'].v).toBe('agreed');
expect(by['Vielen Dank!'].why).toContain('two passes disagree (other pass: Marcian)');
expect(by['Na los!'].why).toContain('two passes agree');
// span-cast confidence is calibrated on agreement (pooled measurement), not on the old heuristics
const conf = await page.evaluate(() => Object.fromEntries(window._audiobook.segments.filter(s => s.type === 'dialogue')
.map(s => [s.text, [s.conf, s.confLabel]])));
expect(conf['Na los!']).toEqual([82, 'Likely']);
expect(conf['Vielen Dank!']).toEqual([48, 'Doubtful']);
expect(conf['Wirklich?']).toEqual([5, 'Guess']);
// a single no-thinking pass: no reasoning calls, no per-line restriction
expect(calls.length).toBeGreaterThan(0);
expect(calls.every(c => !c.reasoning && !c.only)).toBe(true);
// the text is untouched, every span still present once
const text = await page.evaluate(() => window._audiobook.segments.map(s => s.text).join(' '));
expect(text.replace(/[»«]/g, '').replace(/\s+/g, ' ')).toBe(BOOK.replace(/[»«]/g, '').replace(/\s+/g, ' '));
});
test('recast unknown on a span cast only re-asks the Unknown lines, with reasoning', async ({ page }) => {
await openReader(page);
// Untagged lines before any speaker is known: the grammar pre-pass cannot settle them, so the model is asked.
const book = `»Wer ist da?« Wind. »Niemand.« Nacht. ${FILLER}`;
await seedCast(page, { 'Wer ist da?': 'Unknown', 'Niemand.': 'Unknown' }, book);
const calls = await mockSpanEndpoint(page, { second: {}, reasoning: { 'Wer ist da?': 'Alrik', 'Niemand.': 'Unknown' } });
await page.evaluate(() => window.audiobookRecastUnknown(undefined, undefined, {}));
await expect.poll(async () => page.evaluate(() => window._audiobook.segments.find(s => s.text === 'Wer ist da?').speaker)).toBe('Alrik');
const speakers = await page.evaluate(() => Object.fromEntries(window._audiobook.segments.filter(s => s.type === 'dialogue').map(s => [s.text, s.speaker])));
expect(speakers['Niemand.']).toBe('Unknown'); // still unresolved: left alone, never invented
expect(speakers['Zeile 0 ist ein Satz.']).toBe('Marcian'); // known lines are not re-litigated
expect(calls.length).toBeGreaterThan(0);
expect(calls.every(c => c.reasoning)).toBe(true);
// the two Unknown lines were the only ones ever asked about
const asked = new Set(calls.flatMap(c => c.only || []));
const unknownIds = await page.evaluate(() => window._audiobook.segments.filter(s => s.text === 'Wer ist da?' || s.text === 'Niemand.').map(s => s.spanId));
expect(asked.size).toBeGreaterThan(0);
expect([...asked].every(id => unknownIds.includes(id))).toBe(true);
});
test('verify leaves the cast untouched when the endpoint fails, and declines a cast it cannot map to the text', async ({ page }) => {
await openReader(page);
await seedCast(page, { 'Na los!': 'Linosch', 'Vielen Dank!': 'Alrik' });
const before = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker])));
await page.route('**/api/attribute-dialogue', route => route.fulfill({ json: { segments: [] } }));
await page.route('**/api/rehearsals*', route => route.fulfill({ json: { id: 'reh-test' } }));
await page.route('**/api/attribute-spans', route => route.fulfill({ status: 503, json: { detail: 'down' } }));
await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {}));
const after = await page.evaluate(() => JSON.stringify(window._audiobook.segments.map(s => [s.spanId, s.speaker])));
expect(after).toBe(before);
// a cast from a different text (or a classic cast without span ids) is declined so the classic path can run
const declined = await page.evaluate(() => {
window._audiobook.segments = [{ speaker: 'X', type: 'dialogue', text: 'Hallo', emotion: '' }];
return window.audiobookVerifySpans(undefined, undefined, {});
});
expect(declined).toBe(false);
});
test('an unverified span cast is labelled as such, and a classic cast keeps the old confidence model', async ({ page }) => {
await openReader(page);
const out = await page.evaluate(() => {
const span = [{ speaker: 'Marcian', type: 'dialogue', text: '»Ja.«', emotion: '', spanId: 'D1' }];
const classic = [{ speaker: 'Marcian', type: 'dialogue', text: '»Ja.«', emotion: '' }];
window._abStampConfidence(span); window._abStampConfidence(classic);
return { span: [span[0].conf, span[0].confLabel, span[0].confWhy], classic: [typeof classic[0].conf, classic[0].confWhy.includes('speech tag')] };
});
expect(out.span).toEqual([76, 'Probably', 'not verified yet — run Verify to check this line']);
expect(out.classic[0]).toBe('number');
});
test('the "Verify all characters" menu action runs the advisory second pass, not the Unknown-only recast', async ({ page }) => {
await openReader(page);
await seedCast(page, { 'Na los!': 'Linosch' });
const calls = await mockSpanEndpoint(page, { second: { 'Na los!': 'Linosch' }, reasoning: {} });
// the menu handler passes { repairNarration: true, verify: true } (static/js/audiobook-cast.js runVerificationPass)
await page.evaluate(() => window.audiobookRecastUnknown(undefined, undefined, { repairNarration: true, verify: true }));
await expect.poll(async () => page.evaluate(() => window._audiobook.segments.find(s => s.text === 'Na los!').verified)).toBe('agreed');
expect(calls.length).toBeGreaterThan(0);
expect(calls.every(c => !c.reasoning && !c.only)).toBe(true);
// the plain repair entry (auto-repair / Identify unknown) still means Unknown-only
const src = await page.evaluate(() => fetch('/static/js/audiobook-cast.js').then(r => r.text()));
expect(src).toContain('repairNarration: true, verify: true');
});
test('verify wakes the model before the first real request', async ({ page }) => {
await openReader(page);
await seedCast(page, { 'Na los!': 'Linosch' });
const order = [];
await page.route('**/api/attribute-dialogue', route => { order.push('warmup:' + route.request().postDataJSON().text); return route.fulfill({ json: { segments: [] } }); });
await page.route('**/api/attribute-spans', route => { order.push('spans'); return route.fulfill({ json: { speakers: {}, missing: [], characters: [] } }); });
await page.evaluate(() => window.audiobookVerifySpans(undefined, undefined, {}));
expect(order[0]).toBe('warmup:Wake up.');
expect(order.filter(x => x === 'spans').length).toBeGreaterThan(0);
});