Iterația 3: Joc-în-URL + QR (compresie, player hash, encoder QR, UI share)

Stage 1: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream deflate-raw,
offline, file://); `SNIP.compressJs` cu helpers inflate (doubled backslashes).

Stage 2: `campaignShell({tplJson,masterExpr,titleExpr,nStyles,bootMode})` refactor;
gameCampaign = wrapper subțire; bootMode='inline' (nop) | 'hash' (player).

Stage 3: `playerHTML()` — toate 5 motoare inline; boot async cu `(async function(){})()`
(fix: lipsea `function`, eroare Unexpected token '{' în Chromium); MASTER din
location.hash deflate-raw; orchestrator în <script type="text/plain" id="run">.

Stage 4: Encoder QR vanilla JS — GF(256), Reed-Solomon ECC L, byte mode, versiuni 1-22,
8 măști + penalty, BCH format/version. `makeQrSvg(text, opts)` → SVG.

Stage 5: UI builder — fieldset distribuie, #btnShare/#btnCopyLink/#btnDownloadPlayer/
#btnPrintQr, #qrBox, #qrCard (print A4). baseUrl în state, deleted din cleanState().

Tests: 41/41 (6 noi @share). Fix test player: necesită __ow.enterDoor(0) după btn-start
(overworld first, then enter room). Demo files: restaurate din git (configs hardcodate în @regresie).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-14 12:58:41 +00:00
parent 8fc8f8040f
commit dba7fff7a2
5 changed files with 725 additions and 42 deletions

View File

@@ -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 (~35 teste).
- `tests/smoke.mjs` — unicul fișier de teste (~41 teste).
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
## Local Contracts
@@ -13,27 +13,30 @@ până la ecranul final, fără erori de consolă.
zero-dependențe. Instalarea dev e o singură dată: `npm i -D @playwright/test && npx playwright install chromium`.
- **Fără npm scripts** — se rulează direct cu `npx`.
- **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`.
HTML temp generat via builder (`gameHTML`) și-l încarcă de pe `file://`. Testele `@share` scriu
player HTML temp în `tests/.tmp-player*.html` (deleted în `finally`).
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
fiecare test asertează `errors.length === 0` la final.
- **Tag-uri:** `@regresie` (16 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
stil top-level invalid la import + bomberman gameplay + bomberman rază/powerup-uri) și `@campanie`
(21 — intro→hartă→camere→final, resume, cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil,
audio S1, voce/narațiune D10, a11y tap/aria/reduced-motion, navigare overworld, timer calm T10,
muzica ambient T10, diploma A4, adventure branch-jump, adventure resume non-contiguu,
adventure regression non-adventure, adventure branch tf).
- **Status țintă: 35/35 PASS.**
- **Tag-uri:** `@regresie` (16), `@campanie` (21), `@share` (6 — Iterația 3):
- `@share compresie round-trip` — deflate/inflate builder
- `@share QR structural` — makeQrSvg SVG valid
- `@share playerHTML()` — structura HTML player
- `@share player porneste din hash` — campanie 1 cameră din URL hash; folosește `__ow.enterDoor(0)`
- `@share player fara hash` — mesaj „Niciun joc"
- `@share share UI` — butoane disabled fără CompressionStream
- **Status țintă: 41/41 PASS.**
## Work Guidance
- După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă
adaugi/schimbi un stil, `@campanie` pentru contractul de montare.
adaugi/schimbi un stil, `@campanie` pentru contractul de montare, `@share` pentru Iterația 3.
- Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori.
## Verification
```bash
npx playwright test tests/smoke.mjs # 35/35
npx playwright test tests/smoke.mjs --grep @regresie
npx playwright test tests/smoke.mjs --grep @campanie
npx playwright test tests/smoke.mjs # 41/41
npx playwright test tests/smoke.mjs --grep @regresie # 16
npx playwright test tests/smoke.mjs --grep @campanie # 21
npx playwright test tests/smoke.mjs --grep @share # 6
```
## Child DOX Index

View File

