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:
@@ -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
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user