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:
@@ -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
|
||||
|
||||
149
tests/smoke.mjs
149
tests/smoke.mjs
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user