Compare commits
16 Commits
ba949f43b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56d9340f96 | ||
|
|
4d1774429a | ||
|
|
adc786ceec | ||
|
|
714b68d017 | ||
|
|
510581345a | ||
|
|
07664406ac | ||
|
|
25c6631e68 | ||
|
|
00263829cc | ||
|
|
8998cdc10e | ||
|
|
dba7fff7a2 | ||
|
|
8fc8f8040f | ||
|
|
023df382f0 | ||
|
|
d8cb515545 | ||
|
|
b359bbe50a | ||
|
|
16cd521430 | ||
|
|
bfe9be28d7 |
16
AGENTS.md
16
AGENTS.md
@@ -20,9 +20,10 @@ sursa de adevăr tehnică pentru agenți.
|
|||||||
python3 -m http.server 8000
|
python3 -m http.server 8000
|
||||||
|
|
||||||
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
|
# Teste (Playwright; fără package.json commitat — vezi tests/AGENTS.md):
|
||||||
npx playwright test tests/smoke.mjs # suita completă: 27/27
|
npx playwright test tests/smoke.mjs # suita completă: 41/41
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 15
|
npx playwright test tests/smoke.mjs --grep @regresie # regresie: 16
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 12
|
npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 21
|
||||||
|
npx playwright test tests/smoke.mjs --grep @share # Iterația 3: 6
|
||||||
```
|
```
|
||||||
|
|
||||||
## Durable Rules (repo-wide)
|
## Durable Rules (repo-wide)
|
||||||
@@ -30,11 +31,12 @@ npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 12
|
|||||||
- **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe
|
- **Zero dependențe.** Produsul (fișierele `*.html`) e vanilla HTML/CSS/JS, merge offline de pe
|
||||||
`file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/`
|
`file://`. `node_modules/`, `package.json`, `playwright.config.mjs`, `scratch/`, `test-results/`
|
||||||
sunt **gitignored** — doar dev tooling, nu fac parte din produs.
|
sunt **gitignored** — doar dev tooling, nu fac parte din produs.
|
||||||
- **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~1960 linii), pe secțiuni
|
- **Un singur fișier.** Toată aplicația trăiește în `escape-builder.html` (~3200 linii), pe secțiuni
|
||||||
comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`.
|
comentate: `stare` · `editor` · `preview` · `template-urile jocului exportat`.
|
||||||
- **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare:
|
- **Dispatch.** `gameHTML(cfg)` rutează pe `cfg.style` către 6 motoare:
|
||||||
`gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează
|
`gameClassic · gameTerminal · gameArcade · gameChat · gamePoint · gameCampaign`. Fiecare returnează
|
||||||
un string HTML complet, standalone.
|
un string HTML complet, standalone. `playerHTML()` generează player universal (hash-mode, toate 5
|
||||||
|
motoare inline, MASTER din `location.hash` comprimat deflate-raw+base64url).
|
||||||
- **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`,
|
- **Cod partajat = blast radius global.** `libJS(cfg)` (`CFG`, `norm`, `checkAnswer`, `starsFor`,
|
||||||
`finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în
|
`finalWord`, `beep`, `confetti`) și `SNIP.*` (`baseCss`, modal, ecran final) sunt injectate în
|
||||||
TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit.
|
TOATE motoarele. O schimbare aici → verifică fiecare stil în preview înainte de commit.
|
||||||
@@ -45,6 +47,10 @@ npx playwright test tests/smoke.mjs --grep @campanie # campanie E2E: 12
|
|||||||
- **Demo-urile sunt generate.** `exemplu-*.html` = jocuri exportate din builder, unul per stil.
|
- **Demo-urile sunt generate.** `exemplu-*.html` = jocuri exportate din builder, unul per stil.
|
||||||
**NU le edita manual** — după modificări la motoare, regenerează prin export. `index.html` = doar
|
**NU le edita manual** — după modificări la motoare, regenerează prin export. `index.html` = doar
|
||||||
landing care leagă builder-ul + demo-urile.
|
landing care leagă builder-ul + demo-urile.
|
||||||
|
- **`play.html` este generat.** Player universal (toate 5 motoare inline, boot din hash). Generat cu
|
||||||
|
`playerHTML()` din builder și commitat în repo pentru GitHub Pages. **Regenerează după orice
|
||||||
|
modificare la motoare:** `node --input-type=module < /tmp/gen-player.mjs` (sau echivalent Playwright)
|
||||||
|
→ `git add play.html && git commit && git push github main`.
|
||||||
- **Stare.** Obiectul `state` (titlu, poveste, culoare, `style`, `puzzles`) se persistă în
|
- **Stare.** Obiectul `state` (titlu, poveste, culoare, `style`, `puzzles`) se persistă în
|
||||||
`localStorage` sub cheia `escape-builder-v1`; export/import ca JSON. Editorul scrie via `data-g` →
|
`localStorage` sub cheia `escape-builder-v1`; export/import ca JSON. Editorul scrie via `data-g` →
|
||||||
`onChange()` → persist + `refreshPreview()` (debounce 400ms) care setează `iframe.srcdoc`.
|
`onChange()` → persist + `refreshPreview()` (debounce 400ms) care setează `iframe.srcdoc`.
|
||||||
|
|||||||
10
DESIGN.md
10
DESIGN.md
@@ -217,7 +217,10 @@ Mesajul creatorului
|
|||||||
|
|
||||||
## 10. Diplomă — Certificat A4 Print-First (§Design pct. 9 — Etapa 2 / PR2)
|
## 10. Diplomă — Certificat A4 Print-First (§Design pct. 9 — Etapa 2 / PR2)
|
||||||
|
|
||||||
> Implementare în T10/PR2. Spec inclusă aici ca parte a contractului de design.
|
> **LIVRAT** (2026-06-13). Overlay `#diploma`; buton „Vezi diploma →" pe finale + „Joacă din nou".
|
||||||
|
> `buildDiploma()` citește `roomStars`/`collected`/`skipped`/`MASTER`/`_timerExpired`. Câmp builder nou
|
||||||
|
> `creator` („Creat de"). `@media print` izolează `#diploma` (rest `visibility:hidden`). Test smoke
|
||||||
|
> „diploma" (nume/titlu/stele-per-cameră/cuvânt/creator/înapoi). Camere sărite = 🔒 (verificat vizual).
|
||||||
|
|
||||||
- **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)`
|
- **Format:** A4 portret, fundal ALB, chenar dublu `var(--c-accent)`
|
||||||
- **Titlu:** „DIPLOMĂ DE EVADARE" — **singurul** element cu font serif (limbajul certificatelor)
|
- **Titlu:** „DIPLOMĂ DE EVADARE" — **singurul** element cu font serif (limbajul certificatelor)
|
||||||
@@ -231,7 +234,10 @@ Mesajul creatorului
|
|||||||
|
|
||||||
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
|
## 11. Timer Calm (§Design pct. 10 — Etapa 2 / PR2)
|
||||||
|
|
||||||
> Implementare în T10/PR2.
|
> **LIVRAT** (2026-06-13). Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără).
|
||||||
|
> Implementare: `#chrome-timer` în bara chrome; `startTimer/tickTimer/stopTimer`; deadline absolut
|
||||||
|
> în `sessionStorage` (`_DEADLINE_KEY`). Sub 1 min → `.low` (auriu); expirat → `.expired` (auriu, opac).
|
||||||
|
> Test smoke „timer calm" (format, gold, freeze, resume păstrează ceasul).
|
||||||
|
|
||||||
- Pornește **exact** la click „Începe aventura" (intro necronometrat)
|
- Pornește **exact** la click „Începe aventura" (intro necronometrat)
|
||||||
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)
|
- Afișat în chrome: `M:SS`, neutru (`color: var(--c-ink)`)
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -1,6 +1,30 @@
|
|||||||
# Escape Room Builder
|
# Escape Room Builder
|
||||||
|
|
||||||
Generator de jocuri escape room intr-un singur fisier HTML, fara backend, fara build. Acelasi set de puzzle-uri poate fi exportat in 5 stiluri de joc diferite.
|
**[▶ Demo live](https://romfast.github.io/escape-builder/escape-builder.html)** · **[Player universal](https://romfast.github.io/escape-builder/play.html)**
|
||||||
|
|
||||||
|
Generator de jocuri escape room intr-un singur fisier HTML, fara backend, fara build. Acelasi set de puzzle-uri poate fi exportat in 5 stiluri de joc diferite sau ca **campanie** multi-camera cu harta.
|
||||||
|
|
||||||
|
## Distribuie prin link + QR
|
||||||
|
|
||||||
|
Builder-ul poate comprima jocul intr-un URL scurt si genera un cod QR printabil:
|
||||||
|
|
||||||
|
1. Adauga puzzle-uri in editor
|
||||||
|
2. Apasa **„Generează QR / link"** — apare QR-ul + URL-ul complet
|
||||||
|
3. Trimite URL-ul sau printeaza cardul QR (buton „Printează cardul QR")
|
||||||
|
4. Jucatorul deschide link-ul pe telefon — campania porneste instant
|
||||||
|
|
||||||
|
URL-ul pointeaza spre player-ul universal hostat pe GitHub Pages:
|
||||||
|
`https://romfast.github.io/escape-builder/play.html#<joc-comprimat>`
|
||||||
|
|
||||||
|
Jocul calatoreste comprimat in URL (deflate-raw + base64url); playerul il decodeaza local,
|
||||||
|
fara server, fara baza de date. 12+ puzzle-uri incap in ~636 bytes.
|
||||||
|
|
||||||
|
> **`play.html` este un fișier generat** — conține toate 5 motoare de joc inline.
|
||||||
|
> Dupa modificari la motoare, regenereaza si recommita:
|
||||||
|
> ```bash
|
||||||
|
> node --input-type=module < /tmp/gen-player.mjs # sau din builder: playerHTML()
|
||||||
|
> git add play.html && git commit -m "regen play.html" && git push github main
|
||||||
|
> ```
|
||||||
|
|
||||||
## Folosire
|
## Folosire
|
||||||
|
|
||||||
@@ -13,12 +37,11 @@ cd /workspace/escape-builder
|
|||||||
python3 -m http.server 8000
|
python3 -m http.server 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Apoi navigheaza la `http://<IP-server>:8000/escape-builder.html` (in containerul curent: `http://10.0.20.171:8000/escape-builder.html`).
|
|
||||||
|
|
||||||
- **Stanga**: editor — titlu, poveste, culoare, **stil joc**, puzzle-uri (raspuns liber / adevarat-fals / variante), indiciu si litera per puzzle.
|
- **Stanga**: editor — titlu, poveste, culoare, **stil joc**, puzzle-uri (raspuns liber / adevarat-fals / variante), indiciu si litera per puzzle.
|
||||||
- **Dreapta**: preview live — jocul exact cum va arata, jucabil direct in pagina.
|
- **Dreapta**: preview live — jocul exact cum va arata, jucabil direct in pagina.
|
||||||
- **Exporta jocul HTML**: descarca un joc standalone pe care il trimiti pe telefon/email; merge offline.
|
- **Exporta jocul HTML**: descarca un joc standalone pe care il trimiti pe telefon/email; merge offline.
|
||||||
- **Salveaza / Incarca JSON**: pastreaza proiectul ca fisier ca sa-l reiei mai tarziu.
|
- **Salveaza / Incarca JSON**: pastreaza proiectul ca fisier ca sa-l reiei mai tarziu.
|
||||||
|
- **Generează QR / link**: comprima jocul intr-un URL + afiseaza cod QR printabil.
|
||||||
|
|
||||||
Proiectul curent se salveaza automat in `localStorage` la fiecare modificare.
|
Proiectul curent se salveaza automat in `localStorage` la fiecare modificare.
|
||||||
|
|
||||||
@@ -41,12 +64,13 @@ Proiectul curent se salveaza automat in `localStorage` la fiecare modificare.
|
|||||||
|
|
||||||
## Testare
|
## Testare
|
||||||
|
|
||||||
Suita de teste Playwright (smoke + campanie), fara server, direct pe `file://`:
|
Suita de teste Playwright (smoke + campanie + share), fara server, direct pe `file://`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx playwright test tests/smoke.mjs # toata suita (26/26)
|
npx playwright test tests/smoke.mjs # toata suita (41/41)
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie
|
npx playwright test tests/smoke.mjs --grep @regresie
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie
|
npx playwright test tests/smoke.mjs --grep @campanie
|
||||||
|
npx playwright test tests/smoke.mjs --grep @share
|
||||||
```
|
```
|
||||||
|
|
||||||
Detalii harness in `tests/AGENTS.md`.
|
Detalii harness in `tests/AGENTS.md`.
|
||||||
|
|||||||
144
TODOS.md
144
TODOS.md
@@ -18,7 +18,7 @@ Referință plan complet: `~/.gstack/projects/romfast-escape-builder/ceo-plans/2
|
|||||||
- [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26.
|
- [x] **Audit a11y motoare** — LIVRAT (vezi §dedicată mai jos). Smoke 26/26.
|
||||||
|
|
||||||
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
|
**PR2 livrat (2026-06-13):** audio camere `651025b`, voce `da93d84`, unificare `ab11089`, a11y (acest commit).
|
||||||
Rămas din Etapa 2: D7 (migrare classic pe libJS+SNIP) + muzică timer (T10) + Adventure Mode v0.
|
Rămas din Etapa 2: Adventure Mode v0. (D7 + Timer Calm + Muzică T10 + Diplomă LIVRATE — vezi §§ mai jos.)
|
||||||
|
|
||||||
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
|
### [x] Bomberman polish (feedback user 2026-06-13) — LIVRAT
|
||||||
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
|
Trei probleme raportate + o lipsă, toate în `gameArcade` (`escape-builder.html`):
|
||||||
@@ -96,12 +96,40 @@ portează în `escape-builder.html` (un singur fișier, integrare secvențială)
|
|||||||
|
|
||||||
## Post-PR1 (după ship-ul campaniei)
|
## Post-PR1 (după ship-ul campaniei)
|
||||||
|
|
||||||
### Muzică accelerată la timer (PR2 / T10)
|
### [x] Diplomă A4 print-first (§Design pct.9) — LIVRAT (2026-06-13)
|
||||||
- Audio ambient în campanie: track calm → accelerare progresivă sub 1 minut.
|
Certificat A4 portret, fundal alb, chenar dublu accent, titlu serif „DIPLOMĂ DE EVADARE" (singurul serif),
|
||||||
- Ownership: părintele deține AudioContext; camerele nu știu de muzică.
|
numele copilului = cel mai mare element. Overlay `#diploma`; buton „Vezi diploma →" pe finale (+ „Joacă
|
||||||
- Fallback: zero pedeapsă dacă AudioContext lipsă (webview restricitve).
|
din nou"). `buildDiploma()` randează: rând de stele per cameră (★★★/★★☆; camere sărite = 🔒 „sărită"),
|
||||||
- Edge: muzica se oprește la `speechSynthesis.cancel()` dacă vocea e activă simultan.
|
cuvântul magic în dăle (aceeași iconografie ca finalul, lacăte pentru sărite), footer = dată +
|
||||||
- Legat de: T10 (PR2), timer countdown în bara chrome (§Design pct. 10).
|
„creat de {creator}" + marcaj auriu „timpul a expirat" (dacă `_timerExpired`). Câmp builder nou `creator`.
|
||||||
|
Per-cameră `roomStars[]` (persistat în resume). `@media print` izolează `#diploma` (rest `visibility:hidden`,
|
||||||
|
`margin:20mm`, `print-color-adjust:exact`). Verificat: smoke 31/31 (test nou „diploma") + screenshot
|
||||||
|
(`scratch/diploma.png`: A4, 🔒 cameră sărită, footer expirat). Rămas din Etapa 2: doar Adventure Mode v0.
|
||||||
|
|
||||||
|
### [x] Timer Calm (§Design pct.10 / T10) — LIVRAT (2026-06-13)
|
||||||
|
Ceas M:SS în bara chrome a campaniei. Opt-in din builder (câmp „Timp limită (minute)", default 0 = fără;
|
||||||
|
`cleanState` coerce la întreg 0..120). Pornește la „Începe aventura" (intro necronometrat); deadline
|
||||||
|
ABSOLUT în `sessionStorage` (`_DEADLINE_KEY`) → resume-ul (reload mid-campanie) NU resetează ceasul.
|
||||||
|
Sub 1 minut → auriu (`.low`); la expirare îngheață pe `0:00` + marcaj discret (`.expired`, auriu opac),
|
||||||
|
jocul curge nestingherit (zero penalizare, stelele rămân). Fără roșu pulsant (public copii) → reduced-motion
|
||||||
|
safe by default. `exemplu-campanie.html` regenerat (rămâne fără timer — opt-in, ca vocea). Verificat:
|
||||||
|
smoke 29/29 (test nou „timer calm": format M:SS, prag auriu, freeze la expirare, jocul continuă, resume
|
||||||
|
păstrează ceasul). Commit: (acest commit). Următorul: muzică T10 (accelerare sub 1 min — depinde de timer).
|
||||||
|
|
||||||
|
### [x] Muzică ambient accelerată la timer (PR2 / T10) — LIVRAT (2026-06-13)
|
||||||
|
Opt-in din builder (checkbox `music`, default off). Orchestrator-only: părintele deține AudioContext
|
||||||
|
(reutilizează `beep._ctx`, deblocat de gestul global); camerele NU știu de muzică. Arpegiu calm pe
|
||||||
|
pentatonică minoră (`_mTick`, oscilatoare sine scurte la ~520ms); tempo **accelerează** spre ~1.8×
|
||||||
|
pe ultimul minut (`musicTempoFactor`, legat de `_deadline`-ul Timer Calm). Buton 🎵/🔇 în bara chrome
|
||||||
|
(`#btn-music`). Edge-uri tratate:
|
||||||
|
- **Duck pe voce:** `voiceSay` setează `u.onstart→duckMusic(true)` / `onend|onerror→duckMusic(false)`;
|
||||||
|
`voiceCancel` și el unduck. Vocea are prioritate (gain muzică × 0.22 cât timp vorbește).
|
||||||
|
- **Fallback fără AudioContext:** tot în `try/catch` → no-op, buton ascuns (zero penalizare).
|
||||||
|
- pornește la „Începe aventura" + la resume; se oprește la `showFinale` (+ toggle).
|
||||||
|
- fără timer → tempo rămâne 1.0 (loop calm, fără accelerare).
|
||||||
|
Hook test `window.__music` (`tempo()`, `state()`). `exemplu-campanie.html` regenerat (rămâne fără
|
||||||
|
muzică — opt-in, ca vocea). Verificat: smoke 30/30 (test nou „muzica ambient": opt-in, start, tempo
|
||||||
|
crește sub 1 min, duck, toggle). Următorul roadmap: Diplomă (§Design pct.9) + Adventure Mode v0.
|
||||||
|
|
||||||
### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2)
|
### [x] Narațiune vocală (SpeechSynthesis, D10) — LIVRAT (PR2)
|
||||||
Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox
|
Feature NOU (nu doar edge-cases — voce nu exista deloc). Opt-in din builder (checkbox
|
||||||
@@ -134,11 +162,35 @@ Acum trăiește o singură dată în `libJS.campaignDone()` (lângă `roomReady`
|
|||||||
Verificat: smoke 25/25 (terminal standalone test 2 + camere terminal în campanie E2E test 1).
|
Verificat: smoke 25/25 (terminal standalone test 2 + camere terminal în campanie E2E test 1).
|
||||||
Referință: planul §Etapa 2 pct. 1; D7.
|
Referință: planul §Etapa 2 pct. 1; D7.
|
||||||
|
|
||||||
### [ ] D7 rămas: migrarea `gameClassic` pe `libJS+SNIP`
|
### [x] D7: migrarea `gameClassic` pe `libJS` — LIVRAT (2026-06-13)
|
||||||
- Classic (escape-builder.html:451) e singurul motor bespoke: propriul `totalStars`, `beep`,
|
Classic era ultimul motor bespoke (propriul `CFG`/`norm`/`beep`/`confetti`, star-logic inline,
|
||||||
inline `finalWord` (dublat de 2 ori în `next()`), propriul modal final `#sFinal`.
|
`finalWord` dublat, payload `parent.nextRoom` inline). Acum injectează `libJS(cfg)` și folosește
|
||||||
- După migrare: classic folosește `libJS.campaignDone()` + `SNIP` ca celelalte 4 → 5/5 uniform.
|
`checkAnswer`/`starsFor`/`finalWord`/`choiceOpts`/`campaignDone`/`roomReady`/`onerror` din libJS
|
||||||
- Necesită regresie manuală pe classic standalone (e demo-ul implicit, cel mai vizibil).
|
ca celelalte 4 motoare → **5/5 uniform** pe contractul de finalizare.
|
||||||
|
- **Decizie de design (păstrată din unificarea `campaignDone`):** UI-ul bespoke al classicului
|
||||||
|
(card `sStart`/`sGame`/`sFinal`) RĂMÂNE. NU am forțat modalul/overlay-ul `SNIP.modal`/`SNIP.final`
|
||||||
|
— classic e quiz inline (nu deschide puzzle-uri dintr-o hartă), iar `#sFinal` e on-theme; forțarea
|
||||||
|
SNIP-ului ar fi regresie vizuală pe demo-ul implicit (cel mai vizibil). Aceeași logică ca terminalul
|
||||||
|
cu finale CRT. „Migrare pe libJS+SNIP" din formularea inițială = în practică migrare pe **libJS**;
|
||||||
|
SNIP-ul modal nu se aplică unui motor non-modal (vezi și terminalul, care nu folosește SNIP.modal).
|
||||||
|
- net −70 linii duplicate; `campaignDone()` rămâne singura sursă a payload-ului `nextRoom`.
|
||||||
|
- `exemplu-clasic.html` regenerat (celelalte demo-uri byte-identice → classic a fost singura atingere).
|
||||||
|
- Verificat: smoke 28/28 (regresie classic standalone test #1 + campanie E2E cu classic ca odaie test #14).
|
||||||
|
Commit: `bfe9be2`.
|
||||||
|
|
||||||
|
### [x] Known improvements — pasă de igienă (2026-06-13)
|
||||||
|
Auditate faptic. Cele mai multe erau **deja livrate** în PR-uri anterioare:
|
||||||
|
- `persist()` try/catch → DEJA (escape-builder.html:211, D12).
|
||||||
|
- `esc(L)` la point SVG → DEJA rezolvat la SURSĂ: `cleanState()` normalizează `letter` la 1 caracter
|
||||||
|
alfanumeric (linia ~407, D13) → un `<` nu mai poate ajunge în scenă.
|
||||||
|
- Validare 0 puzzle-uri → DEJA: export blocat cu alert + preview cu mesaj ghidant (🚪).
|
||||||
|
- `updateHud` „identic" arcade/point → NU era identic (arcade arată vieți/dușmani/bombe/rază; point
|
||||||
|
arată obiecte). REAL duplicat: scor + bara de litere câștigate → extras în `SNIP.hudJs`
|
||||||
|
(`hudLetters(isSolved)`, `isSolved(j)` diferă per motor: doorsSolved vs solvedFlags). Injectat în
|
||||||
|
ambele; demo-uri arcade+point regenerate.
|
||||||
|
- **Stil top-level invalid la import** (singurul gap rămas, T5/D8) → `TOP_STYLES` guard: fallback la
|
||||||
|
`classic` + alert „Stil necunoscut …" la import; idem la load din storage corupt. Test nou smoke
|
||||||
|
(`stil top-level necunoscut → fallback classic + avertisment`).
|
||||||
|
|
||||||
### [x] Audit a11y motoare existente — LIVRAT (sub harness Playwright)
|
### [x] Audit a11y motoare existente — LIVRAT (sub harness Playwright)
|
||||||
Auditat faptic (măsurat, nu presupus). Ce era DEJA OK (din restyle S3, nemodificat):
|
Auditat faptic (măsurat, nu presupus). Ce era DEJA OK (din restyle S3, nemodificat):
|
||||||
@@ -163,29 +215,61 @@ Referință: §Design pct. 13 (TD5, PR2); D19 din plan.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Iterația 2 — Adventure Mode v0
|
### [x] Adventure Mode v0 — LIVRAT (2026-06-13)
|
||||||
*(decizie office-hours: fundația contractului de azi e infrastructura directă)*
|
Opt-in flag `adventure` (default off) → campanie cu ramificare per-răspuns. Zero regresie non-adventure.
|
||||||
|
|
||||||
- Contract de montare (`nextRoom`, `roomReady`, `roomError`) se refolosesc as-is.
|
**E0** — `adventure: false` în `defaultState()`; checkbox `data-gb="adventure"` în builder (lângă voice/music);
|
||||||
- Motoarele noi (orice stil) implementează aceleași 3 puncte + `parent.beep`.
|
`var ADVENTURE = !!MASTER.adventure` în orchestrator.
|
||||||
- `gameCampaign` se extinde cu ramificare: `if (answer === 'left') nextRoom({dir: 'left'})`.
|
|
||||||
- Builder UI: adaugă câmpul "ramificare" per puzzle; drag & drop între camere.
|
|
||||||
- Referință: design doc §NOT in scope "Adventure Mode v0".
|
|
||||||
|
|
||||||
## Iterația 3 — Joc-în-URL + QR
|
**E1** — `_lastGiven` în libJS; `checkAnswer` setează `_lastGiven` pe succes; `campaignDone()` calculează
|
||||||
*(depinde de măsurarea dimensiunii JSON comprimate)*
|
cheia branch (`'*'` free, text pentru tf, index string pentru choice) și o trimite în payload `nextRoom`.
|
||||||
|
|
||||||
- `gameHTML(cfg)` → URL data: sau LZW/gzip → QR code printabil.
|
**E2** — `resolveBranch(idx, key)`: non-adventure→liniar; adventure→`p.branch[key]` (fallback `branch['*']`,
|
||||||
- Open Question 2 din design doc: câte puzzle-uri încap în 2KB (URL QR L)?
|
apoi liniar idx+1); 'end'/out-of-range→'end'. `nextRoom` pe ramura ADVENTURE: 'end'→`owExitUnlocked=true`+
|
||||||
- Alternative: GitHub Pages export automat; sau link scurt cu backend minimal.
|
`showOverworld` cu exit deblocat; număr→`owUnlocked[dest]=true`+`owTargetIdx=dest`+`showOverworld(dest)`.
|
||||||
- Referință: design doc §NOT in scope "Joc-în-URL + QR".
|
|
||||||
|
**E3** — `owCheckEnter`: blocat dacă `ADVENTURE && !owUnlocked[d.idx]`; exit folosește `owExitUnlocked` în
|
||||||
|
loc de `owAllDone()`. `owRefreshDoors`: stilul `.locked` (dim+🔒) pentru ușile nedeblcate; hint/exit
|
||||||
|
folosesc `owExitUnlocked`. `window.__ow.state`: adaugă `owUnlocked`/`owExitUnlocked`.
|
||||||
|
|
||||||
|
**E4** — `saveProgress`: adaugă `doneList`, `owUnlocked`, `owExitUnlocked`, `target`. `tryResume`: pe
|
||||||
|
ADVENTURE reconstruiește din `doneList` (non-contiguu), nu bucla liniară `0..saved.idx`.
|
||||||
|
|
||||||
|
**E5** — `buildDiploma`: camerele `ADVENTURE && !roomDone[i]` → „neexplorată" (nu ☆☆☆ înșelător).
|
||||||
|
|
||||||
|
**E6** — Builder UI: `normalizePuzzle` garantează `p.branch={}`; `cleanState` clampa țintele + strip
|
||||||
|
`branch` când `!adventure`; `puzzleCard` afișează dropdown-uri ramificare per-puzzle (free=1, tf=2,
|
||||||
|
choice=1/opțiune); `data-fb`/`data-bkey` handler în input listener; `adventure` change → `renderPuzzles()`.
|
||||||
|
|
||||||
|
Verificat: smoke 35/35 (4 teste noi: branch-jump, resume non-contiguu, regression non-adventure, tf branch).
|
||||||
|
|
||||||
|
## Iterația 3 — Joc-în-URL + QR ✅ LIVRAT (2026-06-14)
|
||||||
|
|
||||||
|
**Scope livrat:**
|
||||||
|
- [x] **Stage 1 — Compresie URL**: `deflateToBase64url`/`inflateFromBase64url` (CompressionStream native,
|
||||||
|
offline, `file://`). `CS_OK` guard. `SNIP.compressJs` cu helpers inflate (doubled backslashes).
|
||||||
|
- [x] **Stage 2 — Refactor `campaignShell`**: parametrizat `bootMode='inline'|'hash'`. Zero schimbare
|
||||||
|
de comportament pentru inline (35/35 smoke). `chrome-title` + `document.title` setate din MASTER.
|
||||||
|
- [x] **Stage 3 — `playerHTML()` + boot din hash**: player universal (toate 5 motoare); async IIFE
|
||||||
|
(corect `(async function(){...})()`) decomprimă hash → setează MASTER → apendează orchestratorul
|
||||||
|
din `<script type="text/plain" id="run">`. No-hash → "Niciun joc în acest link."
|
||||||
|
- [x] **Stage 4 — Encoder QR**: GF(256), Reed-Solomon, byte mode, ECC L, auto-versiune 1-22,
|
||||||
|
selecție mască + BCH format/version. `makeQrSvg(text, opts)` → SVG string sau `null`.
|
||||||
|
- [x] **Stage 5 — UI builder**: fieldset „Distribuie (link+QR)", `#btnShare`/`#btnCopyLink`/
|
||||||
|
`#btnDownloadPlayer`/`#btnPrintQr`, `#qrBox`, `#qrCard` (print A4). `baseUrl` în state
|
||||||
|
(deleted din `cleanState()` → nu intră în payload). Butoane disabled dacă `!CS_OK`.
|
||||||
|
- [x] **Stage 6 — Docs**: 41/41 smoke, TODOS/AGENTS actualizate.
|
||||||
|
|
||||||
|
**DEFER** (fast-follow): Import-din-hash în builder (`escape-builder.html#hash` → editabil).
|
||||||
|
Reutilizează inflate+Save/Load; adaugă cale async la boot-ul builder (azi sincron).
|
||||||
|
|
||||||
|
Verificat: smoke 41/41. Capabilitate: 12+ puzzle-uri → ~636B → QR v10 L; 30+ puzzle-uri → ~750B.
|
||||||
|
Scan manual cu telefon real: TODO (notat în HANDOFF).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Known improvements (oricând)
|
## Known improvements (oricând)
|
||||||
|
|
||||||
- **`updateHud` duplicat**: arcade linia 1003 și point linia 1283 au funcții identice → consolidat în `SNIP` (T8 din plan, igienă PR1).
|
Toate cele listate inițial au fost rezolvate — vezi „[x] Known improvements — pasă de igienă" mai sus
|
||||||
- **`persist()` fără try/catch**: builder-ul poate crăpa pe storage plin → guard (D12, T8).
|
(updateHud dedup în `SNIP.hudJs`, persist guard D12, esc/letter D13, validare 0 puzzle, stil invalid la
|
||||||
- **`esc(L)` la inserția innerHTML din point** (:1274): un `<` în câmpul `letter` strică scena SVG (D13, T8).
|
import T5/D8). Adaugă aici lucruri noi pe măsură ce apar.
|
||||||
- **Validare 0 puzzle-uri**: export și preview blocate cu mesaj ghidant (T5).
|
|
||||||
- **Stil invalid la import JSON**: avertisment în builder + rotație automată (T5, D8).
|
|
||||||
|
|||||||
1117
escape-builder.html
1117
escape-builder.html
File diff suppressed because it is too large
Load Diff
@@ -206,9 +206,7 @@ function updateHud(){
|
|||||||
var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0;
|
var solved = puzzleProgress ? puzzleProgress.doorsSolved.filter(Boolean).length : 0;
|
||||||
var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0;
|
var alive = enemies ? enemies.filter(function(e){ return e.alive; }).length : 0;
|
||||||
el('hudStep').textContent = '\u2764\ufe0f ' + lives + ' \ud83d\udc7e ' + alive + ' \ud83d\udd13 ' + solved + '/' + N + ' \ud83d\udca3' + (maxBombs || 1) + ' \ud83d\udd25' + (bombRange || 1);
|
el('hudStep').textContent = '\u2764\ufe0f ' + lives + ' \ud83d\udc7e ' + alive + ' \ud83d\udd13 ' + solved + '/' + N + ' \ud83d\udca3' + (maxBombs || 1) + ' \ud83d\udd25' + (bombRange || 1);
|
||||||
el('hudStars').textContent = totalStars + ' \u2605';
|
hudLetters(function(j){ return puzzleProgress && puzzleProgress.doorsSolved[j]; });
|
||||||
var hb = el('hudLetters'); hb.innerHTML = '';
|
|
||||||
for (var j = 0; j < N; j++){ var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue; var s = document.createElement('span'); if (puzzleProgress && puzzleProgress.doorsSolved[j]){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?'; hb.appendChild(s); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----- Bombe + explozii în lanț ----- */
|
/* ----- Bombe + explozii în lanț ----- */
|
||||||
@@ -375,6 +373,16 @@ window.__game = {
|
|||||||
getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; }
|
getTile: function(x, y){ return map && map[y] ? map[y][x] : -1; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function hudLetters(isSolved){
|
||||||
|
el('hudStars').textContent = totalStars + ' \u2605';
|
||||||
|
var hb = el('hudLetters'); hb.innerHTML = '';
|
||||||
|
for (var j = 0; j < CFG.puzzles.length; j++){
|
||||||
|
var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue;
|
||||||
|
var s = document.createElement('span');
|
||||||
|
if (isSolved(j)){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?';
|
||||||
|
hb.appendChild(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
var mIdx = -1, mAtt = 0, mHint = false, mCb = null;
|
var mIdx = -1, mAtt = 0, mHint = false, mCb = null;
|
||||||
el('mHintBtn').onclick = function(){ mHint = true; el('mHintText').style.display = 'block'; };
|
el('mHintBtn').onclick = function(){ mHint = true; el('mHintText').style.display = 'block'; };
|
||||||
el('mClose').onclick = function(){ el('mOverlay').style.display = 'none'; };
|
el('mClose').onclick = function(){ el('mOverlay').style.display = 'none'; };
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -105,15 +105,32 @@
|
|||||||
<script>
|
<script>
|
||||||
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"classic"};
|
var CFG = {"title":"Comoara ascunsa","player":"","color":"#6d28d9","charName":"Alex","story":"O comoara a fost ascunsa, iar singurul drum spre ea trece prin cateva incercari.","finalMessage":"Felicitari! Ai gasit comoara!","puzzles":[{"title":"Incalzirea","type":"free","question":"Cat fac 7 x 8?","answer":"56","tfAnswer":"Adevarat","choices":"","hint":"Tabla inmultirii cu 7.","letter":"D","style":""},{"title":"Adevarat sau fals","type":"tf","question":"Romania are iesire la Marea Neagra.","answer":"","tfAnswer":"Adevarat","choices":"","hint":"","letter":"A","style":""},{"title":"Alege raspunsul","type":"choice","question":"Care este capitala Frantei?","answer":"","tfAnswer":"Adevarat","choices":"*Paris\nLyon\nMarsilia","hint":"Turnul Eiffel.","letter":"R","style":""}],"style":"classic"};
|
||||||
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
document.documentElement.style.setProperty('--accent', CFG.color || '#6d28d9');
|
||||||
|
var totalStars = 0;
|
||||||
|
function el(id){ return document.getElementById(id); }
|
||||||
|
function norm(s){ return String(s).trim().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').replace(/,/g, '.'); }
|
||||||
|
function starsFor(att, hint){ return (hint || att >= 2) ? 1 : (att === 1 ? 2 : 3); }
|
||||||
|
function finalWord(){ var w = ''; for (var i = 0; i < CFG.puzzles.length; i++){ var L = (CFG.puzzles[i].letter || '').trim(); if (L) w += L.toUpperCase(); } return w; }
|
||||||
|
function choiceOpts(p){ return (p.choices || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(o){ return o.charAt(0) === '*' ? o.slice(1).trim() : o; }); }
|
||||||
|
function choiceCorrect(p){ var ls = (p.choices || '').split('\n'); for (var i = 0; i < ls.length; i++){ var l = ls[i].trim(); if (l.charAt(0) === '*') return l.slice(1).trim(); } return ''; }
|
||||||
|
function checkAnswer(p, given){ var exp = p.type === 'tf' ? p.tfAnswer : (p.type === 'choice' ? choiceCorrect(p) : p.answer); return norm(given) !== '' && norm(given) === norm(exp); }
|
||||||
|
function beep(ok){ if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; } try { var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)()); var t = ctx.currentTime; var fs = ok ? [523, 784] : [196]; fs.forEach(function(f, k){ var o = ctx.createOscillator(), g = ctx.createGain(); o.frequency.value = f; o.type = 'triangle'; g.gain.setValueAtTime(0.12, t + k * 0.09); g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25); o.connect(g); g.connect(ctx.destination); o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3); }); } catch (e) {} }
|
||||||
|
function confetti(){ var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6']; for (var i = 0; i < 90; i++){ var c = document.createElement('div'); c.className = 'confetti'; c.style.left = (i * 137 % 100) + 'vw'; c.style.background = colors[i % colors.length]; c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's'; c.style.animationDelay = ((i * 31 % 14) / 10) + 's'; document.body.appendChild(c); } }
|
||||||
|
function roomReady(){ if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} } }
|
||||||
|
/* Contract de finalizare a camerei — un singur loc pentru payload-ul parent.nextRoom
|
||||||
|
(înlocuiește duplicatele din showFinal/finale; D7). Citește totalStars + finalWord() la apel. */
|
||||||
|
function campaignDone(){ if(CFG._campaign){ try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:finalWord().charAt(0)}); }catch(e){} } }
|
||||||
|
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
||||||
|
if(CFG._campaign){
|
||||||
|
/* Mod cameră (§Design pct.12): ascunde h1, progres propriu, restart propriu */
|
||||||
|
var _cs = document.createElement('style');
|
||||||
|
_cs.textContent = 'h1{display:none!important}.progress{display:none!important}.meta{display:none!important}';
|
||||||
|
(document.head || document.documentElement).appendChild(_cs);
|
||||||
|
}
|
||||||
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
|
document.documentElement.style.setProperty('--accent-light', 'color-mix(in srgb, ' + (CFG.color || '#6d28d9') + ' 40%, white)');
|
||||||
|
|
||||||
var idx = 0, totalStars = 0, attempts = 0, hintUsed = false, won = [];
|
var idx = 0, attempts = 0, hintUsed = false, won = [];
|
||||||
|
/* CFG, totalStars, el, norm, beep, confetti, starsFor, finalWord, checkAnswer,
|
||||||
function el(id) { return document.getElementById(id); }
|
choiceOpts, campaignDone, roomReady, window.onerror — toate din libJS (D7) */
|
||||||
function norm(s) {
|
|
||||||
return String(s).trim().toLowerCase().normalize('NFD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '').replace(/\s+/g, ' ').replace(/,/g, '.');
|
|
||||||
}
|
|
||||||
function show(id) {
|
function show(id) {
|
||||||
var scr = document.querySelectorAll('.screen');
|
var scr = document.querySelectorAll('.screen');
|
||||||
for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on');
|
for (var i = 0; i < scr.length; i++) scr[i].classList.remove('on');
|
||||||
@@ -164,7 +181,7 @@ function renderPuzzle() {
|
|||||||
inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...';
|
inp.type = 'text'; inp.autocomplete = 'off'; inp.placeholder = 'Scrie raspunsul...';
|
||||||
var btn = document.createElement('button');
|
var btn = document.createElement('button');
|
||||||
btn.textContent = 'Verifica';
|
btn.textContent = 'Verifica';
|
||||||
btn.onclick = function () { check(inp.value, p.answer); };
|
btn.onclick = function () { check(p, inp.value); };
|
||||||
inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
|
inp.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
|
||||||
box.appendChild(inp); box.appendChild(btn);
|
box.appendChild(inp); box.appendChild(btn);
|
||||||
setTimeout(function () { inp.focus(); }, 50);
|
setTimeout(function () { inp.focus(); }, 50);
|
||||||
@@ -172,20 +189,17 @@ function renderPuzzle() {
|
|||||||
['Adevarat', 'Fals'].forEach(function (v) {
|
['Adevarat', 'Fals'].forEach(function (v) {
|
||||||
var b = document.createElement('button');
|
var b = document.createElement('button');
|
||||||
b.className = 'opt'; b.textContent = v;
|
b.className = 'opt'; b.textContent = v;
|
||||||
b.onclick = function () { check(v, p.tfAnswer); };
|
b.onclick = function () { check(p, v); };
|
||||||
box.appendChild(b);
|
box.appendChild(b);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
var correct = '';
|
var opts = choiceOpts(p);
|
||||||
var opts = (p.choices || '').split('\n').map(function (l) { return l.trim(); }).filter(Boolean);
|
opts.forEach(function (o) {
|
||||||
opts.forEach(function (o) { if (o.charAt(0) === '*') correct = o.slice(1).trim(); });
|
var b = document.createElement('button');
|
||||||
opts.map(function (o) { return o.charAt(0) === '*' ? o.slice(1).trim() : o; })
|
b.className = 'opt'; b.textContent = o;
|
||||||
.forEach(function (o) {
|
b.onclick = function () { check(p, o); };
|
||||||
var b = document.createElement('button');
|
box.appendChild(b);
|
||||||
b.className = 'opt'; b.textContent = o;
|
});
|
||||||
b.onclick = function () { check(o, correct); };
|
|
||||||
box.appendChild(b);
|
|
||||||
});
|
|
||||||
if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)';
|
if (!opts.length) box.textContent = '(puzzle fara variante - completeaza-le in builder)';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -195,9 +209,9 @@ el('btnHint').onclick = function () {
|
|||||||
el('hinttext').style.display = 'block';
|
el('hinttext').style.display = 'block';
|
||||||
};
|
};
|
||||||
|
|
||||||
function check(given, expected) {
|
function check(p, given) {
|
||||||
if (norm(given) === norm(expected) && norm(given) !== '') {
|
if (checkAnswer(p, given)) {
|
||||||
var stars = (hintUsed || attempts >= 2) ? 1 : (attempts === 1 ? 2 : 3);
|
var stars = starsFor(attempts, hintUsed);
|
||||||
totalStars += stars;
|
totalStars += stars;
|
||||||
won[idx] = true;
|
won[idx] = true;
|
||||||
beep(true);
|
beep(true);
|
||||||
@@ -223,19 +237,11 @@ function check(given, expected) {
|
|||||||
function next() {
|
function next() {
|
||||||
idx++;
|
idx++;
|
||||||
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
if (idx < CFG.puzzles.length) { renderPuzzle(); return; }
|
||||||
if(CFG._campaign){
|
if(CFG._campaign){ campaignDone(); return; }
|
||||||
var L = ''; for(var ci=0;ci<CFG.puzzles.length;ci++){var lc=(CFG.puzzles[ci].letter||'').trim();if(lc)L+=lc.toUpperCase();}
|
|
||||||
try{ parent.nextRoom({idx:CFG._campaign.idx, stars:totalStars, letter:L.charAt(0)}); }catch(e){}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
show('sFinal');
|
show('sFinal');
|
||||||
var max = CFG.puzzles.length * 3;
|
var max = CFG.puzzles.length * 3;
|
||||||
el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605';
|
el('finalStars').textContent = totalStars + ' / ' + max + ' \u2605';
|
||||||
var word = '';
|
var word = finalWord();
|
||||||
for (var i = 0; i < CFG.puzzles.length; i++) {
|
|
||||||
var L = (CFG.puzzles[i].letter || '').trim();
|
|
||||||
if (L) word += L.toUpperCase();
|
|
||||||
}
|
|
||||||
var bw = el('bigword');
|
var bw = el('bigword');
|
||||||
bw.innerHTML = '';
|
bw.innerHTML = '';
|
||||||
for (var j = 0; j < word.length; j++) {
|
for (var j = 0; j < word.length; j++) {
|
||||||
@@ -249,37 +255,7 @@ function next() {
|
|||||||
confetti();
|
confetti();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confetti() {
|
roomReady(); /* beep/confetti/onerror/roomReady din libJS (D7) */
|
||||||
var colors = [CFG.color || '#6d28d9', '#fbbf24', '#34d399', '#60a5fa', '#f472b6'];
|
|
||||||
for (var i = 0; i < 90; i++) {
|
|
||||||
var c = document.createElement('div');
|
|
||||||
c.className = 'confetti';
|
|
||||||
c.style.left = (i * 137 % 100) + 'vw';
|
|
||||||
c.style.background = colors[i % colors.length];
|
|
||||||
c.style.animationDuration = (2.2 + (i * 53 % 18) / 10) + 's';
|
|
||||||
c.style.animationDelay = ((i * 31 % 14) / 10) + 's';
|
|
||||||
document.body.appendChild(c);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function beep(ok) {
|
|
||||||
if(CFG._campaign){ try{ parent.beep(ok); }catch(e){} return; }
|
|
||||||
try {
|
|
||||||
var ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
|
|
||||||
var t = ctx.currentTime;
|
|
||||||
var freqs = ok ? [523, 784] : [196];
|
|
||||||
freqs.forEach(function (f, k) {
|
|
||||||
var o = ctx.createOscillator(), g = ctx.createGain();
|
|
||||||
o.frequency.value = f; o.type = 'triangle';
|
|
||||||
g.gain.setValueAtTime(0.12, t + k * 0.09);
|
|
||||||
g.gain.exponentialRampToValueAtTime(0.001, t + k * 0.09 + 0.25);
|
|
||||||
o.connect(g); g.connect(ctx.destination);
|
|
||||||
o.start(t + k * 0.09); o.stop(t + k * 0.09 + 0.3);
|
|
||||||
});
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
|
||||||
window.onerror = function(msg){ if(CFG._campaign){ try{ parent.roomError(CFG._campaign.idx, String(msg)); }catch(e){} } };
|
|
||||||
if(CFG._campaign){ try{ parent.roomReady(CFG._campaign.idx); }catch(e){} }
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -155,14 +155,15 @@ function onSolved(i){
|
|||||||
|
|
||||||
function updateHud(){
|
function updateHud(){
|
||||||
el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N;
|
el('hudStep').textContent = 'Obiecte: ' + solvedCount + '/' + N;
|
||||||
|
hudLetters(function(j){ return solvedFlags[j]; });
|
||||||
|
}
|
||||||
|
function hudLetters(isSolved){
|
||||||
el('hudStars').textContent = totalStars + ' \u2605';
|
el('hudStars').textContent = totalStars + ' \u2605';
|
||||||
var hb = el('hudLetters'); hb.innerHTML = '';
|
var hb = el('hudLetters'); hb.innerHTML = '';
|
||||||
for (var j = 0; j < N; j++) {
|
for (var j = 0; j < CFG.puzzles.length; j++){
|
||||||
var L = (CFG.puzzles[j].letter || '').trim();
|
var L = (CFG.puzzles[j].letter || '').trim(); if (!L) continue;
|
||||||
if (!L) continue;
|
|
||||||
var s = document.createElement('span');
|
var s = document.createElement('span');
|
||||||
if (solvedFlags[j]) { s.textContent = L.toUpperCase(); s.className = 'won'; }
|
if (isSolved(j)){ s.textContent = L.toUpperCase(); s.className = 'won'; } else s.textContent = '?';
|
||||||
else s.textContent = '?';
|
|
||||||
hb.appendChild(s);
|
hb.appendChild(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<p>Builder-ul + cate un joc demo exportat in fiecare stil.</p>
|
<p>Builder-ul + cate un joc demo exportat in fiecare stil.</p>
|
||||||
<a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a>
|
<a class="builder" href="escape-builder.html">Builder <span>editor + preview live; schimba "Stil joc" si vezi transformarea pe loc</span></a>
|
||||||
<a href="exemplu-campanie.html" style="border-color:#a78bfa;background:rgba(167,139,250,.12)">🗺️ Campanie multi-stil <span>hartă overworld: mergi la fiecare ușă, intri într-o cameră într-un alt stil, aduni litere → cuvântul magic</span></a>
|
<a href="exemplu-campanie.html" style="border-color:#a78bfa;background:rgba(167,139,250,.12)">🗺️ Campanie multi-stil <span>hartă overworld: mergi la fiecare ușă, intri într-o cameră într-un alt stil, aduni litere → cuvântul magic</span></a>
|
||||||
|
<a href="play.html" style="border-color:#34d399;background:rgba(52,211,153,.10)">▶ Player universal <span>deschide un joc comprimat din URL (#hash) — trimite link-ul sau scanează QR-ul generat din builder</span></a>
|
||||||
<a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a>
|
<a href="exemplu-clasic.html">Clasic (quiz) <span>carduri secventiale cu progres si litere</span></a>
|
||||||
<a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a>
|
<a href="exemplu-terminal.html">Terminal retro <span>text adventure CRT; scrie raspunsul, INDICIU, LITERE</span></a>
|
||||||
<a href="exemplu-arcade.html">Arcade Bomberman <span>sageti / WASD + bombe; sparge cutiile, evita dusmanii, usi cu intrebari, cufar = scaparea</span></a>
|
<a href="exemplu-arcade.html">Arcade Bomberman <span>sageti / WASD + bombe; sparge cutiile, evita dusmanii, usi cu intrebari, cufar = scaparea</span></a>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Smoke + regresie + campanie E2E pentru jocurile generate. Verifică faptic: fiec
|
|||||||
până la ecranul final, fără erori de consolă.
|
până la ecranul final, fără erori de consolă.
|
||||||
|
|
||||||
## Ownership
|
## Ownership
|
||||||
- `tests/smoke.mjs` — unicul fișier de teste (~27 teste).
|
- `tests/smoke.mjs` — unicul fișier de teste (~41 teste).
|
||||||
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
- `playwright.config.mjs` (la root, **gitignored**) — config dev.
|
||||||
|
|
||||||
## Local Contracts
|
## Local Contracts
|
||||||
@@ -13,25 +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`.
|
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`.
|
- **Fără npm scripts** — se rulează direct cu `npx`.
|
||||||
- **Teste pe `file://`** — helper-ul `fileURL(name)` mapează cale relativă la `file://`; campania scrie
|
- **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`;
|
- **Zero erori consolă = invariant.** `trackErrors(page)` colectează `console.error` + `pageerror`;
|
||||||
fiecare test asertează `errors.length === 0` la final.
|
fiecare test asertează `errors.length === 0` la final.
|
||||||
- **Tag-uri:** `@regresie` (15 — exemplu-*.html + edge cases + mobil 320px + regenerare via gameHTML +
|
- **Tag-uri:** `@regresie` (16), `@campanie` (21), `@share` (6 — Iterația 3):
|
||||||
bomberman gameplay + bomberman rază/powerup-uri) și `@campanie` (12 — intro→hartă→camere→final, resume,
|
- `@share compresie round-trip` — deflate/inflate builder
|
||||||
cameră moartă, idempotență ușă, `$`/`$&`, beep, mobil, audio S1, voce/narațiune D10,
|
- `@share QR structural` — makeQrSvg SVG valid
|
||||||
a11y tap/aria/reduced-motion, navigare overworld).
|
- `@share playerHTML()` — structura HTML player
|
||||||
- **Status țintă: 27/27 PASS.**
|
- `@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
|
## Work Guidance
|
||||||
- După modificări la motoare (`escape-builder.html`): rulează suita completă; extinde `@regresie` dacă
|
- 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.
|
- Nu testa pe screenshot-uri de pixeli — asertează stare/text/erori.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
```bash
|
```bash
|
||||||
npx playwright test tests/smoke.mjs # 27/27
|
npx playwright test tests/smoke.mjs # 41/41
|
||||||
npx playwright test tests/smoke.mjs --grep @regresie
|
npx playwright test tests/smoke.mjs --grep @regresie # 16
|
||||||
npx playwright test tests/smoke.mjs --grep @campanie
|
npx playwright test tests/smoke.mjs --grep @campanie # 21
|
||||||
|
npx playwright test tests/smoke.mjs --grep @share # 6
|
||||||
```
|
```
|
||||||
|
|
||||||
## Child DOX Index
|
## Child DOX Index
|
||||||
|
|||||||
566
tests/smoke.mjs
566
tests/smoke.mjs
@@ -404,6 +404,34 @@ test.describe('Edge cases @regresie', () => {
|
|||||||
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('builder: JSON cu stil top-level necunoscut → fallback classic + avertisment (T5/D8)', async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
let warned = false;
|
||||||
|
page.on('dialog', d => { if (/stil necunoscut/i.test(d.message())) warned = true; d.accept(); });
|
||||||
|
|
||||||
|
await page.goto(fileURL('escape-builder.html'));
|
||||||
|
|
||||||
|
const tmpPath = join(ROOT, 'tests', '.tmp-invalid-style.json');
|
||||||
|
writeFileSync(tmpPath, JSON.stringify({
|
||||||
|
title: 'Test stil', style: 'banana', color: '#6d28d9', charName: 'X',
|
||||||
|
story: 'S', finalMessage: 'F',
|
||||||
|
puzzles: [{ title: 'P1', type: 'free', question: 'Q?', answer: 'A', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'X' }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.locator('#fileLoad').setInputFiles(tmpPath);
|
||||||
|
await page.waitForTimeout(600);
|
||||||
|
} finally {
|
||||||
|
unlinkSync(tmpPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback la classic + avertisment + builder functional
|
||||||
|
await expect(page.locator('#gStyle')).toHaveValue('classic');
|
||||||
|
expect(warned, 'asteptam un alert despre stilul necunoscut').toBe(true);
|
||||||
|
await expect(page.locator('#addPuzzle')).toBeVisible();
|
||||||
|
expect(errors, 'Erori consola:\n' + errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
@@ -643,6 +671,183 @@ test.describe('Campanie E2E @campanie', () => {
|
|||||||
expect(errors, errors.join('\n')).toHaveLength(0);
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('timer calm — porneste la start, auriu sub 1 min, ingheata la expirare, resume pastreaza ceasul (T10/§Design pct.10) @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = campaignCfg(3, 'classic');
|
||||||
|
cfg.timerMin = 1;
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'timer');
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
|
||||||
|
// Intro necronometrat — ceasul e ascuns pana la start
|
||||||
|
expect(await gp.locator('#chrome-timer').isVisible(), 'timer ascuns pe intro').toBe(false);
|
||||||
|
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// Dupa start: vizibil, format M:SS, aproape de 1:00
|
||||||
|
const timer = gp.locator('#chrome-timer');
|
||||||
|
await expect(timer).toBeVisible();
|
||||||
|
await expect(timer).toHaveText(/^\d:\d\d$/);
|
||||||
|
const t0 = await timer.textContent();
|
||||||
|
const sec0 = (+t0.split(':')[0]) * 60 + (+t0.split(':')[1]);
|
||||||
|
expect(sec0, 'ceasul porneste ~1:00').toBeGreaterThan(50);
|
||||||
|
expect(sec0).toBeLessThanOrEqual(60);
|
||||||
|
|
||||||
|
// Deadline ABSOLUT salvat in sessionStorage (resume pastreaza ceasul)
|
||||||
|
const dl = await gp.evaluate(() => +sessionStorage.getItem(_DEADLINE_KEY));
|
||||||
|
expect(dl, 'deadline absolut in sessionStorage').toBeGreaterThan(Date.now());
|
||||||
|
|
||||||
|
// Sub 1 minut → auriu (.low). Manipulam deadline determinist.
|
||||||
|
await gp.evaluate(() => { _deadline = Date.now() + 5000; tickTimer(); });
|
||||||
|
await expect(timer).toHaveText('0:05');
|
||||||
|
await expect(timer).toHaveClass(/low/);
|
||||||
|
|
||||||
|
// Expirare → ingheata pe 0:00 + .expired; jocul curge nestingherit
|
||||||
|
await gp.evaluate(() => { _deadline = Date.now() - 1000; tickTimer(); });
|
||||||
|
await expect(timer).toHaveText('0:00');
|
||||||
|
await expect(timer).toHaveClass(/expired/);
|
||||||
|
|
||||||
|
// Jocul continua dupa expirare (zero penalizare): rezolva camera 0
|
||||||
|
await enterRoom(gp, 0);
|
||||||
|
await solveRoom(gp, 'classic', 'r1');
|
||||||
|
await waitOverworld(gp);
|
||||||
|
|
||||||
|
// Resume pastreaza ceasul: suprascriem deadline-ul cu o valoare cunoscuta, reload
|
||||||
|
await gp.evaluate(() => { sessionStorage.setItem(_DEADLINE_KEY, String(Date.now() + 8000)); });
|
||||||
|
await gp.reload();
|
||||||
|
await gp.waitForLoadState('domcontentloaded');
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => window.__ow && window.__ow.state.active &&
|
||||||
|
document.getElementById('overworld')?.classList.contains('show'),
|
||||||
|
null, { timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ceasul reia de la deadline-ul salvat (~0:08), NU resetat la 1:00
|
||||||
|
await expect(gp.locator('#chrome-timer')).toBeVisible();
|
||||||
|
const tR = await gp.locator('#chrome-timer').textContent();
|
||||||
|
const secR = (+tR.split(':')[0]) * 60 + (+tR.split(':')[1]);
|
||||||
|
expect(secR, 'resume reia ceasul (nu reset la 60)').toBeLessThan(55);
|
||||||
|
expect(secR).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await gp.close();
|
||||||
|
try { unlinkSync(tmpPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('muzica ambient — opt-in, porneste la start, tempo accelereaza sub 1 min, duck pe voce, toggle (T10)',
|
||||||
|
async ({ page }) => {
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = campaignCfg(3, 'classic');
|
||||||
|
cfg.music = true;
|
||||||
|
cfg.timerMin = 1; /* timer pornit → tempo poate accelera */
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'music');
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
|
||||||
|
// Butonul de muzica vizibil (opt-in activ); muzica inca neporita
|
||||||
|
await expect(gp.locator('#btn-music')).toBeVisible();
|
||||||
|
expect(await gp.evaluate(() => window.__music.state().playing), 'inca neporita pe intro').toBe(false);
|
||||||
|
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// Dupa start: muzica ruleaza, buton apasat
|
||||||
|
await gp.waitForFunction(() => window.__music.state().playing === true, null, { timeout: 4000 });
|
||||||
|
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
// Tempo: 1.0 cand >60s ramase; creste progresiv sub 1 min (citit determinist)
|
||||||
|
const tempos = await gp.evaluate(() => {
|
||||||
|
const f = window.__music.tempo;
|
||||||
|
_deadline = Date.now() + 90000; const t90 = f();
|
||||||
|
_deadline = Date.now() + 30000; const t30 = f();
|
||||||
|
_deadline = Date.now() + 1000; const t1 = f();
|
||||||
|
return { t90, t30, t1 };
|
||||||
|
});
|
||||||
|
expect(tempos.t90).toBeCloseTo(1, 1);
|
||||||
|
expect(tempos.t30, 'accelereaza sub 1 min').toBeGreaterThan(tempos.t90);
|
||||||
|
expect(tempos.t1, 'mai rapid spre expirare').toBeGreaterThan(tempos.t30);
|
||||||
|
|
||||||
|
// Duck: vocea are prioritate → atenueaza muzica
|
||||||
|
const ducked = await gp.evaluate(() => { duckMusic(true); return window.__music.state().duck; });
|
||||||
|
const unducked = await gp.evaluate(() => { duckMusic(false); return window.__music.state().duck; });
|
||||||
|
expect(ducked, 'duck activ < 1').toBeLessThan(1);
|
||||||
|
expect(unducked, 'duck dezactivat = 1').toBe(1);
|
||||||
|
|
||||||
|
// Toggle off din buton → se opreste
|
||||||
|
await gp.locator('#btn-music').click();
|
||||||
|
await expect(gp.locator('#btn-music')).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(await gp.evaluate(() => window.__music.state().playing), 'oprit dupa toggle').toBe(false);
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
await gp.close();
|
||||||
|
try { unlinkSync(tmpPath); } catch (_) {}
|
||||||
|
}
|
||||||
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('diploma — „Vezi diploma" arata certificat A4 cu nume/stele-per-camera/cuvant/creator, inapoi (§Design pct.9)',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.setTimeout(120000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = campaignCfg(3, 'classic');
|
||||||
|
cfg.player = 'Maria';
|
||||||
|
cfg.creator = 'Doamna Ana';
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'diploma');
|
||||||
|
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 < 3; i++) {
|
||||||
|
await enterRoom(gp, i);
|
||||||
|
await solveRoom(gp, 'classic', 'r' + (i + 1));
|
||||||
|
}
|
||||||
|
await gp.waitForFunction(() => document.getElementById('finale')?.classList.contains('show'),
|
||||||
|
null, { timeout: 10000 });
|
||||||
|
|
||||||
|
// Buton „Vezi diploma" → diploma vizibila, finale ascunsa
|
||||||
|
await gp.locator('#btn-diploma').click();
|
||||||
|
await expect(gp.locator('#diploma')).toBeVisible();
|
||||||
|
await expect(gp.locator('#finale')).not.toBeVisible();
|
||||||
|
|
||||||
|
// Numele copilului (cel mai mare element) + titlul jocului
|
||||||
|
await expect(gp.locator('#dipl-name')).toHaveText('Maria');
|
||||||
|
await expect(gp.locator('#dipl-game')).toContainText('Test Campanie');
|
||||||
|
|
||||||
|
// Rand de stele per camera: 3 randuri, fiecare cu ★ (rezolvate, nu sarite)
|
||||||
|
await expect(gp.locator('#dipl-rooms .dipl-room')).toHaveCount(3);
|
||||||
|
const firstRoom = await gp.locator('#dipl-rooms .dipl-room .rstars').first().innerText();
|
||||||
|
expect(firstRoom).toMatch(/[★☆]{3}/);
|
||||||
|
|
||||||
|
// Cuvantul magic in dale (3 litere colectate, A B C)
|
||||||
|
await expect(gp.locator('#dipl-word span')).toHaveCount(3);
|
||||||
|
|
||||||
|
// Footer: data + „creat de Doamna Ana"
|
||||||
|
await expect(gp.locator('#dipl-footer')).toContainText('creat de Doamna Ana');
|
||||||
|
|
||||||
|
// Butonul de print exista (nu il apasam — window.print blocheaza headless)
|
||||||
|
await expect(gp.locator('#dipl-print')).toBeVisible();
|
||||||
|
|
||||||
|
// Inapoi → finale din nou
|
||||||
|
await gp.locator('#dipl-back').click();
|
||||||
|
await expect(gp.locator('#finale')).toBeVisible();
|
||||||
|
await expect(gp.locator('#diploma')).not.toBeVisible();
|
||||||
|
|
||||||
|
} 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 3: Camera moartă — timeout 4s → skip-banner + cod eroare
|
// Test 3: Camera moartă — timeout 4s → skip-banner + cod eroare
|
||||||
// ─────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -1245,4 +1450,365 @@ test.describe('Campanie E2E @campanie', () => {
|
|||||||
expect(errors, errors.join('\n')).toHaveLength(0);
|
expect(errors, errors.join('\n')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
// Adventure Mode tests (E0-E6)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Helper: genereaza cfg de campanie cu adventure ON. */
|
||||||
|
function adventureCfg(puzzles) {
|
||||||
|
return {
|
||||||
|
title: 'Test Adventure', player: 'Tester', color: '#6d28d9',
|
||||||
|
style: 'campaign', charName: 'Alex',
|
||||||
|
story: 'Aventura de test.',
|
||||||
|
finalMessage: 'Ai terminat aventura!',
|
||||||
|
adventure: true,
|
||||||
|
puzzles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('adventure — branch-jump: room0→2 (sare room1), room2→exit, diploma neexplorata @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.setTimeout(90000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = adventureCfg([
|
||||||
|
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
|
||||||
|
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
|
||||||
|
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
|
||||||
|
]);
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-jump');
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// Enter room 0 (door 0 unlocked in adventure)
|
||||||
|
await enterRoom(gp, 0);
|
||||||
|
await solveRoom(gp, 'classic', 'r0');
|
||||||
|
|
||||||
|
// After solving room 0: overworld with door 2 unlocked, door 1 locked
|
||||||
|
await waitOverworld(gp);
|
||||||
|
const stAfter0 = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(stAfter0.owUnlocked[2], 'door 2 trebuie deblocata dupa room0').toBeTruthy();
|
||||||
|
expect(stAfter0.owUnlocked[1], 'door 1 trebuie sa ramana incuiata').toBeFalsy();
|
||||||
|
expect(stAfter0.doors[0].solved, 'room 0 trebuie sa fie done').toBe(true);
|
||||||
|
|
||||||
|
// Door 1 should be locked — entering it should be blocked (stay in overworld)
|
||||||
|
await gp.evaluate(() => window.__ow.enterDoor(1));
|
||||||
|
await gp.waitForTimeout(300);
|
||||||
|
const stLocked = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(stLocked.active, 'harta trebuie sa ramana activa cand usa e incuiata').toBe(true);
|
||||||
|
|
||||||
|
// Enter room 2 (unlocked)
|
||||||
|
await enterRoom(gp, 2);
|
||||||
|
await solveRoom(gp, 'classic', 'r2');
|
||||||
|
|
||||||
|
// After solving room 2: exit should be unlocked
|
||||||
|
await waitOverworld(gp);
|
||||||
|
const stAfter2 = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(stAfter2.owExitUnlocked, 'exit trebuie deblocat dupa room2→end').toBe(true);
|
||||||
|
|
||||||
|
// Enter exit → finale
|
||||||
|
await gp.evaluate(() => window.__ow.enterExit());
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.getElementById('finale')?.classList.contains('show'),
|
||||||
|
null, { timeout: 5000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open diploma → camera 1 should be "neexplorata"
|
||||||
|
await gp.locator('#btn-diploma').click();
|
||||||
|
const diplomaText = await gp.locator('#dipl-rooms').innerText();
|
||||||
|
expect(diplomaText).toMatch(/neexplorat/i);
|
||||||
|
|
||||||
|
} 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('adventure — resume non-contiguu: room0 done → reload → room0 done + usa2 deblocata + usa1 incuiata @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = adventureCfg([
|
||||||
|
{ title: 'Camera 0', type: 'free', question: 'R0?', answer: 'r0', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { '*': 2 } },
|
||||||
|
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: { '*': '' } },
|
||||||
|
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: { '*': 'end' } }
|
||||||
|
]);
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-resume');
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// Solve room 0 → branches to room 2, skips room 1
|
||||||
|
await enterRoom(gp, 0);
|
||||||
|
await solveRoom(gp, 'classic', 'r0');
|
||||||
|
await waitOverworld(gp);
|
||||||
|
|
||||||
|
// Reload — tryResume trebuie sa reconstituie starea non-contigua
|
||||||
|
await gp.reload();
|
||||||
|
await gp.waitForLoadState('domcontentloaded');
|
||||||
|
|
||||||
|
// Asteapta overworld activ (resume, nu intro)
|
||||||
|
await gp.waitForFunction(() => window.__ow && window.__ow.state.active, null, { timeout: 8000 });
|
||||||
|
|
||||||
|
const stResume = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(stResume.doors[0].solved, 'room 0 trebuie sa fie done dupa resume').toBe(true);
|
||||||
|
expect(stResume.owUnlocked[2], 'usa 2 trebuie deblocata dupa resume').toBeTruthy();
|
||||||
|
expect(stResume.owUnlocked[1], 'usa 1 trebuie sa ramana incuiata dupa resume').toBeFalsy();
|
||||||
|
|
||||||
|
} 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('adventure off — regresia non-adventure: toate usile intrabile in orice ordine @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
// adventure:false (default) — toate ușile deblocate, orice ordine
|
||||||
|
const cfg = campaignCfg(3, 'classic');
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-off');
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// In non-adventure: can enter door 1 first (not door 0)
|
||||||
|
await waitOverworld(gp);
|
||||||
|
await gp.evaluate(() => window.__ow.enterDoor(1));
|
||||||
|
await gp.waitForFunction(
|
||||||
|
() => document.getElementById('room-frame')?.hasAttribute('data-room-ready'),
|
||||||
|
null, { timeout: 8000 }
|
||||||
|
);
|
||||||
|
// overworld became inactive (we entered a room) — confirms door 1 was enterable
|
||||||
|
const stAfter = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(stAfter.active, 'harta trebuie sa fie inactiva dupa intrarea in room1').toBe(false);
|
||||||
|
|
||||||
|
} 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('adventure — branch tf: raspuns Adevarat→2, Fals→1 deblocheza usa corecta @campanie',
|
||||||
|
async ({ page }) => {
|
||||||
|
test.setTimeout(60000);
|
||||||
|
const errors = trackErrors(page);
|
||||||
|
const cfg = adventureCfg([
|
||||||
|
{ title: 'Camera 0', type: 'tf', question: 'E adevarat?', answer: '', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'A', style: 'classic', branch: { Adevarat: 2, Fals: 1 } },
|
||||||
|
{ title: 'Camera 1', type: 'free', question: 'R1?', answer: 'r1', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'B', style: 'classic', branch: {} },
|
||||||
|
{ title: 'Camera 2', type: 'free', question: 'R2?', answer: 'r2', tfAnswer: 'Adevarat', choices: '', hint: '', letter: 'C', style: 'classic', branch: {} }
|
||||||
|
]);
|
||||||
|
const tmpPath = await writeCampaignHtml(page, cfg, 'adv-tf');
|
||||||
|
|
||||||
|
const gp = await page.context().newPage();
|
||||||
|
const gameErrors = trackErrors(gp);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await gp.goto('file://' + tmpPath);
|
||||||
|
await gp.locator('#btn-start').click();
|
||||||
|
|
||||||
|
// Enter room 0 (tf puzzle, classic engine: buttons in #answers)
|
||||||
|
await waitOverworld(gp);
|
||||||
|
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();
|
||||||
|
// Click "Adevarat" (correct answer → branch key 'Adevarat' → should unlock door 2)
|
||||||
|
await ifl.locator('#answers button:text("Adevarat")').click();
|
||||||
|
await gp.waitForTimeout(1200); // animatie next()
|
||||||
|
|
||||||
|
await waitOverworld(gp);
|
||||||
|
const st = await gp.evaluate(() => window.__ow.state);
|
||||||
|
expect(st.owUnlocked[2], 'Adevarat→2: usa 2 trebuie deblocata').toBeTruthy();
|
||||||
|
expect(st.owUnlocked[1], 'Adevarat→2: usa 1 trebuie sa ramana incuiata').toBeFalsy();
|
||||||
|
|
||||||
|
} 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);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// 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 + motor inainte de boot + 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();
|
||||||
|
/* Motorul (window.__runGame) trebuie definit ÎNAINTE ca boot-ul să-l apeleze, ca să nu
|
||||||
|
* existe race de parsare (Brave dădea „motor lipsă" când await-ul din inflate se rezolva
|
||||||
|
* pe microtask înainte ca scriptul motor să fie parsat). */
|
||||||
|
return {
|
||||||
|
hasInflate: html.includes('inflateFromBase64url'),
|
||||||
|
hasRunGame: html.includes('window.__runGame=function'),
|
||||||
|
noLegacyInjection: !html.includes('text/plain'),
|
||||||
|
engineBeforeBoot: html.indexOf('window.__runGame=function') < html.indexOf('inflateFromBase64url(h)'),
|
||||||
|
hasTPL: html.includes('var TPL'),
|
||||||
|
len: html.length
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result.err, 'error').toBeUndefined();
|
||||||
|
expect(result.hasInflate, 'inflate helper').toBe(true);
|
||||||
|
expect(result.hasRunGame, 'window.__runGame definit').toBe(true);
|
||||||
|
expect(result.noLegacyInjection, 'fara injectie dinamica text/plain').toBe(true);
|
||||||
|
expect(result.engineBeforeBoot, 'motorul definit inaintea boot-ului').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