From da93d8498c2a1ac80e35f4eb8a75cdb57bcf650b Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sat, 13 Jun 2026 11:46:33 +0000 Subject: [PATCH] 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) --- AGENTS.md | 4 +-- TODOS.md | 29 ++++++++++++---- escape-builder.html | 78 ++++++++++++++++++++++++++++++++++++++++++- exemplu-campanie.html | 69 +++++++++++++++++++++++++++++++++++++- tests/AGENTS.md | 10 +++--- tests/smoke.mjs | 62 ++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4fa9c56..e458cd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,9 +20,9 @@ sursa de adevăr tehnică pentru agenți. python3 -m http.server 8000 # Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md): -npx playwright test tests/smoke.mjs # suita completă: 24/24 +npx playwright test tests/smoke.mjs # suita completă: 25/25 npx playwright test tests/smoke.mjs --grep @regresie # regresie: 14 -npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 10 +npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 11 ``` ## Durable Rules (repo-wide) diff --git a/TODOS.md b/TODOS.md index e30a03f..d583f05 100644 --- a/TODOS.md +++ b/TODOS.md @@ -10,6 +10,15 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2 --- +## ▶ PR2 în curs (le iau pe rând, cerere user 2026-06-13) +- [x] **Audio camere** — fix REAL (vezi S1 mai jos, commit `651025b`): unlock pe primul gest global + (acoperă resume), nu doar btn-start; test rescris (headless crea ctx `running` trivial). +- [x] **Narațiune vocală (D10)** — LIVRAT (vezi §„Narațiune vocală" mai jos). Smoke 25/25. +- [ ] **Unificare `finale()` terminal pe `SNIP.finalJs`** (vezi §dedicată mai jos). +- [ ] **Audit a11y motoare** (vezi §dedicată mai jos). + +--- + ## ▶ BOARD ACTIV — Iterația 2 (Adventure Mode / restyle) Direcția cerută de user (decizii confirmate, vezi `HANDOFF.md`). Model hibrid ca la PR1: @@ -77,12 +86,20 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială) - Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan. - Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10). -### Edge case-uri voce (SpeechSynthesis) — PR2 -- `speechSynthesis.getVoices()` poate fi gol sincron → ascultă `voiceschanged`. -- Fără voce `ro-*` → fallback la vocea default (nu crash, nu tăcere). -- Voce activă mid-cameră → `speechSynthesis.cancel()` la demontare cameră (pater deține). -- `parent.voiceSay(text)` = no-op în jocurile simple (funcția nu există) → guard `typeof parent.voiceSay === 'function'`. -- Referință: D10 din plan; E2 Etapa 2 pct. 3. +### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2) +Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox +`voice`, off implicit), buton 🔊/🔇 în bara chrome a campaniei (părinte deține). Orchestrator-only +voicing (uniform pe toate 5 motoarele, fără dublu-citit): poveste la „Începe aventura", întrebarea +camerei la `roomReady`, mesajul final la `showFinale`. Toate edge-case-urile tratate: +- `getVoices()` gol sincron → re-citire la `onvoiceschanged` (`_pickVoice`). +- Fără voce `ro-*` → vocea default (nu setăm `u.voice`, doar `u.lang='ro-RO'`). +- `speechSynthesis.cancel()` în `hideAll()` → fără replici fantomă la schimbarea scenei. +- Fără `speechSynthesis` în window → buton ascuns, tot devine no-op. +- `window.voiceSay` expus pe părinte (pt. viitor: replici din motoare cu guard `typeof`). +Bug prins de test: `#btn-voice{display:inline-flex}` bătea UA `[hidden]` → adăugat `[hidden]{display:none}`. +Verificat: smoke 25/25 (test nou „voce — naratiune opt-in") + live MCP (buton, toggle, checkbox builder). +NOTĂ scope: motoarele NU cheamă încă `parent.voiceSay` (am evitat dublu-citit cu roomReady); dacă +pe viitor vrei replici chat citite individual, adaugă în `charMsg` cu guard `typeof parent.voiceSay`. ### Unificarea `finale()` din terminal pe `SNIP.finalJs` (PR2 primul pas) - Astăzi terminalul are propria funcție `finale()` (escape-builder.html:863) care NU folosește `SNIP.finalJs`. diff --git a/escape-builder.html b/escape-builder.html index 5da8400..65f54f7 100644 --- a/escape-builder.html +++ b/escape-builder.html @@ -49,6 +49,8 @@ } legend { font-size: 12px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; padding: 0 6px; } label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); margin: 10px 0 3px; } + label.ck { display: flex; align-items: flex-start; gap: 7px; cursor: pointer; line-height: 1.4; } + label.ck input { width: auto; margin-top: 1px; flex-shrink: 0; } label:first-of-type { margin-top: 0; } input[type=text], textarea, select { width: 100%; font: inherit; font-size: 14px; padding: 7px 9px; @@ -132,6 +134,7 @@ +
 
