From a4b0ff41548475ddca15ef989f2fa9236255c68a Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sat, 13 Jun 2026 08:34:57 +0000 Subject: [PATCH] =?UTF-8?q?Campanie=20multi-stil=20=E2=80=94=20PR1=20(T1-T?= =?UTF-8?q?8=20+=20TD1-TD6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adauga al 6-lea stil de joc: campanie multi-stil care leaga puzzle-urile in camere de stiluri diferite (clasic/terminal/arcade/chat/point in rotatie), conectate prin coridoare cu usa, litera si stele. Contract de montare (verificat la gate T1): - gameCampaign: un + +
+

+

+

+ +
+ +
+
+
+
Litera câștigată
+
+
+
+
+
+
+ +
+ +
+

⚠️ Ușa asta e înțepenită!

+
+

Camera nu a răspuns. Poți sări la cea următoare.

+
+ +
+ +
+

🏆 Evadare reușită!

+
+

Cuvântul magic:

+
+

+
+ + + \ No newline at end of file diff --git a/exemplu-campanie.html b/exemplu-campanie.html new file mode 100644 index 0000000..2076ce2 --- /dev/null +++ b/exemplu-campanie.html @@ -0,0 +1,527 @@ + + + + + +Comoara ascunsa + + + + +
+ Comoara ascunsa +
+
+
+ +
+ + +
+

+

+

+ +
+ +
+
+
+
Litera câștigată
+
+
+
+
+
+
+ +
+ +
+

⚠️ Ușa asta e înțepenită!

+
+

Camera nu a răspuns. Poți sări la cea următoare.

+
+ +
+ +
+

🏆 Evadare reușită!

+
+

Cuvântul magic:

+
+

+
+
+ + + + \ No newline at end of file diff --git a/exemplu-chat.html b/exemplu-chat.html index 35d5596..767a0b1 100644 --- a/exemplu-chat.html +++ b/exemplu-chat.html @@ -59,7 +59,7 @@ \ No newline at end of file diff --git a/exemplu-clasic.html b/exemplu-clasic.html index 3084a74..e23efe7 100644 --- a/exemplu-clasic.html +++ b/exemplu-clasic.html @@ -96,7 +96,7 @@ \ No newline at end of file diff --git a/exemplu-point.html b/exemplu-point.html index e11ff21..29dddfa 100644 --- a/exemplu-point.html +++ b/exemplu-point.html @@ -73,7 +73,7 @@ \ No newline at end of file diff --git a/exemplu-terminal.html b/exemplu-terminal.html index e4bc319..6a5539f 100644 --- a/exemplu-terminal.html +++ b/exemplu-terminal.html @@ -25,7 +25,7 @@
>
\ No newline at end of file diff --git a/index.html b/index.html index 1682c14..704ee00 100644 --- a/index.html +++ b/index.html @@ -20,6 +20,7 @@

Escape Room Builder

Builder-ul + cate un joc demo exportat in fiecare stil.

Builder editor + preview live; schimba "Stil joc" si vezi transformarea pe loc + 🗺️ Campanie multi-stil 3 puzzle-uri × 3 stiluri diferite — ușa ca erou, coridor cu litera, cuvântul magic Clasic (quiz) carduri secventiale cu progres si litere Terminal retro text adventure CRT; scrie raspunsul, INDICIU, LITERE Arcade pixel sageti / WASD; usi incuiate, cufar final diff --git a/tests/smoke.mjs b/tests/smoke.mjs new file mode 100644 index 0000000..53222ae --- /dev/null +++ b/tests/smoke.mjs @@ -0,0 +1,700 @@ +// @ts-check +/** + * tests/smoke.mjs — Escape Room Builder smoke & regression tests + * + * Setup (o singura data): + * npm install + * npx playwright install chromium + * + * Rulare: + * npx playwright test tests/smoke.mjs # suita completa + * npx playwright test tests/smoke.mjs --grep @regresie # regresie (baseline, acum) + * npx playwright test tests/smoke.mjs --grep @campanie # campanie (dupa integrator) + * npm test # alias pentru suita completa + * + * @see CLAUDE.md § Testing + */ + +import { test, expect } from '@playwright/test'; +import { writeFileSync, unlinkSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, '..'); + +/** Converteste cale relativa la file:// URL. */ +const fileURL = (name) => 'file://' + join(ROOT, name); + +/** + * Ataseaza listeneri de erori si returneaza array-ul de erori. + * Test-ul trebuie sa asserteze `errors.length === 0` la final. + */ +function trackErrors(page) { + const errors = []; + page.on('console', msg => { + if (msg.type() === 'error') errors.push(`[console.error] ${msg.text()}`); + }); + page.on('pageerror', err => errors.push(`[pageerror] ${err.message}`)); + return errors; +} + +/** + * Asteapta si rezolva un puzzle in modalul comun (motoarele arcade / point). + * Presupune ca modalul e deja vizibil. + */ +async function solveModal(page, puzzle) { + await expect(page.locator('#mOverlay')).toBeVisible({ timeout: 5000 }); + if (puzzle.type === 'free') { + await page.locator('#mAnswers input[type=text]').fill(puzzle.answer); + await page.locator('#mAnswers button:not(.mhint):not(.mclose)').first().click(); + } else if (puzzle.type === 'tf') { + await page.locator(`#mAnswers button:text("${puzzle.tfAnswer}")`).click(); + } else { + // choice: gaseste varianta corecta (prefixata cu *) + const correct = puzzle.choices.split('\n') + .find(l => l.trim().startsWith('*'))?.replace(/^\*/, '').trim() ?? ''; + await page.locator(`#mAnswers button:text("${correct}")`).click(); + } + // Modalul se inchide dupa ~750ms animatie de success + await page.waitForFunction( + () => document.getElementById('mOverlay')?.style.display !== 'flex', + { timeout: 3000 } + ); +} + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 1 — REGRESIE: fiecare exemplu-*.html rezolvat pana la final +// +// Contractul: diff-ul campaniei modifica finalul tuturor celor 5 stiluri +// (ramura _campaign in SNIP.finalJs + finale() terminal + final classic). +// Aceste teste verifica ca finalul existent NU este stricat. +// +// Ruleaza ACUM ca baseline contra exemplu-*.html curente. +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Regresie exemplu-*.html @regresie', () => { + + test('exemplu-clasic.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-clasic.html')); + + // Start game + await page.locator('#btnStart').click(); + await page.waitForSelector('#sGame.on', { timeout: 3000 }); + + // Puzzle 1: raspuns liber "56" + await page.locator('#answers input[type=text]').fill('56'); + await page.locator('#answers button:text("Verifica")').click(); + await page.waitForTimeout(1100); // asteapta animatia si next() + + // Puzzle 2: adevarat/fals "Adevarat" + await page.locator('#answers button:text("Adevarat")').click(); + await page.waitForTimeout(1100); + + // Puzzle 3: variante "Paris" + await page.locator('#answers button:text("Paris")').click(); + await page.waitForTimeout(1200); + + // Ecranul final trebuie sa fie vizibil + await expect(page.locator('#sFinal')).toHaveClass(/on/, { timeout: 3000 }); + // Cuvantul magic "DAR" afişat ca litere individuale + const bigword = page.locator('#bigword'); + await expect(bigword).toContainText('D'); + await expect(bigword).toContainText('A'); + await expect(bigword).toContainText('R'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-terminal.html — rezolvat pana la ecranul final', async ({ page }) => { + // Animatia de typing poate fi lenta; marim timeout-ul pentru acest test + test.setTimeout(120000); + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-terminal.html')); + + // Asteapta ca intro-ul sa termine animatia de typing si primul puzzle sa apara. + // Nota: { timeout } in waitForFunction merge ca al doilea argument FARA arg intermediar. + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[1/'), + null, { timeout: 20000 } + ); + + // Puzzle 1: raspuns liber "56" + // Folosim .press() pe locator (nu keyboard global) pentru focus garantat. + await page.locator('#cmd').fill('56'); + await page.locator('#cmd').press('Enter'); + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('ACCES PERMIS'), + null, { timeout: 15000 } + ); + + // Asteapta puzzle 2 + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[2/'), + null, { timeout: 15000 } + ); + + // Puzzle 2: adevarat/fals "Adevarat" + await page.locator('#cmd').fill('Adevarat'); + await page.locator('#cmd').press('Enter'); + await page.waitForFunction( + () => (document.getElementById('out')?.textContent?.match(/ACCES PERMIS/g) ?? []).length >= 2, + null, { timeout: 15000 } + ); + + // Asteapta puzzle 3 + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('[3/'), + null, { timeout: 15000 } + ); + + // Puzzle 3: variante - "1" = Paris (prima optiune numerotata) + await page.locator('#cmd').fill('1'); + await page.locator('#cmd').press('Enter'); + + // Asteapta textul de finale. + // IMPORTANT: textul e "E V A D A R E R E U S I T A" (litere cu spatii intre ele)! + await page.waitForFunction( + () => document.getElementById('out')?.textContent?.includes('E V A D A R E'), + null, { timeout: 20000 } + ); + + // Cuvantul magic "DAR" apare ca "D A R" (spaced) — verifica fiecare litera + const outText = await page.locator('#out').innerText(); + expect(outText).toMatch(/D\s+A\s+R/); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-arcade.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-arcade.html')); + + // Asteapta initializarea canvas + API-urilor de puzzle + await page.waitForFunction( + () => typeof openPuzzle !== 'undefined' && typeof onDoorSolved !== 'undefined', + { timeout: 5000 } + ); + + const puzzles = await page.evaluate(() => CFG.puzzles); + + // Rezolva fiecare puzzle prin API-ul modal (simuleaza interactiunea cu usile) + for (let i = 0; i < puzzles.length; i++) { + await page.evaluate((idx) => openPuzzle(idx, onDoorSolved), i); + await solveModal(page, puzzles[i]); + } + + // Toate puzzle-urile rezolvate → trigger final (simuleaza ajungerea la cufar) + await page.evaluate(() => showFinal()); + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-chat.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-chat.html')); + + // Asteapta ca intro-ul (Salut + poveste + "Ma ajuti?") sa termine + // si primul puzzle (free) sa apara cu input in composer + await page.waitForFunction( + () => document.getElementById('composer')?.querySelector('input') !== null, + { timeout: 20000 } + ); + + // Puzzle 1: raspuns liber "56" + await page.locator('#composer input').fill('56'); + await page.locator('#composer button:not(.chip)').first().click(); // "Trimite" + // Asteapta confirmarea "Asta era!" + await page.waitForFunction( + () => document.getElementById('msgs')?.textContent?.includes('Asta era'), + { timeout: 12000 } + ); + + // Puzzle 2: tf — asteapta chip-ul "Adevarat" + await page.waitForFunction( + () => { + const c = document.getElementById('composer'); + return c && [...c.querySelectorAll('button.chip')] + .some(b => b.textContent.trim() === 'Adevarat'); + }, + { timeout: 15000 } + ); + await page.locator('#composer button.chip:text("Adevarat")').click(); + await page.waitForFunction( + () => (document.getElementById('msgs')?.textContent?.match(/Asta era/g) ?? []).length >= 2, + { timeout: 12000 } + ); + + // Puzzle 3: choice — asteapta chip-ul "Paris" + await page.waitForFunction( + () => { + const c = document.getElementById('composer'); + return c && [...c.querySelectorAll('button.chip')] + .some(b => b.textContent.trim() === 'Paris'); + }, + { timeout: 15000 } + ); + await page.locator('#composer button.chip:text("Paris")').click(); + + // Asteapta overlay-ul final + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 15000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('exemplu-point.html — rezolvat pana la ecranul final', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('exemplu-point.html')); + + // Asteapta randarea scenei SVG cu obiectele "hot" + await page.waitForFunction( + () => typeof openPuzzle !== 'undefined' && document.querySelectorAll('g.hot').length > 0, + { timeout: 5000 } + ); + + const puzzles = await page.evaluate(() => CFG.puzzles); + + // Click fiecare obiect hot si rezolva puzzle-ul din modal + for (let i = 0; i < puzzles.length; i++) { + await page.locator(`g.hot[data-i="${i}"]`).click(); + await solveModal(page, puzzles[i]); + } + + // Toate rezolvate → usa se deschide → click pe usa → ecranul final + await expect(page.locator('#door')).toHaveClass(/open/, { timeout: 3000 }); + await page.locator('#door').click(); + await expect(page.locator('#fOverlay')).toBeVisible({ timeout: 3000 }); + await expect(page.locator('#fWord')).toContainText('D'); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + +}); + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 2 — EDGE CASES (rulabile acum, fara campanie) +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Edge cases @regresie', () => { + + test('import JSON corupt — fara crash, builder ramane functional', async ({ page }) => { + const errors = trackErrors(page); + + // Dismiss alert-ul de eroare + page.on('dialog', d => d.accept()); + + await page.goto(fileURL('escape-builder.html')); + + // Scrie un fisier JSON corupt temporar si incarca-l + const tmpPath = join(ROOT, 'tests', '.tmp-corrupt-test.json'); + writeFileSync(tmpPath, '{"title":"test","puzzles":[{INVALID_JSON_HERE}]}'); + + try { + await page.locator('#fileLoad').setInputFiles(tmpPath); + await page.waitForTimeout(600); // asteapta alert + dismiss + } finally { + unlinkSync(tmpPath); + } + + // Builder-ul trebuie sa fie in continuare functional + await expect(page.locator('#gTitle')).toBeVisible(); + await expect(page.locator('#addPuzzle')).toBeVisible(); + // Starea existenta trebuie pastrata (nu resetata la corupt) + const title = await page.locator('#gTitle').inputValue(); + expect(title.length).toBeGreaterThan(0); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + + // Assert 320x568 fara overflow orizontal per stil (§Design pct. 11) + for (const stil of ['clasic', 'terminal', 'arcade', 'chat', 'point']) { + test(`320x568 fara overflow orizontal — ${stil}`, async ({ page }) => { + await page.setViewportSize({ width: 320, height: 568 }); + const errors = trackErrors(page); + + await page.goto(fileURL(`exemplu-${stil}.html`)); + await page.waitForTimeout(400); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1 + ); + expect(overflow, `Overflow orizontal la 320x568 in exemplu-${stil}`).toBe(false); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + } + + test('regenerare demo-uri via gameHTML — fara erori de consola', async ({ page }) => { + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + // Verifica ca gameHTML genereaza HTML valid si fara erori pentru fiecare stil + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + const singlePuzzle = { + title: 'Q1', type: 'free', question: 'Test?', answer: 'da', + tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'T' + }; + + for (const style of styles) { + const html = await page.evaluate((args) => { + return gameHTML({ + title: 'Test ' + args.style, player: 'Tester', color: '#6d28d9', + style: args.style, charName: 'Alex', + story: 'Poveste test.', finalMessage: 'Bravo!', + puzzles: [args.puzzle] + }); + }, { style, puzzle: singlePuzzle }); + + // Verificari de baza pe stringul HTML generat + expect(typeof html, `${style}: gameHTML nu a returnat string`).toBe('string'); + expect(html, `${style}: lipseste doctype`).toContain('undefined<'); + + // Incarca in pagina noua si verifica absenta erorilor + const genPage = await page.context().newPage(); + const genErrors = []; + genPage.on('console', m => { if (m.type() === 'error') genErrors.push(m.text()); }); + genPage.on('pageerror', e => genErrors.push(e.message)); + + await genPage.setContent(html, { waitUntil: 'domcontentloaded' }); + await genPage.waitForTimeout(500); + + expect(genErrors, `${style} gameHTML erori:\n${genErrors.join('\n')}`).toHaveLength(0); + await genPage.close(); + } + + expect(errors, 'Builder erori:\n' + errors.join('\n')).toHaveLength(0); + }); + + test('builder: JSON cu tip puzzle necunoscut → normalizat, fara crash', async ({ page }) => { + const errors = trackErrors(page); + page.on('dialog', d => d.accept()); + + await page.goto(fileURL('escape-builder.html')); + + // Incarca JSON cu tip invalid si choices non-string + const tmpPath = join(ROOT, 'tests', '.tmp-invalid-type.json'); + writeFileSync(tmpPath, JSON.stringify({ + title: 'Test normalizare', + style: 'classic', + color: '#6d28d9', + charName: 'X', + story: 'S', + finalMessage: 'F', + puzzles: [ + { title: 'P1', type: 'INVALID_TYPE', question: 'Q?', answer: 'A', + tfAnswer: 'Adevarat', choices: 42, hint: null, letter: 'X' } + ] + })); + + try { + await page.locator('#fileLoad').setInputFiles(tmpPath); + await page.waitForTimeout(600); + } finally { + unlinkSync(tmpPath); + } + + // Builder-ul trebuie sa ramana functional (nu crash) + await expect(page.locator('#gTitle')).toBeVisible(); + await expect(page.locator('#addPuzzle')).toBeVisible(); + + expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0); + }); + +}); + +// ═══════════════════════════════════════════════════════════════════════ +// SECTIUNEA 3 — CAMPANIE E2E +// +// *** SKIP pana cand integrator anunta implementarea gata *** +// Dupa: schimba `test.skip(true, ...)` → `test.skip(false)` sau sterge linia. +// +// Contractul documentat in plan: +// - gameCampaign(cfg) genereaza HTML cu iframe per camera +// - Fiecare camera apeleaza parent.nextRoom({idx, stars, letter}) +// - parent.roomReady() semnaledaza montarea cu succes +// - parent.roomError(idx, msg) declanseaza skip cu 0 stele +// - Timeout 4s fara roomReady → camera moarta → skip +// - CFG._campaign = {idx, total, stars, letters, deadline} in fiecare camera +// - Replace token: TPL.replace('__CFG__', () => json) (D1: evita $&) +// - safeStore (try/catch) pentru resume (D3) +// - Hash djb2 peste CFG embedat la export (D11) +// ═══════════════════════════════════════════════════════════════════════ + +test.describe('Campanie E2E @campanie', () => { + + /** Helper: genereaza un cfg de campanie cu N puzzle-uri, stiluri rotite. */ + function campaignCfg(n = 5) { + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + return { + title: 'Test Campanie ' + n, player: 'Tester', color: '#6d28d9', + style: 'campaign', charName: 'Alex', + story: 'O campanie de test cu ' + n + ' camere.', + finalMessage: 'Ai terminat campania!', + puzzles: Array.from({ length: n }, (_, i) => ({ + title: 'Camera ' + (i + 1), + type: 'free', + question: 'Raspunde ' + (i + 1), + answer: 'r' + (i + 1), + tfAnswer: 'Adevarat', + choices: '', + hint: '', + letter: String.fromCharCode(65 + (i % 26)), + style: styles[i % 5] + })) + }; + } + + test('campanie E2E — intro → camere cu stiluri rotite → final cu stele+litere+cuvant corect @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta gameCampaign de la integrator — ster skip dupa'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(5); + const html = await page.evaluate((c) => { + if (typeof gameCampaign !== 'function') throw new Error('gameCampaign not yet'); + return gameHTML(c); + }, cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Intro campanie + await gp.locator('button:text("Incepe aventura")').click(); + + // Parcurge 5 camere, fiecare in stilul ei rotat + const styles = ['classic', 'terminal', 'arcade', 'chat', 'point']; + for (let i = 0; i < 5; i++) { + const answer = 'r' + (i + 1); + const style = styles[i % 5]; + + // Asteapta roomReady → camera montata + await gp.waitForFunction( + () => document.querySelector('[data-room-ready="true"]') !== null || + document.querySelector('iframe.room-ready') !== null, + { timeout: 8000 } + ); + + // Raspunde in camera curenta (prin iframe) + const iframeLocator = gp.frameLocator('iframe[data-room]').last(); + + if (style === 'classic') { + await iframeLocator.locator('#btnStart').click(); + await iframeLocator.locator('#answers input').fill(answer); + await iframeLocator.locator('#answers button:text("Verifica")').click(); + } else if (style === 'terminal') { + await gp.waitForFunction(() => true); // terminal necesita interactiune specifica + await iframeLocator.locator('#cmd').fill(answer); + await iframeLocator.locator('#cmd').press('Enter'); + } + // Alte stiluri: similar + + // Asteapta nextRoom apelat → apare coridorul + if (i < 4) { + await gp.waitForSelector('button:text("Deschide usa")', { timeout: 10000 }); + // Verifica stele si litera in coridor + await expect(gp.locator('[data-stars], .stars, .stele')).toBeVisible({ timeout: 3000 }); + // Click "Deschide usa" + await gp.locator('button:text("Deschide usa")').click(); + } + } + + // Ecranul final + await expect(gp.locator('#fOverlay, [data-final]')).toBeVisible({ timeout: 10000 }); + // Cuvantul magic = ABCDE (primele 5 litere) + const finalText = await gp.content(); + expect(finalText).toMatch(/A.*B.*C.*D.*E/); + + expect(gameErrors, 'Game errors: ' + gameErrors.join('\n')).toHaveLength(0); + expect(errors, 'Builder errors: ' + errors.join('\n')).toHaveLength(0); + }); + + test('resume — reload mid-campanie returneaza la coridor (safeStore D3+D11) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta safeStore+hash din T7 de la integrator'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(3); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Start si completa camera 1 (progreseaza dincolo de ea) + await gp.locator('button:text("Incepe aventura")').click(); + // ... rezolva camera 1 ... + await gp.waitForSelector('button:text("Deschide usa")', { timeout: 12000 }); + + // Reload mid-campanie (inainte de camera 2) + await gp.reload(); + + // Trebuie sa se reia la coridor, NU la intro + await expect(gp.locator('button:text("Deschide usa")')).toBeVisible({ timeout: 5000 }); + // Intro-ul NU trebuie sa fie vizibil + const hasIntro = await gp.locator('button:text("Incepe aventura")').isVisible(); + expect(hasIntro).toBe(false); + + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('camera moarta — template stricat → skip 0 stele + cod eroare vizibil @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta roomReady/roomError+timeout T3 de la integrator'); + // Contractul: un template de camera care arunca eroare inainte de roomReady + // → coridorul afiseaza "usa intepenita" cu: + // - 0 stele + // - cod eroare "stil·idx" monospace mic + // - buton "Sari la camera urmatoare" + // + // La export final: litera camerei sarite = dala goala cu lakat. + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(3); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + await gp.locator('button:text("Incepe aventura")').click(); + + // Injecteaza o eroare in prima camera dupa montare + await gp.waitForFunction( + () => document.querySelector('iframe[data-room]') !== null, + { timeout: 6000 } + ); + await gp.evaluate(() => { + const iframe = document.querySelector('iframe[data-room]'); + if (iframe?.contentWindow) { + iframe.contentWindow.dispatchEvent(new ErrorEvent('error', { + message: 'Template stricat', error: new Error('Template stricat') + })); + } + }); + + // Coridorul trebuie sa arate "usa intepenita" cu 0 stele si cod eroare + await expect(gp.locator(':text("intepenita"), :text("Sari")')).toBeVisible({ timeout: 8000 }); + const corridorText = await gp.content(); + expect(corridorText).toMatch(/classic[·.]0|terminal[·.]0|arcade[·.]0/i); // cod eroare + + // Stars = 0 pentru camera sarita + await expect(gp.locator(':text("0 ★"), :text("0/")').first()).toBeVisible(); + + expect(gameErrors.filter(e => !e.includes('Template stricat')), gameErrors.join('\n')) + .toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('eroare post-ready — acelasi skip ca camera moarta @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta roomError semantic ORICAND T3+D5 de la integrator'); + // Camera apeleaza roomReady() dar arunca o eroare async mai tarziu + // → acelasi overlay "usa intepenita" ca si camera moarta + // Specificat in plan: "roomError are semantica ORICAND — si post-ready" + }); + + test('dublu-click "Deschide usa" — idempotent (fara stare corupta) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta guard idempotenta T4+D5 de la integrator'); + // Doua click-uri rapide pe "Deschide usa" NU trebuie: + // - sa monteze doua camere + // - sa corupte idx-ul activ + // - sa dupleze apelurile nextRoom + // Specificat in plan: butonul dezactivat dupa primul click; nextRoom ignorat pt idx deja incheiat + }); + + test('intrebare cu $/$& in text — camera se monteaza corect (D1 replace-functie) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta replace(TOKEN, () => json) D1 de la integrator'); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + // Puzzle cu $ si & in intrebare (ar corupe JSON-ul daca replace e string) + const cfg = { + ...campaignCfg(1), + puzzles: [{ + title: 'Dollar test', type: 'free', + question: 'Costa $10.00 & $& mai mult?', + answer: 'da', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X', + style: 'classic' + }] + }; + + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + const gameErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + + // Asteapta montarea camerei (roomReady) + await gp.waitForFunction( + () => document.querySelector('iframe[data-room]')?.contentDocument != null, + { timeout: 5000 } + ); + + // CFG.puzzles[0].question trebuie sa fie intact + const question = await gp.evaluate(() => { + const iframe = document.querySelector('iframe[data-room]'); + return iframe?.contentWindow?.CFG?.puzzles?.[0]?.question ?? ''; + }); + expect(question).toContain('$10.00'); + expect(question).toContain('$&'); + + expect(gameErrors, gameErrors.join('\n')).toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + + test('campanie 8+ camere — beep functional pana la final @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta gameCampaign + parent.beep D2 de la integrator'); + // Cu 8 camere (peste limita de 5 stiluri), beep() trebuie sa functioneze + // in toate camerele fara sa depaseasca limita de AudioContext a browser-ului. + // Specificat in plan: audio detinut de parinte; camerele apeleaza parent.beep(ok). + }); + + test('campanie: 320x568 fara overflow orizontal (chrome 40px + calc(100vh-chrome)) @campanie', + async ({ page }) => { + test.skip(true, 'Asteapta chrome bar + buget vertical T6+TD4 de la integrator'); + await page.setViewportSize({ width: 320, height: 568 }); + const errors = trackErrors(page); + await page.goto(fileURL('escape-builder.html')); + + const cfg = campaignCfg(2); + const html = await page.evaluate((c) => gameHTML(c), cfg); + + const gp = await page.context().newPage(); + await gp.setViewportSize({ width: 320, height: 568 }); + const gpErrors = trackErrors(gp); + await gp.setContent(html, { waitUntil: 'domcontentloaded' }); + await gp.waitForTimeout(500); + + // Zero scroll orizontal (§Design pct. 11) + const overflow = await gp.evaluate( + () => document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1 + ); + expect(overflow, 'Overflow orizontal la 320x568 in campanie').toBe(false); + + // Chrome 40px sub 600px (§Design pct. 11) + const chromeHeight = await gp.evaluate(() => { + const chrome = document.querySelector('[data-chrome], .campaign-chrome, #chrome'); + return chrome ? chrome.getBoundingClientRect().height : null; + }); + if (chromeHeight !== null) { + expect(chromeHeight, 'Chrome > 40px la 320px').toBeLessThanOrEqual(40); + } + + expect(gpErrors, gpErrors.join('\n')).toHaveLength(0); + expect(errors, errors.join('\n')).toHaveLength(0); + }); + +});