@@ -1656,3 +1656,152 @@ test.describe('Campanie E2E @campanie', () => {
});
});
// ═══════════════════════════════════════════════════════════════════════
// SECTIUNEA 5 — SHARE (link + QR + player.html) @share
// ═══════════════════════════════════════════════════════════════════════
test.describe('Share: link + QR + player @share', () => {
test('@share compresie round-trip: inflate(deflate(s)) === s', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const ok = await page.evaluate(async () => {
if (typeof deflateToBase64url !== 'function') return 'missing deflate';
if (typeof inflateFromBase64url !== 'function') return 'missing inflate';
const s = JSON.stringify({ title: 'Test', puzzles: [{ question: 'x', answer: '42' }] });
const compressed = await deflateToBase64url(s);
if (typeof compressed !== 'string' || compressed.length === 0) return 'empty compressed';
const decompressed = await inflateFromBase64url(compressed);
return decompressed === s ? 'ok' : 'mismatch';
});
expect(ok, 'round-trip result').toBe('ok');
expect(errors).toHaveLength(0);
});
test('@share QR structural: makeQrSvg produce SVG valid', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const result = await page.evaluate(() => {
if (typeof makeQrSvg !== 'function') return { err: 'makeQrSvg missing' };
const svg = makeQrSvg('https://example.com/play.html#abc123');
if (!svg) return { err: 'null result' };
return { hasViewBox: svg.includes('viewBox'), hasPath: svg.includes('<path'), len: svg.length };
});
expect(result.err, 'error').toBeUndefined();
expect(result.hasViewBox, 'viewBox').toBe(true);
expect(result.hasPath, '<path').toBe(true);
expect(result.len, 'svg length').toBeGreaterThan(100);
expect(errors).toHaveLength(0);
});
test('@share playerHTML() genereaza HTML cu inflate + script run + TPL', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const result = await page.evaluate(() => {
if (typeof playerHTML !== 'function') return { err: 'playerHTML missing' };
const html = playerHTML();
return {
hasInflate: html.includes('inflateFromBase64url'),
hasRunScript: html.includes('text/plain'),
hasTPL: html.includes('var TPL'),
len: html.length
};
});
expect(result.err, 'error').toBeUndefined();
expect(result.hasInflate, 'inflate helper').toBe(true);
expect(result.hasRunScript, 'text/plain run script').toBe(true);
expect(result.hasTPL, 'var TPL').toBe(true);
expect(result.len).toBeGreaterThan(5000);
expect(errors).toHaveLength(0);
});
test('@share player porneste din hash — campanie 1 camera, final vizibil', async ({ page }) => {
test.setTimeout(60000);
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const { playerHtml, hash } = await page.evaluate(async () => {
const cfg = {
title: 'Test Player', player: '', color: '#6d28d9', style: 'campaign', creator: '',
charName: 'Alex', voice: false, music: false, adventure: false, timerMin: 0,
puzzles: [{ title: 'P1', type: 'free', question: 'Cat fac 1+1?', answer: '2',
tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: {} }],
story: 'Povestea', finalMessage: 'Bravo!'
};
const compressed = await deflateToBase64url(JSON.stringify(cfg));
return { playerHtml: playerHTML(), hash: compressed };
});
const tmpPath = join(ROOT, 'tests', '.tmp-player.html');
writeFileSync(tmpPath, playerHtml);
const gp = await page.context().newPage();
const gameErrors = trackErrors(gp);
const consoleLogs = [];
gp.on('console', m => consoleLogs.push(m.type() + ': ' + m.text()));
try {
await gp.goto('file://' + tmpPath + '#' + hash);
await gp.waitForFunction(() => !!window.MASTER, { timeout: 10000 }).catch(e => { throw new Error('MASTER not set. Console: ' + consoleLogs.slice(0,5).join(' | ')); });
const title = await gp.evaluate(() => window.MASTER.title);
expect(title, 'MASTER.title').toBe('Test Player');
await gp.locator('#btn-start').click();
/* after start, overworld is shown; navigate to room 0 */
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
await gp.evaluate(() => window.__ow.enterDoor(0));
await gp.waitForFunction(
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
null, { timeout: 8000 }
);
const ifl = gp.frameLocator('#room-frame');
await ifl.locator('#btnStart').click();
await ifl.locator('#answers input[type=text]').fill('2');
await ifl.locator('#answers button:text("Verifica")').click();
await gp.waitForTimeout(1200);
await gp.waitForFunction(() => {
const fin = document.getElementById('finale');
return fin && fin.classList.contains('show');
}, { timeout: 10000 });
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(gameErrors, 'Game errors:\n' + gameErrors.join('\n')).toHaveLength(0);
expect(errors).toHaveLength(0);
});
test('@share player fara hash — afiseaza mesaj niciun joc', async ({ page }) => {
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const playerHtml = await page.evaluate(() => playerHTML());
const tmpPath = join(ROOT, 'tests', '.tmp-player-empty.html');
writeFileSync(tmpPath, playerHtml);
const gp = await page.context().newPage();
try {
await gp.goto('file://' + tmpPath);
await gp.waitForTimeout(600);
const txt = await gp.locator('#intro-title').textContent({ timeout: 3000 }).catch(() => '');
expect(txt, 'mesaj fara hash').toContain('Niciun joc');
} finally {
await gp.close();
try { unlinkSync(tmpPath); } catch (_) {}
}
expect(errors).toHaveLength(0);
});
test('@share share UI: butoane share disabled fara CompressionStream', async ({ page }) => {
await page.addInitScript(() => {
delete window.CompressionStream;
delete window.DecompressionStream;
});
const errors = trackErrors(page);
await page.goto(fileURL('escape-builder.html'));
const shareDisabled = await page.locator('#btnShare').isDisabled();
const copyDisabled = await page.locator('#btnCopyLink').isDisabled();
expect(shareDisabled, 'btnShare disabled').toBe(true);
expect(copyDisabled, 'btnCopyLink disabled').toBe(true);
const dlEnabled = await page.locator('#btnDownloadPlayer').isEnabled();
expect(dlEnabled, 'btnDownloadPlayer enabled').toBe(true);
expect(errors).toHaveLength(0);
});
});