PR2: naratiune vocala (SpeechSynthesis, D10) - opt-in din builder

Feature nou (vocea nu exista deloc). Opt-in via checkbox 'voice' in builder
(off implicit), buton toggle in bara chrome a campaniei (parintele detine).
Voicing orchestrator-only, uniform pe toate 5 motoarele (fara dublu-citit):
povestea la 'Incepe aventura', intrebarea camerei la roomReady, mesajul final.

Edge cases (toate tratate):
- getVoices() gol sincron -> re-citire la onvoiceschanged.
- fara voce ro-* -> vocea default a sistemului (doar u.lang='ro-RO').
- speechSynthesis.cancel() in hideAll() -> fara replici fantoma la schimbarea scenei.
- fara 'speechSynthesis' in window -> buton ascuns, totul no-op.
- window.voiceSay expus pe parinte pt. viitor (replici motoare cu guard typeof).

Bug prins de test: #btn-voice{display:inline-flex} batea UA [hidden] ->
adaugat #btn-voice[hidden]{display:none}.

Test nou smoke #9b (voce opt-in: buton, citeste poveste/intrebare, cancel,
toggle) + asertare buton-ascuns cand voice=false. Suita 25/25. Demo regenerat.
AGENTS.md/TODOS actualizate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-13 11:46:33 +00:00
parent 651025bd28
commit da93d8498c
6 changed files with 237 additions and 15 deletions

View File

@@ -958,6 +958,8 @@ test.describe('Campanie E2E @campanie', () => {
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Fara voice in cfg → butonul de naratiune ramane ascuns (opt-in, D10)
await expect(gp.locator('#btn-voice')).toBeHidden();
// Inainte de orice gest: ctx inexistent (creat lazy)
const before = await gp.evaluate(
() => (window.beep && window.beep._ctx) ? window.beep._ctx.state : 'NO_CTX'
@@ -993,6 +995,66 @@ test.describe('Campanie E2E @campanie', () => {
}
});
// ─────────────────────────────────────────────────────────────────────
// Test 9b (PR2): Voce — naratiune opt-in (D10). Headless nu reda audio, dar
// spionam speechSynthesis.speak/cancel: butonul apare doar la voice=true,
// povestea+intrebarea sunt citite, schimbarea scenei cheama cancel, iar
// toggle-off face voiceSay no-op.
// ─────────────────────────────────────────────────────────────────────
test('voce — naratiune opt-in: buton, citeste poveste/intrebare, cancel + toggle @campanie',
async ({ page }) => {
const cfg = campaignCfg(3, 'classic');
cfg.voice = true;
const tmpPath = await writeCampaignHtml(page, cfg, 'voice');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
// Spy: inlocuim speak (sa nu redea real) si numaram cancel
await gp.evaluate(() => {
window.__spoken = []; window.__cancels = 0;
const ss = window.speechSynthesis;
ss.speak = (u) => { window.__spoken.push(String((u && u.text) || '')); };
const oc = ss.cancel.bind(ss);
ss.cancel = () => { window.__cancels++; try { oc(); } catch (e) {} };
});
// Butonul apare cand voice=true, pornit implicit
await expect(gp.locator('#btn-voice')).toBeVisible();
expect(await gp.locator('#btn-voice').getAttribute('aria-pressed')).toBe('true');
// Start → citeste povestea
await gp.locator('#btn-start').click();
await waitOverworld(gp);
await gp.waitForTimeout(120);
const afterStart = await gp.evaluate(() => window.__spoken.slice());
expect(afterStart.some(t => t.includes('O campanie de test')),
'povestea trebuie citita la start').toBeTruthy();
// Intra in camera → roomReady citeste intrebarea; tranzitia cheama cancel
await enterRoom(gp, 0);
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
await gp.waitForTimeout(120);
const afterRoom = await gp.evaluate(() => window.__spoken.slice());
expect(afterRoom.some(t => t.includes('Raspunde 1')),
'intrebarea camerei trebuie citita la roomReady').toBeTruthy();
expect(await gp.evaluate(() => window.__cancels),
'schimbarea scenei trebuie sa cheme speechSynthesis.cancel').toBeGreaterThan(0);
// Toggle off → aria-pressed false + voiceSay devine no-op
await gp.locator('#btn-voice').click();
expect(await gp.locator('#btn-voice').getAttribute('aria-pressed')).toBe('false');
await gp.evaluate(() => window.voiceSay('NU_TREBUIE_CITIT'));
const spokenAfter = await gp.evaluate(() => window.__spoken.slice());
expect(spokenAfter.includes('NU_TREBUIE_CITIT'),
'voiceSay trebuie no-op cand naratiunea e oprita').toBeFalsy();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
});
// ─────────────────────────────────────────────────────────────────────
// Test 10 (S4): Overworld — mers cu tastatura + iesire blocata pana la final
// ─────────────────────────────────────────────────────────────────────