Files
escape-builder/tests/smoke.mjs
Claude Agent a42c960b46 QA #9 — suita completa 21/21 campanie E2E
tests/smoke.mjs: 8 teste @campanie implementate complet (test.skip inlaturat):
- E2E 5 camere cu stiluri rotite → final stele+litere
- Resume safeStore+djb2 (D3+D11)
- Camera moartă — timeout 4s → skip-banner+cod
- Eroare post-ready (D5 semantica ORICAND)
- Dublu-click idempotent (T4+D4)
- $/$& replace-functie (D1)
- 8+ camere beep (D2)
- 320x568 chrome-40px fara overflow (T6+TD4)

CLAUDE.md: ## Testing actualizat — comenzi npx directe, fara npm scripts;
21/21 status curent documentat.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 08:49:45 +00:00

948 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @ts-check
/**
* tests/smoke.mjs — Escape Room Builder smoke & regression tests
*
* Setup (o singura data, fara package.json commitat):
* npm i -D @playwright/test && npx playwright install chromium
*
* Rulare:
* npx playwright test tests/smoke.mjs # suita completa
* npx playwright test tests/smoke.mjs --grep @regresie # regresie (baseline)
* npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E
*
* @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.
// IMPORTANT: al doilea argument al waitForFunction e arg-ul functiei, nu optiunile!
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('<!doctype html');
expect(html, `${style}: contine "undefined"`).not.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
//
// Contractul documentat in plan:
// - gameCampaign(cfg) genereaza HTML cu iframe per camera
// - Fiecare camera apeleaza parent.nextRoom({idx, stars, letter})
// - parent.roomReady(idx) semnaleaza montarea cu succes (data-room-ready attr)
// - 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__', function(){ return json; }) (D1: evita $&)
// - safeStore (try/catch) pentru resume (D3)
// - Hash djb2 peste CFG embedat la export (D11)
// ═══════════════════════════════════════════════════════════════════════
test.describe('Campanie E2E @campanie', () => {
test.describe.configure({ timeout: 90000 });
/** Helper: genereaza cfg de campanie cu N puzzle-uri. */
function campaignCfg(n = 5, forceStyle = null) {
const styles = ['classic', 'terminal', 'arcade', 'chat', 'point'];
return {
title: 'Test Campanie', player: 'Tester', color: '#6d28d9',
style: 'campaign', charName: 'Alex',
story: 'O campanie de test.',
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: forceStyle || styles[i % 5]
}))
};
}
/**
* Genereaza HTML campanie via builder si il scrie la fisier temp (file://).
* Returneza calea. Caller-ul trebuie sa stearga fisierul dupa test (try/finally).
*/
async function writeCampaignHtml(page, cfg, suffix) {
await page.goto(fileURL('escape-builder.html'));
const html = await page.evaluate((c) => gameHTML(c), cfg);
const tmpPath = join(ROOT, 'tests', `.tmp-campaign-${suffix}.html`);
writeFileSync(tmpPath, html);
return tmpPath;
}
/**
* Rezolva o camera din campanie (in iframe #room-frame).
* Presupune ca data-room-ready e deja setat pe iframe.
* Tip: 'free', raspuns: answer.
*/
async function solveRoom(gp, style, answer) {
// Asteapta ca noul room sa semnaleze roomReady
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
const ifl = gp.frameLocator('#room-frame');
if (style === 'classic') {
await ifl.locator('#btnStart').click();
await ifl.locator('#answers input[type=text]').fill(answer);
await ifl.locator('#answers button:text("Verifica")').click();
} else if (style === 'terminal') {
// Asteapta animatia intro + aparitia primului puzzle [1/
await gp.waitForFunction(
() => document.getElementById('room-frame')
?.contentDocument?.getElementById('out')
?.textContent?.includes('[1/'),
null, { timeout: 20000 }
);
await ifl.locator('#cmd').fill(answer);
await ifl.locator('#cmd').press('Enter');
} else if (style === 'arcade') {
// Deschide puzzle-ul prin API + rezolva modal
await gp.evaluate(() => {
const w = document.getElementById('room-frame').contentWindow;
if (typeof w.openPuzzle === 'function') w.openPuzzle(0, w.onDoorSolved);
});
await expect(ifl.locator('#mOverlay')).toBeVisible({ timeout: 3000 });
await ifl.locator('#mAnswers input[type=text]').fill(answer);
await ifl.locator('#mAnswers button:not(.mhint):not(.mclose)').first().click();
// Asteapta inchiderea modalului (animatie 750ms) — la iesire onDoorSolved e apelat
await ifl.locator('#mOverlay').waitFor({ state: 'hidden', timeout: 3000 });
// Triggereaza showFinal → parent.nextRoom (ramura _campaign)
await gp.evaluate(() => {
const w = document.getElementById('room-frame').contentWindow;
if (typeof w.showFinal === 'function') w.showFinal();
});
} else if (style === 'chat') {
// Asteapta aparitia input-ului in composer (dupa animatia intro)
await ifl.locator('#composer input').waitFor({ timeout: 20000 });
await ifl.locator('#composer input').fill(answer);
await ifl.locator('#composer button:not(.chip)').first().click();
} else if (style === 'point') {
// Click pe primul obiect hot → modal
await ifl.locator('g.hot[data-i="0"]').click();
await expect(ifl.locator('#mOverlay')).toBeVisible({ timeout: 3000 });
await ifl.locator('#mAnswers input[type=text]').fill(answer);
await ifl.locator('#mAnswers button:not(.mhint):not(.mclose)').first().click();
// Asteapta inchiderea modalului
await ifl.locator('#mOverlay').waitFor({ state: 'hidden', timeout: 3000 });
// Usa se deschide dupa ce onDoorSolved e apelat (solvedCount >= N)
await ifl.locator('#door.open').waitFor({ timeout: 3000 });
await ifl.locator('#door').click();
}
}
/**
* Asteapta coridorul si apasa "Deschide usa".
* Asteapta si ca coridorul sa dispara (mountRoom apelat) inainte de return —
* asta garanteaza ca data-room-ready al camerei precedente a fost sters.
*/
async function openCorridor(gp) {
await gp.waitForFunction(
() => document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 10000 }
);
await gp.locator('#btn-next').click();
// Asteapta inchiderea coridorului (mountRoom apelat dupa 280ms animatie)
await gp.waitForFunction(
() => !document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 3000 }
);
}
// ─────────────────────────────────────────────────────────────────────
// Test 1: E2E complet — 5 camere, stiluri rotite, final cu stele+cuvant
// ─────────────────────────────────────────────────────────────────────
test('campanie E2E — intro → camere cu stiluri rotite → final cu stele+litere+cuvant corect @campanie',
async ({ page }) => {
test.setTimeout(120000);
const errors = trackErrors(page);
const cfg = campaignCfg(5);
const tmpPath = await writeCampaignHtml(page, cfg, 'e2e');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
// Intro → click start
await expect(gp.locator('#btn-start')).toBeVisible({ timeout: 5000 });
await gp.locator('#btn-start').click();
const styles = ['classic', 'terminal', 'arcade', 'chat', 'point'];
for (let i = 0; i < 5; i++) {
await solveRoom(gp, styles[i], 'r' + (i + 1));
if (i < 4) await openCorridor(gp);
}
// Finale trebuie sa apara
await gp.waitForFunction(
() => document.getElementById('finale')?.classList.contains('show'),
null, { timeout: 10000 }
);
// Litere colectate: A, B, C, D, E
const finWordText = await gp.locator('#fin-word').innerText();
expect(finWordText).toMatch(/A/);
expect(finWordText).toMatch(/B/);
expect(finWordText).toMatch(/C/);
// Stele: "X / 15 ★" (5 camere × 3 max = 15)
const finStars = await gp.locator('#fin-stars').innerText();
expect(finStars).toMatch(/\d+ \/ 15/);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, 'Builder errors:\n' + errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 2: Resume — reload mid-campanie revine la coridor
// ─────────────────────────────────────────────────────────────────────
test('resume — reload mid-campanie returneaza la coridor (safeStore D3+D11) @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'resume');
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Rezolva camera 0 (classic)
await solveRoom(gp, 'classic', 'r1');
// Asteapta coridorul — saveProgress() a fost apelat, sesionStorage are progresul
await gp.waitForFunction(
() => document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 10000 }
);
// Reload — tryResume() trebuie sa redeschida coridorul, NU intro-ul
await gp.reload();
await gp.waitForLoadState('domcontentloaded');
// Coridorul trebuie sa fie vizibil
await expect(gp.locator('#btn-next')).toBeVisible({ timeout: 5000 });
// Intro-ul NU trebuie sa fie vizibil
const introVisible = await gp.locator('#btn-start').isVisible();
expect(introVisible, 'Intro-ul nu ar trebui sa fie vizibil dupa resume').toBe(false);
// Intro overlay nu are clasa show
const introHasShow = await gp.evaluate(
() => document.getElementById('intro')?.classList.contains('show')
);
expect(introHasShow, '#intro.show dupa resume').toBe(false);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 3: Camera moartă — timeout 4s → skip-banner + cod eroare
// ─────────────────────────────────────────────────────────────────────
test('camera moarta — template stricat → skip-banner + cod eroare vizibil @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'dead');
const gp = await page.context().newPage();
// Captura doar pageerror (nu console.warn asteptat de la timeout)
const gameErrors = [];
gp.on('pageerror', err => gameErrors.push(err.message));
try {
await gp.goto('file://' + tmpPath);
// Inlocuieste template-ul 'classic' cu HTML gol care NU apeleaza roomReady
await gp.evaluate(() => {
TPL['classic'] = '<!doctype html><html><body><p>Camera intepenita</p></body></html>';
});
await gp.locator('#btn-start').click();
// Timeout 4s → skip-banner apare in max 9s (4s timeout + 5s marja)
await gp.waitForFunction(
() => document.getElementById('skip-banner')?.classList.contains('show'),
null, { timeout: 9000 }
);
// Codul erorii e vizibil
const skipCode = await gp.locator('#skip-code').innerText();
expect(skipCode).toContain('Cod:');
expect(skipCode).toContain('timeout');
// Butonul "Sari la camera urmatoare" e vizibil
await expect(gp.locator('#btn-skip')).toBeVisible();
// Camera urmatoare (idx=1) se poate deschide
await gp.locator('#btn-skip').click();
// Coridorul sau direct camera urmatoare (daca N>2 ramane coridor)
// Inlocuim si al doilea template sa se deschida coridorul
// skipRoom → showSkipBanner → btn-skip → idx+1 < N → showCorridor
await gp.waitForFunction(
() => document.getElementById('corridor')?.classList.contains('show') ||
document.getElementById('skip-banner')?.classList.contains('show'),
null, { timeout: 5000 }
);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 4: Eroare post-ready — roomError dupa roomReady = acelasi skip
// ─────────────────────────────────────────────────────────────────────
test('eroare post-ready — acelasi skip ca camera moarta @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(2, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'post-ready');
const gp = await page.context().newPage();
const gameErrors = [];
gp.on('pageerror', err => gameErrors.push(err.message));
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Asteapta roomReady pentru camera 0 (data-room-ready setat)
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
// Apeleaza window.roomError direct de pe fereastra orchestratorului (gp)
// roomError are semantica ORICAND — si post-ready (D5)
await gp.evaluate(() => {
window.roomError(0, 'eroare-post-ready-test');
});
// skip-banner trebuie sa apara imediat
await gp.waitForFunction(
() => document.getElementById('skip-banner')?.classList.contains('show'),
null, { timeout: 5000 }
);
const skipCode = await gp.locator('#skip-code').innerText();
expect(skipCode).toContain('Cod:');
await expect(gp.locator('#btn-skip')).toBeVisible();
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 5: Dublu-click "Deschide usa" — idempotent (T4 + D4)
// ─────────────────────────────────────────────────────────────────────
test('dublu-click "Deschide usa" — idempotent (fara stare corupta) @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = campaignCfg(3, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, 'dblclick');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Rezolva camera 0
await solveRoom(gp, 'classic', 'r1');
// Asteapta coridorul
await gp.waitForFunction(
() => document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 10000 }
);
// Primul click (normal) — butonul se dezactiveaza imediat
await gp.locator('#btn-next').click();
// Al doilea click fortat (butonul e disabled dupa primul click)
await gp.locator('#btn-next').click({ force: true });
// Asteapta inchiderea coridorului + mountRoom(1)
await gp.waitForFunction(
() => !document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 3000 }
);
// Camera 1 trebuie sa se monteze exact o singura data
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
// Rezolva camera 1 si verifica starea finala
await solveRoom(gp, 'classic', 'r2');
// Coridorul pentru camera 2 trebuie sa apara (nu sarit din cauza duplicate mount)
await gp.waitForFunction(
() => document.getElementById('corridor')?.classList.contains('show'),
null, { timeout: 10000 }
);
// "Camera 3" trebuie sa fie mentionata in corr-next (nu Camera 4 sau altceva)
const corrNext = await gp.locator('#corr-next').innerText();
expect(corrNext).toMatch(/[Uu]ltima|3/); // e ultima camera sau Camera 3
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 6: $ / $& in text — D1 replace-functie nu corupe JSON-ul
// ─────────────────────────────────────────────────────────────────────
test('intrebare cu $/$& in text — camera se monteaza corect (D1 replace-functie) @campanie',
async ({ page }) => {
const errors = trackErrors(page);
const cfg = {
...campaignCfg(1, 'classic'),
puzzles: [{
title: 'Dollar test', type: 'free',
question: 'Costa $10.00 & $& mai mult?',
answer: 'da', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X',
style: 'classic'
}]
};
const tmpPath = await writeCampaignHtml(page, cfg, 'dollar');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
// Asteapta roomReady
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
// CFG.puzzles[0].question in frame trebuie sa fie intact
const question = await gp.evaluate(() => {
return document.getElementById('room-frame')
?.contentWindow?.CFG?.puzzles?.[0]?.question ?? '';
});
expect(question, 'Intrebarea lipseste din CFG al frame-ului').toBeTruthy();
expect(question).toContain('$10.00');
expect(question).toContain('$&');
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 7: 8+ camere — beep functional (AudioContext D2) pana la final
// ─────────────────────────────────────────────────────────────────────
test('campanie 8+ camere — beep functional pana la final @campanie',
async ({ page }) => {
test.setTimeout(120000);
const errors = trackErrors(page);
const cfg = campaignCfg(8, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, '8rooms');
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.locator('#btn-start').click();
for (let i = 0; i < 8; i++) {
await solveRoom(gp, 'classic', 'r' + (i + 1));
if (i < 7) await openCorridor(gp);
}
// Finale trebuie sa apara
await gp.waitForFunction(
() => document.getElementById('finale')?.classList.contains('show'),
null, { timeout: 10000 }
);
// 8 litere colectate: A-H
const finWord = await gp.locator('#fin-word').innerText();
expect(finWord.replace(/\s/g, '')).toMatch(/[A-H]{1,8}/);
// Stele: "X / 24 ★" (8 × 3 = 24)
const finStars = await gp.locator('#fin-stars').innerText();
expect(finStars).toMatch(/\d+ \/ 24/);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
// ─────────────────────────────────────────────────────────────────────
// Test 8: 320x568 — chrome 40px + zero overflow orizontal (T6 + TD4)
// ─────────────────────────────────────────────────────────────────────
test('campanie: 320x568 fara overflow orizontal (chrome 40px + calc(100vh-chrome)) @campanie',
async ({ page }) => {
await page.setViewportSize({ width: 320, height: 568 });
const errors = trackErrors(page);
const cfg = campaignCfg(2, 'classic');
const tmpPath = await writeCampaignHtml(page, cfg, '320x568');
const gp = await page.context().newPage();
await gp.setViewportSize({ width: 320, height: 568 });
const gpErrors = trackErrors(gp);
try {
await gp.goto('file://' + tmpPath);
await gp.waitForTimeout(500); // asteapta CSS aplicat
// 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 bar 40px la viewport < 600px (media query #chrome { height: 40px })
const chromeHeight = await gp.evaluate(() => {
const chrome = document.getElementById('chrome');
return chrome ? chrome.getBoundingClientRect().height : null;
});
expect(chromeHeight, 'chromeHeight null — #chrome nu exista').not.toBeNull();
expect(chromeHeight, 'Chrome > 40px la 320px').toBeLessThanOrEqual(40);
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gpErrors, gpErrors.join('\n')).toHaveLength(0);
expect(errors, errors.join('\n')).toHaveLength(0);
});
});