Se compune automat din campul "Litera" al fiecarui puzzle, in ordine.
@@ -170,6 +173,7 @@ const defaultState = () => ({ color: '#6d28d9', style: 'classic', charName: 'Alex', + voice: false, story: 'O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari. Rezolva fiecare puzzle ca sa aduni literele cuvantului magic.', finalMessage: 'Felicitari! Ai gasit comoara!', puzzles: [ @@ -214,6 +218,7 @@ const puzzleList = $('#puzzleList'); function renderGlobals() { document.querySelectorAll('[data-g]').forEach(el => { el.value = state[el.dataset.g]; }); + document.querySelectorAll('[data-gb]').forEach(el => { el.checked = !!state[el.dataset.gb]; }); renderWord(); } @@ -310,6 +315,10 @@ document.querySelectorAll('[data-g]').forEach(el => { }); }); +document.querySelectorAll('[data-gb]').forEach(el => { + el.addEventListener('change', () => { state[el.dataset.gb] = el.checked; onChange(); }); +}); + puzzleList.addEventListener('input', e => { const f = e.target.dataset.f; if (!f) return; @@ -1596,6 +1605,15 @@ body { } #chrome-title { font-size: 15px; font-weight: 700; } #chrome .sp { flex: 1; } +#btn-voice { + width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer; + border-radius: 8px; background: rgba(255,255,255,.12); color: #fff; + font-size: 17px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; +} +#btn-voice[hidden] { display: none; } /* id batea specificitatea UA [hidden] */ +#btn-voice:hover { background: rgba(255,255,255,.22); } +#btn-voice[aria-pressed="false"] { opacity: .5; } +#btn-voice:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; } #dots { display: flex; gap: 8px; } #dots span { width: 10px; height: 10px; border-radius: 50%; @@ -1758,6 +1776,7 @@ body {
${esc(cfg.title)}
+
@@ -1913,6 +1932,40 @@ function _onFirstGesture(){ document.addEventListener('pointerdown',_onFirstGesture,true); document.addEventListener('keydown',_onFirstGesture,true); +/* ----- Narațiune vocală (D10) — opt-in via MASTER.voice, buton în bara chrome. + Edge cases tratate: (1) getVoices() poate fi gol sincron → re-citim la voiceschanged; + (2) fără voce ro-* → vocea default a sistemului (nu setăm u.voice); (3) la fiecare + schimbare de scenă (hideAll) → speechSynthesis.cancel() (fără replici fantomă); + (4) fără API → butonul rămâne ascuns, totul devine no-op. */ +var SPEECH = ('speechSynthesis' in window) && !!MASTER.voice; +var voiceOn = SPEECH; /* pornit implicit când feature-ul e activat din builder */ +var _roVoice = null, _voicesReady = false; +function _pickVoice(){ + try{ + var vs = window.speechSynthesis.getVoices(); + if(!vs || !vs.length) return; /* gol sincron — așteptăm voiceschanged */ + _voicesReady = true; + _roVoice = vs.filter(function(v){ return /(^|[^a-z])ro([-_]|$)/i.test(v.lang||''); })[0] || null; + }catch(e){} +} +if(SPEECH){ + _pickVoice(); + try{ window.speechSynthesis.onvoiceschanged = _pickVoice; }catch(e){} +} +function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } } +function voiceSay(text){ + if(!SPEECH || !voiceOn || !text) return; + try{ + window.speechSynthesis.cancel(); + if(!_voicesReady) _pickVoice(); + var u = new SpeechSynthesisUtterance(String(text)); + if(_roVoice){ u.voice = _roVoice; u.lang = _roVoice.lang; } else { u.lang = 'ro-RO'; } + u.rate = 1; u.pitch = 1; + window.speechSynthesis.speak(u); + }catch(e){} +} +window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — guard în motoare */ + /* ----- parent.* API ----- */ window.nextRoom = function(data){ @@ -1941,6 +1994,8 @@ window.roomReady = function(idx){ if(+idx !== activeIdx) return; clearTimeout(readyTimer); frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */ + var q = MASTER.puzzles[idx] && MASTER.puzzles[idx].question; + if(q) voiceSay(q); /* citește întrebarea camerei (D10) */ }; window.roomError = function(idx, msg){ @@ -2051,6 +2106,7 @@ function showFinale(){ var pl = MASTER.player || ''; document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg; beep(true); confetti(); + voiceSay(document.getElementById('fin-msg').textContent); /* citește mesajul final (D10) */ } /* ----- Confetti ----- */ @@ -2067,6 +2123,7 @@ function confetti(){ var overworldEl = document.getElementById('overworld'); function hideAll(){ + voiceCancel(); /* fără replici fantomă la schimbarea scenei (D10) */ [introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); }); } @@ -2190,13 +2247,32 @@ owBuild(); /* ----- Intro ----- */ document.getElementById('intro-title').textContent = MASTER.title; -document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; +var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; +document.getElementById('intro-story').textContent = _introStory; document.getElementById('intro-promise').textContent = N+' camere \\u00b7 ${nStyles} stiluri \\u00b7 1 cuvânt magic'; document.getElementById('btn-start').onclick = function(){ unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ clearProgress(); owResetPlayer(); showOverworld(0); + voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */ }; +/* ----- Buton voce în bara chrome (D10) ----- */ +var btnVoice = document.getElementById('btn-voice'); +if(SPEECH && btnVoice){ + btnVoice.hidden = false; + var _syncVoiceBtn = function(){ + btnVoice.innerHTML = voiceOn ? '🔊' : '🔇'; /* 🔊 / 🔇 */ + btnVoice.setAttribute('aria-pressed', voiceOn ? 'true' : 'false'); + btnVoice.title = voiceOn ? 'Naratiune pornita — apasa ca sa opresti' : 'Naratiune oprita — apasa ca sa pornesti'; + }; + _syncVoiceBtn(); + btnVoice.onclick = function(){ + voiceOn = !voiceOn; + if(!voiceOn) voiceCancel(); + _syncVoiceBtn(); + }; +} + buildDots(); /* ----- Resume la reload (D11) ----- */ diff --git a/exemplu-campanie.html b/exemplu-campanie.html index d070131..a624fe7 100644 --- a/exemplu-campanie.html +++ b/exemplu-campanie.html @@ -30,6 +30,15 @@ body { } #chrome-title { font-size: 15px; font-weight: 700; } #chrome .sp { flex: 1; } +#btn-voice { + width: 34px; height: 34px; min-width: 34px; padding: 0; border: 0; cursor: pointer; + border-radius: 8px; background: rgba(255,255,255,.12); color: #fff; + font-size: 17px; line-height: 1; display: inline-flex; align-items: center; justify-content: center; +} +#btn-voice[hidden] { display: none; } /* id batea specificitatea UA [hidden] */ +#btn-voice:hover { background: rgba(255,255,255,.22); } +#btn-voice[aria-pressed="false"] { opacity: .5; } +#btn-voice:focus-visible { outline: 2px solid #a78bfa; outline-offset: 2px; } #dots { display: flex; gap: 8px; } #dots span { width: 10px; height: 10px; border-radius: 50%; @@ -192,6 +201,7 @@ body {
Comoara ascunsa
+
@@ -347,6 +357,40 @@ function _onFirstGesture(){ document.addEventListener('pointerdown',_onFirstGesture,true); document.addEventListener('keydown',_onFirstGesture,true); +/* ----- Narațiune vocală (D10) — opt-in via MASTER.voice, buton în bara chrome. + Edge cases tratate: (1) getVoices() poate fi gol sincron → re-citim la voiceschanged; + (2) fără voce ro-* → vocea default a sistemului (nu setăm u.voice); (3) la fiecare + schimbare de scenă (hideAll) → speechSynthesis.cancel() (fără replici fantomă); + (4) fără API → butonul rămâne ascuns, totul devine no-op. */ +var SPEECH = ('speechSynthesis' in window) && !!MASTER.voice; +var voiceOn = SPEECH; /* pornit implicit când feature-ul e activat din builder */ +var _roVoice = null, _voicesReady = false; +function _pickVoice(){ + try{ + var vs = window.speechSynthesis.getVoices(); + if(!vs || !vs.length) return; /* gol sincron — așteptăm voiceschanged */ + _voicesReady = true; + _roVoice = vs.filter(function(v){ return /(^|[^a-z])ro([-_]|$)/i.test(v.lang||''); })[0] || null; + }catch(e){} +} +if(SPEECH){ + _pickVoice(); + try{ window.speechSynthesis.onvoiceschanged = _pickVoice; }catch(e){} +} +function voiceCancel(){ if(SPEECH){ try{ window.speechSynthesis.cancel(); }catch(e){} } } +function voiceSay(text){ + if(!SPEECH || !voiceOn || !text) return; + try{ + window.speechSynthesis.cancel(); + if(!_voicesReady) _pickVoice(); + var u = new SpeechSynthesisUtterance(String(text)); + if(_roVoice){ u.voice = _roVoice; u.lang = _roVoice.lang; } else { u.lang = 'ro-RO'; } + u.rate = 1; u.pitch = 1; + window.speechSynthesis.speak(u); + }catch(e){} +} +window.voiceSay = voiceSay; /* expus camerelor: parent.voiceSay(replica) — guard în motoare */ + /* ----- parent.* API ----- */ window.nextRoom = function(data){ @@ -375,6 +419,8 @@ window.roomReady = function(idx){ if(+idx !== activeIdx) return; clearTimeout(readyTimer); frameEl.setAttribute('data-room-ready','true'); /* hook pentru tests */ + var q = MASTER.puzzles[idx] && MASTER.puzzles[idx].question; + if(q) voiceSay(q); /* citește întrebarea camerei (D10) */ }; window.roomError = function(idx, msg){ @@ -485,6 +531,7 @@ function showFinale(){ var pl = MASTER.player || ''; document.getElementById('fin-msg').textContent = pl ? pl+', '+msg.charAt(0).toLowerCase()+msg.slice(1) : msg; beep(true); confetti(); + voiceSay(document.getElementById('fin-msg').textContent); /* citește mesajul final (D10) */ } /* ----- Confetti ----- */ @@ -501,6 +548,7 @@ function confetti(){ var overworldEl = document.getElementById('overworld'); function hideAll(){ + voiceCancel(); /* fără replici fantomă la schimbarea scenei (D10) */ [introEl,overworldEl,skipEl,finaleEl].forEach(function(el){ el.classList.remove('show'); }); } @@ -624,13 +672,32 @@ owBuild(); /* ----- Intro ----- */ document.getElementById('intro-title').textContent = MASTER.title; -document.getElementById('intro-story').textContent = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; +var _introStory = (MASTER.player?'Salut, '+MASTER.player+'! ':'')+MASTER.story; +document.getElementById('intro-story').textContent = _introStory; document.getElementById('intro-promise').textContent = N+' camere \u00b7 3 stiluri \u00b7 1 cuvânt magic'; document.getElementById('btn-start').onclick = function(){ unlockAudio(); /* gest direct pe părinte (handlerul global prinde și el, dar e idempotent) */ clearProgress(); owResetPlayer(); showOverworld(0); + voiceSay(_introStory); /* după showOverworld → hideAll a rulat deja, nu mai anulează (D10) */ }; +/* ----- Buton voce în bara chrome (D10) ----- */ +var btnVoice = document.getElementById('btn-voice'); +if(SPEECH && btnVoice){ + btnVoice.hidden = false; + var _syncVoiceBtn = function(){ + btnVoice.innerHTML = voiceOn ? '🔊' : '🔇'; /* 🔊 / 🔇 */ + btnVoice.setAttribute('aria-pressed', voiceOn ? 'true' : 'false'); + btnVoice.title = voiceOn ? 'Naratiune pornita — apasa ca sa opresti' : 'Naratiune oprita — apasa ca sa pornesti'; + }; + _syncVoiceBtn(); + btnVoice.onclick = function(){ + voiceOn = !voiceOn; + if(!voiceOn) voiceCancel(); + _syncVoiceBtn(); + }; +} + buildDots(); /* ----- Resume la reload (D11) ----- */ diff --git a/tests/AGENTS.md b/tests/AGENTS.md index c8c7147..786c6ec 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec până la ecranul final, fără erori de consolă. ## Ownership -- `tests/smoke.mjs` — unicul fișier de teste (~24 teste). +- `tests/smoke.mjs` — unicul fișier de teste (~25 teste). - `playwright.config.mjs` (la root, **gitignored**) — config dev. ## Local Contracts @@ -17,9 +17,9 @@ până la ecranul final, fără erori de consolă. - **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`; fiecare test asertează `errors.length === 0` la final. - **Tag-uri:** `@regresie` (14 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML + - bomberman gameplay) și `@campanie` (10 — intro→hartă→camere→final, resume, cameră moartă, - idempotență ușă, `$`/`$&`, beep, mobil, audio S1, navigare overworld). -- **Status țintă: 24/24 PASS.** + bomberman gameplay) și `@campanie` (11 — intro→hartă→camere→final, resume, cameră moartă, + idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10, navigare overworld). +- **Status țintă: 25/25 PASS.** ## Work Guidance - După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă @@ -28,7 +28,7 @@ până la ecranul final, fără erori de consolă. ## Verification ```bash -npx playwright test tests/smoke.mjs # 24/24 +npx playwright test tests/smoke.mjs # 25/25 npx playwright test tests/smoke.mjs --grep @regresie npx playwright test tests/smoke.mjs --grep @campanie ``` diff --git a/tests/smoke.mjs b/tests/smoke.mjs index 505c8c4..24517dc 100644 --- a/tests/smoke.mjs +++ b/tests/smoke.mjs @@ -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 // ─────────────────────────────────────────────────────────────────────