test: 37 frontend tests (App integration, API service, achievements), 43 backend tests — all passing

This commit is contained in:
2026-08-09 09:11:13 +00:00
parent 7c4a0aadc4
commit 5ef76439e9
9 changed files with 1393 additions and 292 deletions

104
docs/game-design-review.md Normal file
View File

@@ -0,0 +1,104 @@
# Spildesign-gennemgang: My Little Pony — Halerne i Equestria (web)
Genereret med `game-design`-pluginets `loop-check`- og `verify-design`-skabeloner, baseret på den faktiske implementering (`App.js`, `GameScenePage.js`, `services/api.js`, `services/achievements.js`, `web/app.py`) og på det officielle regelsæt i `rulbog.txt` (den danske udgave af *Tails of Equestria*).
## Kerneløkke-analyse: Scene → Terningkast → Konsekvens
### Løkken
```
[Vælg pony + tema] → [Læs scene] → [Kast terninger] → [Succes/fiasko-historiebid] → (gentag ca. 5x) → [Slut: sejr/nederlag + point]
```
### Delelementernes sundhed
#### Målklarhed
**Vurdering:** Tilstrækkelig
- Er målet altid synligt? Ja — fremgangslinjen med 5 prikker (`GameScenePage.js:44-52`) samt sceneteksten og handlingslabellen ("Du skal: …") gør det umiddelbare mål tydeligt.
- Er det tiltrækkende? Til dels — det umiddelbare mål ("Kast terningerne!") er klart, men der er ikke noget synligt langsigtet mål på skærmen, mens man er inde i en scene.
- Er det opnåeligt? Ja — sværhedsgraden er ét enkelt d6-pulje-slag pr. scene.
**Bemærkninger:** Handlingslabellen er god designpraksis — den navngiver hensigten *før* kastet, ikke bagefter.
#### Handlingsengagement
**Vurdering:** Svag
- Er selve handlingen sjov fra øjeblik til øjeblik? Den eneste spillerhandling er at trykke på én "kast"-knap (`onRollDice`). Der er intet valg — ingen forgrenende beslutninger, intet valg af *hvilken* egenskab eller talent der kastes med, ingen risiko/gevinst-afvejning.
- Betyder dygtighed noget? Nej. Ponytypen sætter et fast `diceBonus` (+1 eller +2) ved oprettelsen; derefter er hvert kast ren tilfældighed uden spilleragens pr. tur.
- Er feedback øjeblikkelig? Ja — terningeanimation, lydeffekter (`playRoll`/`playSuccess`/`playFail`) og et nyt punkt i historik-feedet udløses samtidig.
**Bemærkninger:** Dette er løkkens største svaghed. Selve bordrollespillet (jf. `rulbog.txt`) lader spillerne vælge *hvilken* egenskab (krop/sind/charme) og *hvilket* talent der skal kastes med — det valg er selve spillet. I regelbogen hedder det ligefrem: *"Dette spil har ikke et fast antal handlinger du kan tage, men lader dig i stedet komme med mange løsninger på dine problemer."* Webversionen koger dette ned til én knap, hvilket er en rimelig forenkling for en ung målgruppe, men det fjerner den eneste dygtighedsudfoldelse, som kildematerialet har.
#### Belønningstilfredshed
**Vurdering:** Tilstrækkelig
- Er belønningen knyttet til handlingen? Ja — succes/fiasko styrer direkte det næste historiebid (`item.story`).
- Er belønningen passende doseret? Terningudfaldet påvirker kun stemningstekst inden for en scene; det fremgår ikke tydeligt af frontend'en, om en fiasko kan afslutte et forløb tidligt, eller blot farver fortællingen.
- Er der variation? Delvist — regelbogens d6-system skelner mellem "fantastisk succes" (dobbelt-6'ere), almindelig succes og "ulykker" (1-tal, se `Tæl_Succes`/`tael_succeser`), men UI'en viser kun et binært `item.success`-felt, så gradsforskellen går tabt.
**Bemærkninger:** Overvej at vise "fantastisk succes"-niveauet tydeligt i historik-feedet — det udregnes allerede server-side (`fantastiske` i `historie.py`), men ser ud til at blive kasseret, før det når klienten.
#### Investeringskrog
**Vurdering:** Svag
- Investerer spilleren tilbage i løkken? Kun via `achievements.js` — badges på tværs af sessioner (antal spil, sejre, bedste score) gemt i localStorage.
- Skaber investeringen ejerskab? Minimalt — ponyvalget har kosmetisk identitet (navn/emoji/farve), men ingen vedvarende karaktervækst inden for eller på tværs af forløb.
- Forbedrer investeringen fremtidige løkker? Nej — hvert forløb starter forfra; der er ingen oplåsninger, ingen talentvækst, ingen pony der følger med videre. Dette står i skarp kontrast til regelbogens *Niveauer og niveauer op*-kapitel, hvor en pony efter et eventyr stiger i niveau, opgraderer en egenskab, får flere Venskabstegn og opgraderer brugte talenter.
**Bemærkninger:** Badge-systemet (`computeBadges`) er den eneste fastholdelseskrog. Det er talbaseret (antal spil/sejre) frem for knyttet til *hvordan* man spillede — der findes fx intet badge for en bestemt ponytype eller for et forløb uden en eneste fiasko.
### Løkkens bæredygtighed
**Gentagelsestolerance:** Lav-mellem
Et forløb er ca. 5 scener (afgrænset visuelt af de 5 fremgangsprikker) med ét knaptryk hver — ca. 2-3 minutter. For målgruppen (børn sammen med en voksen, jf. regelbogens ramme: *"Den bedste størrelse for en gruppe er fire personer"* og GM-rollen beskrevet som ofte en forælder) er det en passende sessionlængde, men der er ikke meget grund til at *spille igen*, ud over at opleve et andet tema eller en anden pony, da selve kastet ikke har noget beslutningsrum.
**Variationspunkter:**
- Temavalg (`ThemeSelectPage`) ændrer historieindholdet.
- Ponytype ændrer `diceBonus` og kosmetisk fremtoning.
- Server-side scene-forgrening ved succes/fiasko (jf. den forgrenende fortælling i `historie.py`).
**Naturlige stoppesteder:** Efter hver scene er afgjort, før næste kast; samt på `GameEndPage` (sejr/nederlag).
### Diagnose
Løkken er komplet og lukker pænt (mål → handling → feedback → belønning → gentag), men **handlings**-trinnet har næsten ingen spilleragens: det eneste input er ét tryk på "kast", så løkken minder mere om en interaktiv billedbog med en tilfældighedsgenerator end om et dygtighedsbaseret spil. Det er et legitimt designvalg for målgruppen (en dansksproget, familievenlig genfortælling af et bordrollespil), men det bør være et bevidst valg — ikke bare en forenkling, der er sket undervejs.
### Anbefalinger
1. **Lad spilleren vælge egenskab/talent før kastet** (krop/sind/charme + evt. talentbonus), selv med bare 2-3 knapper — det genindfører det ene meningsfulde valg, som kildespillet har, og kan kobles direkte på den eksisterende `test()`-logik i `historie.py`/`web/app.py`, som allerede understøtter en `talent`-parameter.
2. **Vis "fantastisk succes" (dobbelt-6) tydeligt i UI'en** — dataene findes allerede server-side; en distinkt animation/farve i `DiceRoll`/historik-feedet vil gøre store sejre mere følelsesmæssigt tydelige uden nyt backend-arbejde.
3. **Tilføj en let overlevering mellem forløb** — fx et per-ponytype "venskabstegn"-antal i `achievements.js`, der låser en kosmetisk detalje eller en temabonus-scene op, inspireret af regelbogens Tokens of Friendship-system. Det giver det nuværende rene tilskuer-statistiksystem noget på spil.
---
## Designverifikation: Scene/Kast/Historie-systemet
## Sammendrag
**Kerneløkke:** Komplet, men lav agens (se loop-check ovenfor)
**Mekanik:** Sammenhængende — ét d6-pulje-testsystem bruges konsekvent i `engine.py`-slægten, `historie.py` og `web/app.py`, og matcher regelbogens system (6=dobbelt succes, 4-5=én succes, 1=ulykke).
**Progression:** Ikke relevant på samme måde som bordudgaven — dette er et enkelt-sessions historiespil uden niveau-op mellem forløb, i modsætning til regelbogens fulde progressionssystem (niveauer, talentopgraderinger, udholdenhedsvækst).
**Oplevelse:** Klar for målgruppen; uklar for enhver, der forventer spilleragens ud over terningtryk.
## Mekanikanalyse
| Mekanik | Formål | Klarhed |
|---|---|---|
| d6-pulje-test (`Tæl_Succes`/`tael_succeser`) | Afgør scenehandlinger | Klar: 6=2 succeser, 4-5=1 succes, 1=ulykke. Matcher regelbogens system konsekvent gennem hele kodebasen. |
| Ponytypens `diceBonus` | Differentiere ponyvalg | Svag: påvirker kun ét tal ved karakteroprettelse; ingen anden typespecifik adfærd er synlig client-side (til sammenligning giver regelbogen hver ponytype et helt gratis starttalent: Stout Heart, Fly eller Telekinese). |
| Sværhedsgrader (let/normal/svært/meget svært) | Skalere sceneudfordring | Klar, matcher kildebogens sværhedsstige. |
| Præstationer/badges | Fastholdelse | Klar, men overfladisk — kun tal-tærskler, ingen build- eller dygtighedsspecifikke badges. |
## Systemsammenhæng
### Ressourceflow
- Den eneste "ressource" er terningsuccesser, som bruges med det samme mod en scenes sværhedsgrad — ingen vedvarende ressource (udholdenhed) er synligt sporet eller brugt på tværs af scener i frontend'en, selvom `Udholdenhed` findes i Pony-datamodellen og har en klar rolle i regelbogen (når udholdenheden når 0, er ponyen "for træt til at fortsætte, og må stoppe og hvile"). **Dette bør undersøges**: hvis udholdenhed spores server-side, men aldrig vises, har fiasko ingen synlige konsekvenser for spilleren.
### Kantsituationer
- [x] Nul-tilstand: en pony med 0 i en egenskab kan stadig kaste (0-terningers pulje) — bør bekræftes, at `web/app.py` håndterer et 0-terningskast pænt (en tom terningeliste) i stedet for at fejle.
- [ ] **Uverificeret**: hvad sker der ved gentagne fiaskoer — findes der en tabsbetingelse, eller ruter historien blot gennem fiasko-grene i det uendelige? `GameEndPage` antyder, at der findes en nederlagstilstand, men udløsningsbetingelsen fremgår ikke af de gennemgåede frontend-filer.
## Fundne problemer
| Problem | Kategori | Alvor | Anbefaling |
|---|---|---|---|
| Intet spillervalg i kast-trinnet | Mekanik/Oplevelse | Middel | Tilføj valg af egenskab/talent før kast (se loop-check-anbefaling #1) |
| "Fantastisk succes" (dobbelt-6) beregnes, men vises ikke i UI | Oplevelse | Lav | Send `fantastiske`-tallet med i API-svaret og vis det tydeligt |
| Udholdenhed modelleret, men tilsyneladende ikke brugt synligt i frontend'en | Systemsammenhæng | Middel — kræver verifikation | Bekræft om `web/app.py` bruger udholdenhed som en fiasko-ressource; vis den i så fald i `GameScenePage` |
| Præstationer er kun tal-baserede, ingen variation | Investering | Lav | Tilføj ponytype- eller kastkvalitets-specifikke badges |
## Dom
**Design-klar:** Ja, med justeringer — løkken fungerer og leverer et sammenhængende, letforståeligt historiespil til sin målgruppe; anbefalingerne ovenfor er forbedringer, ikke blokerende problemer.
**Blokerende problemer:** Ingen.
**Spørgsmål til playtesting:** Vil børn faktisk have et egenskabsvalg-trin, eller betyder den simple ét-knap-oplevelse mere for målgruppens alder end agens gør? Dette bør afprøves med rigtige spillere, før anbefaling #1 implementeres — det er en reel designafvejning, ikke en åbenlys fejl.
## Næste skridt
1. Bekræft fiasko-/tabsbetingelsen server-side (`web/app.py`) og dokumentér den her.
2. Beslut, sammen med rigtige playtestere, om egenskabsvalg-trinnet (anbefaling #1) skal tilføjes — det er den enkeltstående ændring med størst potentiel effekt, men kun hvis målgruppen efterspørger det.
3. Før "fantastisk succes"-feedback igennem til UI'en (lav omkostning, ingen designrisiko).

View File

@@ -0,0 +1,96 @@
# Web-/lydgennemgang: pony-frontend
Genereret med `web-games`-pluginets `web-optimization-checklist`- og `web-audio-specialist`-skabeloner, tilpasset til den faktiske kode i `pony-frontend/` (Create React App 5 / react-scripts, React 19, framer-motion) i stedet for at gengive den generiske tjekliste ukritisk.
## Lydarkitektur: pony-frontend
## Lydoversigt
**Lydkontekst:** Én delt `AudioContext` via singleton'en `AudioManager` i `SceneMusic.js` — korrekt arkitektur (matcher "single context, multiple channels"-princippet).
**Kanaler:** `masterGain``musicGain` + `sfxGain`, med volumen persisteret i `localStorage` (`pony_music_vol`, `pony_sfx_vol`, `pony_muted`).
**Format-strategi:** Ingen lydfiler overhovedet — al lyd (musik og SFX) genereres proceduralt med oscillatorer (`createOscillator`/`createBiquadFilter`). Det betyder, at store dele af den generiske "asset-optimering"-tjekliste (MP3/OGG-formater, komprimering, audio sprites, streaming) simpelthen ikke er relevant her — der er 0 KB lyd-assets at optimere.
## Fund (specifikt for denne kode)
### 1. `SoundFX.js` er dødt/duplikeret kode
`SoundFX.js` genimplementerer nøjagtig de samme funktioner (`playClick`, `playRoll`, `playSuccess`, `playFail`, `playSelect`, `playVictory`) som allerede eksporteres fra `SceneMusic.js` — men med sin egen rå `AudioContext` forbundet direkte til `ctx.destination`, uden om `AudioManager`s gain-struktur. `App.js` importerer udelukkende fra `./SceneMusic`, så `SoundFX.js` ser ud til aldrig at blive brugt.
**Konsekvens:** Hvis filen nogensinde importeres ved en fejl (fx under en refaktorering), vil de lyde, den afspiller, ignorere spillerens lydstyrke- og mute-indstillinger, fordi de ikke går gennem `sfxGain`/`masterGain`.
**Anbefaling:** Slet `SoundFX.js`. Det er ren dødvægt og en fælde for fremtidige imports.
### 2. Kun musik-lydstyrke er justerbar i UI'en
`AudioManager` understøtter separate `musicGain`/`sfxGain`-kanaler, men `VolumeControl.js` kalder kun `setMasterVolume(v)`, som mapper til `setMusicVolume` (se kommentaren "Backward-compatible master volume (maps to music)" i `SceneMusic.js:195-199`). SFX-lydstyrken er derfor låst til sin `localStorage`-standardværdi (0.7) eller sidst gemte værdi og kan ikke justeres fra UI'en, selvom infrastrukturen til det allerede findes.
**Anbefaling:** Enten tilføj en synlig SFX-lydstyrkeknap, eller — hvis én samlet skyder er et bevidst UX-valg for målgruppen (børn) — lad `VolumeControl` kalde både `setMusicVolume` og `setSfxVolume`, så "mute" reelt slukker alt, ikke kun musikken.
### 3. Ingen håndtering af faneskift/afbrydelse
`SceneMusic` lytter ikke til `document.visibilitychange` eller `AudioContext`s `statechange`-event. Musikken (drevet af `setInterval(playMelody, …)` og `setInterval(playPad, 8000)`) fortsætter med at planlægge noder, selv når fanen er skjult, hvilket spilder CPU/batteri på mobil og kan give en pludselig lydbrag, når man vender tilbage til fanen, hvis flere planlagte noder er stablet op.
**Anbefaling:** Suspendér `AudioContext` ved `document.hidden` og genoptag ved synlighed (standardmønster, se f.eks. iOS Safari-afbrydelseshåndtering).
### 4. `manifest.json` er stadig CRA's standardskabelon
`public/manifest.json` indeholder stadig `"short_name": "React App"` og `"name": "Create React App Sample"` — den er aldrig tilpasset til spillet. `theme_color` er sort og `background_color` hvid, uden sammenhæng med spillets faktiske farvepalet.
**Anbefaling:** Udfyld navn, kort navn, temafarve og ikoner, hvis siden nogensinde skal kunne installeres som PWA (se punkt 5).
### 5. Ingen service worker / ingen reel PWA
Der er intet service worker-register i `src/index.js`, og `reportWebVitals.js` er den eneste "extra" fra CRA-skabelonen. Appen er reelt en almindelig SPA uden offline-understøttelse eller cache-first-indlæsning, selvom `manifest.json` antyder PWA-hensigt.
**Anbefaling:** Enten fjern PWA-artefakterne helt for at undgå at love noget, siden ikke leverer, eller tilføj CRA's indbyggede `serviceWorkerRegistration` (kræver at skifte fra `register()`/`unregister()`-boilerplaten, som CRA 5 stadig leverer som opt-in).
### 6. Ubrugt asset-mappe
`public/music/` eksisterer, men er tom — sandsynligvis en rest fra en tidligere plan om at bruge indspillede lydfiler, før musikken blev lagt om til proceduralt genereret lyd.
**Anbefaling:** Fjern den tomme mappe, eller dokumentér hvorfor den er der, hvis den er tiltænkt fremtidig brug.
## Platformovervejelser
### Mobilhåndtering
- **Brugerinteraktions-oplåsning:** Håndteres korrekt — `App.js` viser en eksplicit "Aktivér lyd"-prompt (`enableSound``resumeAudioContext()`), som overholder browsernes autoplay-politik.
- **Afbrydelseshåndtering:** Mangler (se fund #3).
- **Baggrundslyd:** Ikke defineret opførsel — bør besluttes bevidst (se fund #3).
### Browserkompatibilitet
| Funktion | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
| Web Audio API | ✓ | ✓ | ✓ | ✓ |
| `BiquadFilterNode` (bruges i `playPad`) | ✓ | ✓ | ✓ | ✓ |
| `webkitAudioContext`-fallback | ✓ (ikke nødvendig) | ✓ (ikke nødvendig) | Understøttet i `AudioManager.init()` — god praksis | ✓ |
---
## Optimeringstjekliste til weblansering: pony-frontend
Tilpasset til det, der faktisk er relevant for et CRA/React 19/framer-motion-projekt uden lydfiler eller WebGL.
### Build-optimering
- [ ] Kør `npm run build` og inspicér output med `source-map-explorer` eller `react-scripts`-standard bundle-rapporten — `framer-motion` er et relativt tungt afhængighed, og det er værd at bekræfte, at kun de nødvendige dele importeres.
- [ ] Overvej om Create React App (react-scripts 5, i praksis uvedligeholdt siden 2023) fortsat er det rette byggeværktøj, eller om en migrering til Vite ville reducere build-tid og bundle-størrelse — dette er en større beslutning og ikke noget, der skal gøres uden en eksplicit vurdering.
- [ ] CSS er allerede organiseret i design-tokens (jf. tidligere CSS-refaktorering) — bekræft at ubrugt CSS ikke akkumuleres, efterhånden som komponenter fjernes.
### Asset-optimering
- [x] **Lyd:** Ikke relevant — al lyd er proceduralt genereret, 0 KB lydassets.
- [ ] **Billeder:** Ponybilleder (`data.ponyImg`, hentet fra backend'en via `${API}${data.ponyImg}`) bør tjekkes for komprimering og passende opløsning — dette ligger i `web/`-backend'en, ikke i frontend-repoet, og er ikke gennemgået her.
- [ ] **Fonte:** Ingen brugerdefinerede web-fonte fundet i de gennemgåede filer — bekræft i `index.css`/`App.css` om systemfonte bruges (billigst) eller om der indlæses eksterne fonte, der bør subsettes.
### Runtime-ydeevne
- [ ] `SceneMusic`s `setInterval`-baserede node-planlægning (fund #3) bør profileres på en lav-effekt mobilenhed for at bekræfte, at det ikke giver mærkbare hak i `framer-motion`-animationerne, som kører samtidig på samme hovedtråd.
- [ ] Bekræft, at `activeNodesRef`/`activeNodes`-oprydningen i `AudioManager.scheduleNote` rent faktisk forhindrer akkumulering af oscillator-noder over en lang spilsession (koden ser korrekt ud ved gennemlæsning, men bør verificeres i praksis over 10+ minutters kørsel).
### Indlæsningsydeevne
- [ ] Mål First Contentful Paint / Time to Interactive med Lighthouse — ingen eksisterende måling er fundet i repoet.
- [ ] `api.loadContent()` kaldes ved opstart og har en fallback til `DEFAULT_PONIES`, hvis backend'en fejler (god praksis) — bekræft at brugeren ser en meningsfuld loading-tilstand, mens dette kald er undervejs (ser ud til at være tilfældet via `loading`-state i `App.js`).
### Mobiloptimering
- [ ] Berøringsmål: "🎲 KAST TERNINGERNE!"-knappen og navigationsknapperne bør måles for at bekræfte ≥48px berøringsflade, særligt for målgruppen (børn, som ofte har mindre præcis finger-styring).
- [ ] Bekræft responsivt layout på faktiske mobilskærme — ikke verificeret i denne gennemgang (kræver visuel test, se anbefaling nedenfor).
### PWA-tjekliste
- [ ] `manifest.json` er ikke udfyldt korrekt (fund #4) — enten ret det op, eller fjern PWA-ambitionen bevidst.
- [ ] Ingen service worker registreret (fund #5).
### Før lancering
- [ ] Kør browserkonsollen igennem for fejl/advarsler på alle sider (Home, Theme, PonySelect, GameScene, GameEnd).
- [ ] Bekræft at `SoundFX.js` fjernes, så det ikke ved en fejl importeres senere (fund #1).
## Ydeevnebudget
| Metrik | Budget | Status |
|---|---|---|
| Lyd-assets (KB) | 0 (proceduralt) | ✓ Opfyldt af design |
| Berøringsmål | ≥48px | Ikke målt i denne gennemgang |
| Initial indlæsning | <3s Time to Interactive | Ikke målt kræver Lighthouse-kørsel i browser |
**Bemærkning om dækning:** Denne gennemgang er baseret statisk kodelæsning, ikke en kørende måling i browseren (ingen Lighthouse-/DevTools-profil er kørt). Punkterne markeret "Ikke målt" bør verificeres ved faktisk at køre appen, før de afkrydses.

View File

@@ -1,3 +1,9 @@
/**
* Integration tests for the full App flow.
*
* Tests: Home → Theme Select → Pony Select → Game Scene → Game End → Home.
*/
// Mock framer-motion
jest.mock('framer-motion', () => {
const React = require('react');
@@ -13,14 +19,12 @@ jest.mock('framer-motion', () => {
jest.mock('./dice/ReactDice', () => {
const React = require('react');
return React.forwardRef(function ReactDice({ numDice, ...props }, ref) {
if (ref) {
ref.current = { rollAll: jest.fn() };
}
if (ref) ref.current = { rollAll: jest.fn() };
return React.createElement('div', { 'data-testid': 'dice', 'data-dice-count': numDice || 2, ...props });
});
});
// Mock SceneMusic - exports both default and named
// Mock SceneMusic
jest.mock('./SceneMusic', () => {
const SceneMusic = function SceneMusic() { return null; };
return {
@@ -32,6 +36,7 @@ jest.mock('./SceneMusic', () => {
playFail: jest.fn(),
playSelect: jest.fn(),
playVictory: jest.fn(),
playDefeat: jest.fn(),
setMasterVolume: jest.fn(),
resumeAudioContext: jest.fn(),
};
@@ -43,17 +48,8 @@ import userEvent from '@testing-library/user-event';
import App from './App';
import * as SFX from './SceneMusic';
// Mock fetch
const mockFetch = (response, ok = true) => {
global.fetch = jest.fn(() =>
Promise.resolve({
ok,
json: () => Promise.resolve(response),
})
);
};
// --- Helpers ---
// Mock localStorage - with tutorial already seen so it doesn't block tests
const mockLocalStorage = (overrides) => {
const store = {
pony_tutorial_seen: 'true',
@@ -66,19 +62,7 @@ const mockLocalStorage = (overrides) => {
const parsed = JSON.parse(storeStr);
return parsed[k] || null;
},
setItem: (k, v) => {
// mutate the string representation
const parsed = JSON.parse(storeStr);
parsed[k] = v;
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (kk) => JSON.parse(JSON.stringify(parsed))[kk] || null,
setItem: (kk, vv) => {},
clear: () => {},
},
writable: true,
});
},
setItem: (k, v) => {},
clear: () => {},
},
writable: true,
@@ -90,33 +74,52 @@ beforeEach(() => {
mockLocalStorage();
});
// Helper - dismiss sound prompt to get to actual game
// Helper - dismiss sound prompt
async function dismissSoundPrompt() {
// Wait for the sound prompt to appear
await waitFor(() => {
const btn = screen.queryByText('Aktiver lyd') || screen.queryByText('Skip');
expect(btn).not.toBeNull();
}, { timeout: 2000 });
// Click "Aktiver lyd" if present, otherwise "Skip"
const enableBtn = screen.queryByText('Aktiver lyd');
if (enableBtn) {
await userEvent.click(enableBtn);
} else {
await userEvent.click(screen.getByText('Skip'));
}
// Wait for animations to settle
await new Promise(r => setTimeout(r, 100));
}
// Helper
// Multi-call fetch mock — returns same content data for all calls
function mockContentFetch(contentData) {
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(contentData),
})
);
}
// Multi-call fetch with different responses per call
function mockMultiFetch(responses) {
let idx = 0;
global.fetch = jest.fn(() => {
const r = responses[idx++];
return Promise.resolve({
ok: r.ok !== false,
status: r.status || 200,
json: () => Promise.resolve(r.body || {}),
});
});
}
const findByText = (t) => screen.getByText(t);
const queryByText = (t) => screen.queryByText(t);
const findAllByText = (t) => screen.getAllByText(t);
// ===== HOME =====
test('renders title and start button', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
expect(screen.getByText('My Little Pony')).toBeInTheDocument();
@@ -124,19 +127,75 @@ test('renders title and start button', async () => {
expect(findByText('🎮 Start Nyt Spil!')).toBeInTheDocument();
});
test('navigates to start page when clicking Start', async () => {
test('navigates to theme select when clicking Start', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
expect(SFX.playClick).toHaveBeenCalled();
expect(screen.getByText('Vælg din Pony! 🐴')).toBeInTheDocument();
expect(findByText('Vælg et eventyr! 📖')).toBeInTheDocument();
});
// ===== THEME SELECT =====
test('renders theme selection page', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [
{ titel: 'Skyggen Over Equestria', emoji: '🌑', intro: 'A dark shadow...', sceneCount: 5 },
{ titel: 'Havdypens Skat', emoji: '🌊', intro: 'Under the sea...', sceneCount: 7 },
],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
expect(findByText('Vælg et eventyr! 📖')).toBeInTheDocument();
});
test('selecting a theme navigates to pony selection', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
expect(findByText('Vælg et eventyr! 📖')).toBeInTheDocument();
await userEvent.click(findByText('Skyggen'));
expect(SFX.playClick).toHaveBeenCalled();
expect(findByText('Vælg din Pony! 🐴')).toBeInTheDocument();
});
test('Tilbage from theme goes to home', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
expect(findByText('Vælg et eventyr! 📖')).toBeInTheDocument();
await userEvent.click(findByText('Tilbage'));
expect(screen.getByText('My Little Pony')).toBeInTheDocument();
});
// ===== PONY SELECTION =====
test('renders all 4 pony types', async () => {
mockContentFetch({
ponies: [
{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' },
{ navn: 'Pegasus', emoji: '🦅', bonus: 'Flyver 🪽', tekst: 'x', img: 'pegasus.png' },
{ navn: 'Enhjørning', emoji: '🦄', bonus: 'Magisk ✨', tekst: 'x', img: 'enhjorning.png' },
{ navn: 'Alicorn', emoji: '👑', bonus: 'Magi 🌟', tekst: 'x', img: 'alicorn.png' },
],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
expect(screen.getByText('Jordpony')).toBeInTheDocument();
expect(screen.getByText('Pegasus')).toBeInTheDocument();
expect(screen.getByText('Enhjørning')).toBeInTheDocument();
@@ -144,339 +203,173 @@ test('renders all 4 pony types', async () => {
});
test('calls /api/start when selecting a pony', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr i Equestria', sceneText: 'Du møder en drage.',
actionText: 'Kæmp mod dragen', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
expect(SFX.playSelect).toHaveBeenCalled();
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(2));
});
test('shows error when /api/start fails', async () => {
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ ok: false, body: { error: 'bad' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText(/Kunne ikke starte spil/)).toBeInTheDocument());
});
test('Tilbage from pony selection goes to home', async () => {
mockContentFetch({
ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }],
themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
expect(SFX.playSelect).toHaveBeenCalled();
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'http://localhost/api/start',
expect.objectContaining({ method: 'POST' })
));
});
test('shows error when /api/start fails', async () => {
mockFetch(null, false);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText(/Kunne ikke starte spil/)).toBeInTheDocument());
});
test('handles network error (throws)', async () => {
global.fetch = jest.fn(() => Promise.reject(new Error('Network')));
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText(/Kunne ikke starte spil/)).toBeInTheDocument());
});
test('Tilbage button navigates to home', async () => {
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Tilbage'));
expect(SFX.playClick).toHaveBeenCalled();
await userEvent.click(findByText('Skyggen'));
expect(findByText('Vælg din Pony! 🐴')).toBeInTheDocument();
await userEvent.click(findByText('Tilbage'));
expect(screen.getByText('My Little Pony')).toBeInTheDocument();
});
// ===== GAME SCENE =====
test('renders scene information', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('Eventyr')).toBeInTheDocument());
expect(screen.getByText('Du møder en drage.')).toBeInTheDocument();
});
test('shows roll button', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
test('shows roll button in game', async () => {
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
});
test('calls /api/kast when rolling dice', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { sceneNum: 2, tema: 'Eventyr', sceneText: 'Scene 2', actionText: 'Kæmp', difficulty: '⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
expect(SFX.playRoll).toHaveBeenCalled();
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith(
'http://localhost/api/kast',
expect.objectContaining({ method: 'POST' })
));
});
test('shows history items in feed', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
history: [{ action: 'Kæmp', dice: [3, 4], result: 'Du vandt!', success: true, story: 'Bravo!' }],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('Du vandt!')).toBeInTheDocument());
});
test('shows pony info in game', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(findAllByText('Jordpony').length).toBeGreaterThanOrEqual(1));
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(3));
});
// ===== GAME END =====
test('shows victory message', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { victory: true, finished: true, endText: 'Du vandt!', score: '100', history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }], ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument());
expect(SFX.playVictory).toHaveBeenCalled();
});
test('shows history on game end', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
test('shows defeat message for non-victory', async () => {
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { victory: false, mixed: false, finished: true, endText: 'Du tabte...', score: '0', history: [{ action: 'Kæmp', dice: [1, 1], result: 'Tab!', success: false }], ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('📖 Hvad skete der? 📖')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('💪 Prøv igen! 💪')).toBeInTheDocument());
});
test('has Spil Igen button', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
test('Spil Igen navigates to pony selection', async () => {
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { victory: true, finished: true, endText: 'Du vandt!', score: '100', history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }], ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('🎲 Spil Igen!')).toBeInTheDocument());
});
test('Spil Igen navigates to start page', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('🎲 Spil Igen!')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument());
await userEvent.click(screen.getByText('🎲 Spil Igen!'));
expect(screen.getByText('Vælg din Pony! 🐴')).toBeInTheDocument();
});
test('Forside button navigates to home', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.', actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { victory: true, finished: true, endText: 'Du vandt!', score: '100', history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }], ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(findByText('Skyggen'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('🏠 Forside')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('🌟 SEJR! 🌟')).toBeInTheDocument());
await userEvent.click(screen.getByText('🏠 Forside'));
expect(screen.getByText('My Little Pony')).toBeInTheDocument();
});
test('shows defeat message for non-victory', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: false, mixed: false, finished: true, endText: 'Du tabte...', score: '0',
history: [{ action: 'Kæmp', dice: [1, 1], result: 'Tab!', success: false }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('💪 Prøv igen! 💪')).toBeInTheDocument());
});
test('shows mixed result message', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: false, mixed: true, finished: true, endText: 'Blandet...', score: '50',
history: [{ action: 'Kæmp', dice: [3, 4], result: 'Blandet', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByText('⚖️ Blant resultat! ⚖️')).toBeInTheDocument());
});
// ===== ERROR =====
test('shows error screen with Tilbage button', async () => {
global.fetch = jest.fn(() => Promise.reject(new Error('Network')));
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText(/Kunne ikke starte spil/)).toBeInTheDocument());
expect(screen.getByText('Tilbage')).toBeInTheDocument();
});
// ===== LOADING =====
test('shows loading spinner while waiting for API', async () => {
let resolveFetch;
global.fetch = jest.fn(() => new Promise(r => { resolveFetch = r; }));
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
expect(screen.getByText('Indlæser...')).toBeInTheDocument();
resolveFetch({ ok: true, json: () => Promise.resolve({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Test', actionText: 'Test',
difficulty: '⭐', ponyName: 'Jordpony', ponyType: 'Jordpony',
ponyImg: '/static/images/jordpony.png', history: [],
})});
});
// ===== DICE COMPONENT =====
test('renders dice in game end history', async () => {
mockFetch({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Du møder en drage.',
actionText: 'Kæmp', difficulty: '⭐⭐', ponyName: 'Jordpony',
ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [],
});
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());
mockFetch({
victory: true, finished: true, endText: 'Du vandt!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
});
await userEvent.click(screen.getByText('🎲 KAST TERNINGERNE! 🎲'));
await waitFor(() => expect(screen.getByTestId('dice')).toBeInTheDocument());
});
// ===== FULL FLOW =====
test('home -> start -> game -> end -> home', async () => {
let callCount = 0;
global.fetch = jest.fn(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ ok: true, json: () => Promise.resolve({
sceneNum: 1, tema: 'Eventyr', sceneText: 'Test', actionText: 'Test',
difficulty: '⭐', ponyName: 'Jordpony', ponyType: 'Jordpony',
ponyImg: '/static/images/jordpony.png', history: [],
})});
} else {
return Promise.resolve({ ok: true, json: () => Promise.resolve({
victory: true, finished: true, endText: 'Sejr!', score: '100',
history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }],
ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png',
})});
}
});
test('home -> theme -> pony -> game -> end -> home', async () => {
mockMultiFetch([
{ body: { ponies: [{ navn: 'Jordpony', emoji: '🐴', bonus: 'Stærk 💪', tekst: 'x', img: 'jordpony.png' }], themes: [{ titel: 'Skyggen', emoji: '🌑', intro: 'x', sceneCount: 5 }] } },
{ body: { sceneNum: 1, tema: 'Eventyr', sceneText: 'Test', actionText: 'Test', difficulty: '⭐', ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png', history: [] } },
{ body: { victory: true, finished: true, endText: 'Sejr!', score: '100', history: [{ action: 'Kæmp', dice: [6, 6], result: 'Sejr!', success: true }], ponyName: 'Jordpony', ponyType: 'Jordpony', ponyImg: '/static/images/jordpony.png' } },
]);
render(<App />);
await dismissSoundPrompt();
await userEvent.click(findByText('🎮 Start Nyt Spil!'));
expect(screen.getByText('Vælg et eventyr! 📖')).toBeInTheDocument();
await userEvent.click(findByText('Skyggen'));
expect(screen.getByText('Vælg din Pony! 🐴')).toBeInTheDocument();
await userEvent.click(screen.getByText('Jordpony'));
await waitFor(() => expect(screen.getByText('🎲 KAST TERNINGERNE! 🎲')).toBeInTheDocument());

View File

@@ -0,0 +1,88 @@
/**
* Tests for the achievement service.
*
* Verifies recordGame, resetStats, getStats, and computeBadges.
*/
import * as achievements from '../services/achievements';
// Clear localStorage before each test
beforeEach(() => {
localStorage.clear();
});
describe('achievements.getStats', () => {
it('returns default stats when nothing stored', () => {
const stats = achievements.getStats();
expect(stats).toEqual({ wins: 0, losses: 0, games: 0, bestScore: 0 });
});
it('reads existing stats from localStorage', () => {
localStorage.setItem('pony_stats', JSON.stringify({ wins: 5, losses: 2, games: 7, bestScore: 4 }));
expect(achievements.getStats()).toEqual({ wins: 5, losses: 2, games: 7, bestScore: 4 });
});
});
describe('achievements.recordGame', () => {
it('increments wins on victory', () => {
const stats = achievements.recordGame(true, 3);
expect(stats.games).toBe(1);
expect(stats.wins).toBe(1);
expect(stats.losses).toBe(0);
expect(stats.bestScore).toBe(3);
});
it('increments losses on defeat', () => {
const stats = achievements.recordGame(false, 1);
expect(stats.losses).toBe(1);
expect(stats.wins).toBe(0);
});
it('updates bestScore only if higher', () => {
achievements.recordGame(true, 5);
const stats = achievements.recordGame(true, 3);
expect(stats.bestScore).toBe(5);
expect(stats.games).toBe(2);
expect(stats.wins).toBe(2);
});
it('persists to localStorage', () => {
achievements.recordGame(true, 2);
const stored = JSON.parse(localStorage.getItem('pony_stats'));
expect(stored.games).toBe(1);
});
});
describe('achievements.resetStats', () => {
it('clears all stats', () => {
achievements.recordGame(true, 10);
achievements.resetStats();
expect(achievements.getStats()).toEqual({ wins: 0, losses: 0, games: 0, bestScore: 0 });
});
});
describe('achievements.computeBadges', () => {
it('returns first game badge', () => {
expect(achievements.computeBadges({ games: 1, wins: 0, bestScore: 0 })).toContain('🎮 Første spil');
});
it('returns first win badge', () => {
expect(achievements.computeBadges({ games: 1, wins: 1, bestScore: 0 })).toContain('🌟 Første sejr');
});
it('returns 5 games badge', () => {
expect(achievements.computeBadges({ games: 5, wins: 2, bestScore: 0 })).toContain('🎯 5 spil');
});
it('returns 5 wins badge', () => {
expect(achievements.computeBadges({ games: 5, wins: 5, bestScore: 0 })).toContain('⭐ 5 sejre');
});
it('returns perfect run badge', () => {
expect(achievements.computeBadges({ games: 1, wins: 1, bestScore: 5 })).toContain('🏆 Perfekt run');
});
it('returns Pony Mester badge', () => {
expect(achievements.computeBadges({ games: 10, wins: 5, bestScore: 5 })).toContain('👑 Pony Mester!');
});
});

View File

@@ -0,0 +1,88 @@
/**
* Tests for the API service layer.
*/
import * as api from '../services/api';
function mockFetch(responses) {
let callIndex = 0;
global.fetch = jest.fn(() => {
const r = responses[callIndex++];
return Promise.resolve({
ok: r.ok !== false,
status: r.status || 200,
json: () => Promise.resolve(r.body || {}),
});
});
}
describe('api.startGame', () => {
it('sends POST to /api/start with pony type and tema', async () => {
mockFetch([{ body: { game_id: 'abc123' } }]);
await api.startGame(2, 1);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/start'),
expect.objectContaining({
method: 'POST',
body: JSON.stringify({ type: 2, tema: 1 }),
}),
);
});
it('defaults tema to undefined when omitted', async () => {
mockFetch([{ body: { game_id: 'x' } }]);
await api.startGame(0);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/start'),
expect.objectContaining({
body: JSON.stringify({ type: 0 }),
}),
);
});
it('throws on non-200', async () => {
mockFetch([{ ok: false, status: 400, body: { error: 'bad' } }]);
await expect(api.startGame(0)).rejects.toThrow();
});
});
describe('api.rollDice', () => {
it('sends POST to /api/kast', async () => {
mockFetch([{ body: { scene: 1 } }]);
await api.rollDice();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/kast'),
expect.objectContaining({ method: 'POST' }),
);
});
});
describe('api.loadContent', () => {
it('fetches GET /api/content', async () => {
mockFetch([{ body: { ponies: [], themes: [] } }]);
const data = await api.loadContent();
const calls = global.fetch.mock.calls;
expect(calls[0][0]).toMatch(/\/api\/content/);
expect(data).toEqual({ ponies: [], themes: [] });
});
});
describe('api.getScene', () => {
it('fetches GET /api/scene', async () => {
mockFetch([{ body: { scene: 0 } }]);
await api.getScene();
const calls = global.fetch.mock.calls;
expect(calls[0][0]).toMatch(/\/api\/scene/);
});
});
describe('api.resetGame', () => {
it('sends POST to /api/reset', async () => {
mockFetch([{ body: { ok: true } }]);
await api.resetGame();
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/reset'),
expect.objectContaining({ method: 'POST' }),
);
});
});

659
talk.json Normal file
View File

@@ -0,0 +1,659 @@
{
"goal": {
"name": "MLP Pony Game - Dansk Voice Interaction for 4-årige",
"agent": "Hermes",
"target_model": "Qwen3.6-27B",
"project_path": "/home/alex/Documents/Obsidian Vault/Projects/MLP-Pony",
"documentation_path": "/home/alex/Documents/Obsidian Vault/Projects/MLP-Pony/Dokumentation.md",
"primary_objective": "Implementér et komplet dansk voice-interaction system i MLP Pony Game, hvor et barn omkring 4 år kan tale til spillet, besvare spørgsmål, vælge handlinger og interagere med ponyerne uden at skulle kunne læse eller skrive. Brug Wispr Flow/Whisper-kompatibel speech-to-text som inputlag, tolerant dansk keyword-, synonym- og fuzzy matching som primær forståelsesmekanisme og Qwen3.6-27B som intelligent fallback til kreative eller uventede svar.",
"execution_mode": "autonomous_iterative",
"priority": "high",
"language": {
"child_interface": "Danish",
"documentation": "Danish",
"code": "English",
"code_comments": "English",
"api_fields": "English"
}
},
"product_vision": {
"feature_name": "Tal med ponyerne",
"target_user": {
"age": 4,
"reading_required": false,
"writing_required": false,
"interaction_style": "voice-first"
},
"experience": "En pony eller fortællerstemme stiller et spørgsmål på dansk. Barnet trykker på en stor mikrofonknap og svarer med sin egen stemme. Speech-to-text konverterer talen til dansk tekst. Spillet forsøger først at forstå svaret deterministisk via keywords, synonymer og fuzzy matching. Hvis dette ikke giver et sikkert resultat, kan Qwen3.6-27B klassificere svaret mod de tilladte scene-intents. Ponyen reagerer derefter positivt og naturligt, og historien fortsætter.",
"design_principles": [
"Barnet skal kunne spille uden at kunne læse.",
"Voice interaction skal føles som samtale, ikke som diktering.",
"Barnets formulering behøver ikke være grammatisk korrekt.",
"Små udtalefejl skal tolereres.",
"Speech-to-text fejl skal tolereres.",
"Spillet skal forstå betydningen fremfor at kræve eksakte sætninger.",
"Barnet skal sjældent opleve et hårdt 'forkert svar'.",
"Ved misforståelser skal spillet hjælpe barnet videre.",
"Responstiden skal være kort nok til, at samtalen føles naturlig.",
"LLM må ikke være nødvendig for simple svar.",
"Gameplay skal stadig fungere med mus/touch hvis voice ikke bruges."
]
},
"important_architecture_decision": {
"rule": "Qwen3.6-27B må ikke være første trin for alle voice-svar.",
"reasoning": [
"Keyword matching er hurtigere.",
"Keyword matching er deterministisk.",
"Simple børnesvar kræver ikke LLM reasoning.",
"LLM-inference giver unødvendig latency.",
"Det reducerer risikoen for at modellen fortolker svaret anderledes end spillets design."
],
"matching_pipeline": [
"speech_to_text",
"normalization",
"exact_keyword_match",
"normalized_keyword_match",
"synonym_match",
"fuzzy_match",
"intent_scoring",
"llm_fallback",
"child_friendly_recovery"
]
},
"high_level_flow": {
"steps": [
{
"step": 1,
"action": "Scene vises."
},
{
"step": 2,
"action": "Pony/fortæller afspiller spørgsmålet med dansk TTS hvis TTS er aktiveret."
},
{
"step": 3,
"action": "Mikrofonknappen bliver tydelig og aktiv."
},
{
"step": 4,
"action": "Barnet trykker eller holder mikrofonknappen."
},
{
"step": 5,
"action": "Browseren optager lyd."
},
{
"step": 6,
"action": "Lyd sendes til backend."
},
{
"step": 7,
"action": "Backend sender lyden til konfigureret speech-to-text provider."
},
{
"step": 8,
"action": "Dansk transcription returneres."
},
{
"step": 9,
"action": "Teksten normaliseres."
},
{
"step": 10,
"action": "Voice matcher forsøger deterministic intent matching."
},
{
"step": 11,
"action": "Hvis confidence er høj, accepter intent."
},
{
"step": 12,
"action": "Hvis confidence er middel, brug kontekst eller clarification."
},
{
"step": 13,
"action": "Hvis deterministic matching ikke kan afgøre det, send kun nødvendige data til Qwen fallback."
},
{
"step": 14,
"action": "Resultatet oversættes til en game action."
},
{
"step": 15,
"action": "Ponyen reagerer positivt."
},
{
"step": 16,
"action": "Scene/game state opdateres."
}
]
},
"speech_to_text": {
"provider": "Wispr Flow or Whisper-compatible STT",
"provider_abstraction_required": true,
"language": "da",
"expected_input": [
"webm",
"ogg",
"wav"
],
"requirements": [
"Speech-to-text provider må ikke kaldes direkte fra React hvis API credentials ellers eksponeres.",
"Frontend sender audio blob til Flask backend.",
"Backend håndterer provider communication.",
"Provider logic skal ligge bag en adapter/interface.",
"STT provider skal senere kunne udskiftes uden ændringer i game logic.",
"Transcription skal returnere plain text og metadata.",
"Ingen gameplaybeslutning må foretages i STT-provider adapteren."
],
"suggested_interface": {
"file": "backend/app/services/speech_to_text.py",
"methods": [
"transcribe(audio_bytes, language='da')"
],
"output": {
"text": "string",
"language": "da",
"confidence": "float|null",
"provider": "string"
}
}
},
"frontend_voice_capture": {
"technology": [
"navigator.mediaDevices.getUserMedia",
"MediaRecorder"
],
"components": [
"VoiceButton",
"VoiceRecorder",
"VoiceStatus",
"VoiceTranscriptDebug"
],
"ui_states": [
"idle",
"requesting_permission",
"listening",
"processing",
"understood",
"need_retry",
"error"
],
"microphone_button": {
"size": "large",
"touch_friendly": true,
"icon": "microphone",
"animations": {
"idle": "gentle pulse",
"listening": "animated listening pulse",
"processing": "soft spinner",
"success": "positive bounce"
}
},
"child_facing_status_examples": {
"idle": "Tryk og fortæl mig dit svar!",
"listening": "Jeg lytter...",
"processing": "Hmm, lad mig tænke...",
"understood": "Ja!",
"retry": "Jeg hørte dig ikke helt. Vil du prøve igen?"
},
"requirements": [
"Ingen tekniske fejlbeskeder må vises til barnet.",
"Microphone permission denial skal håndteres.",
"Hvis microphone ikke virker, skal normale knapper fortsat kunne bruges.",
"Audio recording skal stoppes korrekt ved component unmount.",
"Gamle MediaRecorder streams skal closes.",
"Mikrofonstatus skal være visuelt tydelig.",
"Undgå at optage kontinuerligt i baggrunden."
]
},
"voice_api": {
"new_endpoints": [
{
"method": "POST",
"path": "/api/v1/games/<game_id>/voice",
"content_type": "multipart/form-data",
"purpose": "Transcribe audio, match voice answer og returnér intent/result."
},
{
"method": "POST",
"path": "/api/v1/games/<game_id>/voice/text",
"content_type": "application/json",
"purpose": "Development/debug endpoint hvor transcribed text kan testes uden microphone."
}
],
"voice_request_fields": {
"audio": "binary audio file",
"scene_id": "string",
"question_id": "string optional"
},
"text_debug_request": {
"scene_id": "forest_02",
"text": "jeg tror vi skal kigge inde mellem træerne"
},
"response_example": {
"ok": true,
"data": {
"transcript": "jeg tror vi skal kigge inde mellem træerne",
"normalized_text": "jeg tror vi skal kigge inde mellem træerne",
"matched": true,
"intent": "forest",
"confidence": 0.96,
"match_method": "keyword",
"response_type": "accepted",
"child_response": "Ja! Lad os kigge mellem træerne!",
"next_action": {
"type": "scene_choice",
"choice_id": "search_forest"
}
}
}
},
"voice_question_schema": {
"id": "find_bunny",
"prompt": "Hvor tror du, kaninen gemmer sig?",
"prompt_tts": "Hvor tror du, kaninen gemmer sig?",
"allow_voice": true,
"allow_buttons": true,
"intents": [
{
"id": "forest",
"choice_id": "search_forest",
"keywords": [
"skov",
"skoven",
"træ",
"træer",
"træerne",
"mellem træerne"
],
"synonyms": [
"inde i skoven",
"henne ved træerne",
"under træerne"
],
"positive_response": [
"Ja! Lad os kigge i skoven!",
"God idé! Måske gemmer den sig mellem træerne.",
"Det prøver vi!"
]
},
{
"id": "garden",
"choice_id": "search_garden",
"keywords": [
"have",
"haven",
"blomst",
"blomster",
"blomsterne"
],
"synonyms": [
"i blomsterne",
"ude i haven"
]
},
{
"id": "house",
"choice_id": "search_house",
"keywords": [
"hus",
"huset",
"indenfor",
"inde"
]
}
],
"fallback": {
"retry_prompt": "Hmm... skal vi kigge i skoven, i haven eller i huset?",
"max_retries": 2,
"after_max_retries": "show_visual_choices"
}
},
"normalization": {
"file": "backend/app/game/voice_normalizer.py",
"requirements": [
"Lowercase.",
"Trim whitespace.",
"Collapse repeated whitespace.",
"Remove irrelevant punctuation.",
"Bevar danske tegn æ, ø og å.",
"Normalisér almindelige transcription artifacts.",
"Kun minimal grammatisk behandling.",
"Barnet må ikke være afhængigt af korrekt bøjning."
],
"optional_features": [
"simple Danish stemming",
"common child pronunciation map",
"common STT error map"
],
"examples": [
{
"input": "SKOVEN!!",
"output": "skoven"
},
{
"input": "Jeg tror... den er ved træerne.",
"output": "jeg tror den er ved træerne"
}
]
},
"matching_engine": {
"file": "backend/app/game/voice_matcher.py",
"objective": "Klassificér barnets svar mod de intents, som den aktuelle scene tillader.",
"important_rule": "Matcher må aldrig vælge mellem intents, som ikke findes i den aktuelle scenes voice configuration.",
"methods_in_priority_order": [
{
"method": "exact_phrase",
"weight": 1.0
},
{
"method": "keyword",
"weight": 0.95
},
{
"method": "synonym",
"weight": 0.9
},
{
"method": "token_overlap",
"weight": 0.8
},
{
"method": "fuzzy",
"weight": 0.7
},
{
"method": "llm",
"weight": "dynamic"
}
],
"confidence_thresholds": {
"accept": 0.8,
"clarify": 0.55,
"fallback": 0.0
},
"requirements": [
"Match hele ord hvor muligt.",
"Undgå naive substring matches der giver falske positive resultater.",
"Flere keywords for samme intent skal øge confidence.",
"Conflicting intents skal ikke automatisk vælges hvis scores er næsten ens.",
"Returnér debug metadata server-side.",
"Matching skal være unit-testable uden Flask."
]
},
"fuzzy_matching": {
"recommended_library": "RapidFuzz",
"use_cases": [
"STT transcription typo",
"child pronunciation approximated by STT",
"singular/plural differences",
"minor spelling deviations"
],
"rules": [
"Fuzzy match må kun sammenligne med ord og phrases i aktive intents.",
"Lav fuzzy confidence alene må ikke auto-acceptes.",
"Korte ord skal have strengere threshold end lange ord."
],
"example": {
"transcript": "skåven",
"candidate": "skoven",
"result": "possible forest intent"
}
},
"child_language_handling": {
"principle": "Et barns svar behøver ikke matche de ord, en voksen ville bruge.",
"examples": [
{
"question": "Hvor skal vi lede efter kaninen?",
"child_answer": "der hvor alle træerne er",
"expected_intent": "forest"
},
{
"question": "Hvordan kommer vi over floden?",
"child_answer": "vi kan flyve",
"expected_intent": "fly"
},
{
"question": "Hvem skal hjælpe os?",
"child_answer": "den lilla pony",
"expected_behavior": "match character alias if purple pony is a valid current choice"
}
]
},
"llm_fallback": {
"enabled": true,
"model": "Qwen3.6-27B",
"purpose": "Klassificér kreative eller indirekte børnesvar til én af de allerede tilladte intents.",
"critical_rules": [
"Qwen må ikke opfinde nye scene-intents.",
"Qwen skal vælge blandt en eksplicit liste eller returnere no_match.",
"Qwen skal ikke styre state direkte.",
"Qwen output skal være strict JSON.",
"Backend skal validere Qwen output.",
"Hvis modellen returnerer invalid JSON, behandles det som no_match.",
"LLM må ikke være nødvendig for at gennemføre spillet."
],
"input_minimization": {
"send": [
"scene question",
"child transcript",
"allowed intent IDs",
"short descriptions of allowed intents"
],
"do_not_send": [
"entire game source code",
"entire adventure",
"unrelated player information"
]
},
"system_prompt": "Du klassificerer korte danske svar fra et barn omkring 4 år. Barnets sprog kan være upræcist, kreativt eller grammatisk ukorrekt. Du må kun vælge blandt de intents, du får udleveret. Forsøg at forstå betydningen uden at kræve eksakt ordmatch. Hvis svaret ikke med rimelig sikkerhed matcher en mulighed, returner no_match. Returnér kun valid JSON.",
"input_example": {
"question": "Hvor skal vi lede efter kaninen?",
"transcript": "der hvor alle træerne står",
"allowed_intents": [
{
"id": "forest",
"description": "lede i skoven eller omkring træerne"
},
{
"id": "garden",
"description": "lede i haven eller blomsterne"
},
{
"id": "house",
"description": "lede inde i huset"
}
]
},
"required_output_schema": {
"intent": "forest|garden|house|no_match",
"confidence": 0.0,
"reason": "short internal classification explanation"
},
"game_rule": "reason må aldrig vises direkte til barnet."
},
"response_strategy": {
"principle": "Spillet skal understøtte barnet fremfor at bedømme barnet.",
"result_types": {
"accepted": {
"behavior": "Bekræft positivt og fortsæt.",
"examples": [
"Ja! Lad os gøre det!",
"God idé!",
"Det prøver vi!",
"Woohoo! Afsted!"
]
},
"creative_accepted": {
"behavior": "Anerkend barnets formulering og map den til nærmeste valide handling."
},
"clarify": {
"behavior": "Gentag spørgsmålet med 2-3 konkrete muligheder."
},
"no_match": {
"behavior": "Sig at ponyen ikke helt hørte/forstod og hjælp barnet med valgmuligheder."
},
"stt_error": {
"behavior": "Bed barnet prøve igen uden at antyde at barnet gjorde noget forkert."
}
},
"forbidden_child_responses": [
"Forkert",
"Du svarede forkert",
"Ugyldigt svar",
"Error",
"Invalid input",
"Speech recognition failed"
]
},
"retry_logic": {
"attempt_1": {
"strategy": "normal voice question"
},
"attempt_2": {
"strategy": "simpler wording and explicit choices"
},
"attempt_3": {
"strategy": "show large visual buttons and optionally read them aloud"
},
"rule": "Voice recognition må aldrig blokere historien permanent."
},
"example_interaction_1": {
"scene": "Fluttershy leder efter Angel Bunny.",
"pony": "Hvor tror du, Angel gemmer sig?",
"child": "I skoven!",
"transcript": "i skoven",
"matched_intent": "forest",
"response": "Ja! Lad os kigge mellem træerne!",
"result": "continue"
},
"example_interaction_2": {
"pony": "Hvordan kommer vi over den store flod?",
"valid_intents": [
"bridge",
"fly",
"boat"
],
"child": "Rainbow Dash kan bare tage os op!",
"deterministic_match": "no_match",
"llm_result": {
"intent": "fly",
"confidence": 0.92
},
"response": "Ja! Vi kan flyve over floden!",
"result": "continue"
},
"example_interaction_3": {
"pony": "Hvilken vej skal vi gå?",
"valid_intents": [
"left",
"right"
],
"child": "Jeg vil hen til slottet!",
"result": "clarify",
"response": "Okay! Skal vi gå denne vej eller denne vej?",
"ui": "highlight two large visual direction buttons"
},
"example_interaction_4": {
"pony": "Hvilken farve er æblet?",
"valid_intents": [
"red",
"green"
],
"child": "Det er et kæmpe æble!",
"result": "clarify",
"response": "Ja, det er et stort æble! Er det rødt eller grønt?"
},
"tts": {
"phase": "recommended after core voice input works",
"objective": "Gør spillet næsten fuldt læsefrit.",
"features": [
"Read scene narration aloud.",
"Read pony dialogue aloud.",
"Read questions aloud.",
"Read retry prompts aloud.",
"Read positive reactions aloud."
],
"architecture": {
"interface": "TextToSpeechProvider",
"provider_specific_logic": "isolated adapter"
},
"controls": [
"mute",
"voice volume",
"music volume",
"SFX volume"
],
"important": "TTS skal integreres med eksisterende SceneMusic/audio system så tale ikke overdøves af baggrundsmusik."
},
"audio_ducking": {
"required_if_tts_enabled": true,
"behavior": [
"Reduce background music volume when pony speaks.",
"Restore volume gradually after speech ends.",
"Pause listening while TTS is speaking to avoid the game transcribing itself."
]
},
"scene_data_extension": {
"objective": "Voice skal være content-driven og ikke hardcoded i React.",
"example": {
"id": "everfree_01",
"title": "Den forsvundne kanin",
"voice": {
"enabled": true,
"question": {
"id": "where_bunny",
"text": "Hvor tror du, kaninen gemmer sig?",
"intents": [
{
"id": "forest",
"choice_id": "forest_path",
"keywords": [
"skov",
"skoven",
"træ",
"træerne"
]
},
{
"id": "garden",
"choice_id": "garden_path",
"keywords": [
"have",
"haven",
"blomst",
"blomster"
]
}
]
}
}
},
"rule": "Voice intents skal mappe til de samme underlying choice IDs som touch/mouse UI bruger."
},
"game_engine_integration": {
"critical_rule": "Voice er en alternativ inputmetode, ikke et separat gameplay-system.",
"existin

View File

@@ -0,0 +1,76 @@
"""Deterministic intent matching for short Danish child utterances."""
from dataclasses import asdict, dataclass
from difflib import SequenceMatcher
import re
from app.game.voice_normalizer import normalize_danish
@dataclass(frozen=True)
class MatchResult:
matched: bool
intent: str | None
confidence: float
method: str
normalized_text: str
response_type: str
scores: dict
def to_dict(self):
return asdict(self)
def _contains_phrase(text, phrase):
return bool(re.search(rf"(?<!\w){re.escape(phrase)}(?!\w)", text))
def _fuzzy_score(text, phrase):
text_tokens = text.split()
phrase_tokens = phrase.split()
windows = [" ".join(text_tokens[i:i + len(phrase_tokens)])
for i in range(max(1, len(text_tokens) - len(phrase_tokens) + 1))]
return max((SequenceMatcher(None, value, phrase).ratio() for value in windows), default=0.0)
def match_intent(text, intents, accept_threshold=0.8, clarify_threshold=0.55):
"""Match only against the supplied active intents.
Ambiguous top scores are returned as ``clarify`` rather than guessed.
"""
normalized = normalize_danish(text)
if not normalized or not intents:
return MatchResult(False, None, 0.0, "none", normalized, "no_match", {})
results = []
for intent in intents:
candidates = []
candidates.extend((normalize_danish(x), "keyword") for x in intent.get("keywords", []))
candidates.extend((normalize_danish(x), "synonym") for x in intent.get("synonyms", []))
candidates = [(phrase, kind) for phrase, kind in candidates if phrase]
best_score, best_method = 0.0, "none"
phrase_hits = 0
for phrase, kind in candidates:
if normalized == phrase:
score, method = 1.0, "exact_phrase"
elif _contains_phrase(normalized, phrase):
score, method = (0.95 if kind == "keyword" else 0.9), kind
phrase_hits += 1
else:
fuzzy = _fuzzy_score(normalized, phrase)
minimum = 0.88 if len(phrase) <= 4 else 0.78
score, method = ((0.7 + (fuzzy - minimum) * 0.5), "fuzzy") if fuzzy >= minimum else (0.0, "none")
if score > best_score:
best_score, best_method = score, method
if phrase_hits > 1:
best_score = min(1.0, best_score + min(0.04, 0.02 * (phrase_hits - 1)))
results.append((intent.get("id"), round(best_score, 3), best_method))
results.sort(key=lambda item: item[1], reverse=True)
top_id, top_score, top_method = results[0]
scores = {intent_id: score for intent_id, score, _ in results if intent_id}
ambiguous = len(results) > 1 and top_score >= clarify_threshold and top_score - results[1][1] < 0.08
if ambiguous or top_score < accept_threshold:
response_type = "clarify" if top_score >= clarify_threshold else "no_match"
return MatchResult(False, None, top_score, top_method, normalized, response_type, scores)
return MatchResult(True, top_id, top_score, top_method, normalized, "accepted", scores)

View File

@@ -0,0 +1,23 @@
"""Small, deterministic normalizer for Danish speech transcripts."""
import re
import unicodedata
_STT_REPLACEMENTS = {
"okay": "ok",
"okey": "ok",
"ja tak": "ja",
}
def normalize_danish(text):
"""Normalize a transcript while preserving the Danish letters æ, ø and å."""
if not isinstance(text, str):
return ""
value = unicodedata.normalize("NFC", text).lower().strip()
value = re.sub(r"[^a-z0-9æøå\s-]", " ", value)
value = re.sub(r"\s+", " ", value).strip()
for source, target in _STT_REPLACEMENTS.items():
value = re.sub(rf"\b{re.escape(source)}\b", target, value)
return value

View File

@@ -0,0 +1,74 @@
"""Replaceable speech-to-text providers.
The default provider is disabled. Configure an OpenAI-compatible transcription
endpoint with STT_BASE_URL, STT_API_KEY and optionally STT_MODEL.
"""
from dataclasses import asdict, dataclass
import json
import mimetypes
import os
import uuid
from urllib import request
class SpeechToTextError(RuntimeError):
pass
@dataclass(frozen=True)
class Transcript:
text: str
language: str = "da"
confidence: float | None = None
provider: str = "unknown"
def to_dict(self):
return asdict(self)
class SpeechToTextProvider:
def transcribe(self, audio_bytes, language="da", filename="audio.webm", content_type=None):
raise NotImplementedError
class DisabledSpeechToTextProvider(SpeechToTextProvider):
def transcribe(self, audio_bytes, language="da", filename="audio.webm", content_type=None):
raise SpeechToTextError("Speech-to-text is not configured")
class OpenAICompatibleSpeechToTextProvider(SpeechToTextProvider):
def __init__(self, base_url, api_key, model="whisper-1", timeout=20):
self.url = base_url.rstrip("/") + "/audio/transcriptions"
self.api_key, self.model, self.timeout = api_key, model, timeout
def transcribe(self, audio_bytes, language="da", filename="audio.webm", content_type=None):
boundary = "----PonyVoice" + uuid.uuid4().hex
mime = content_type or mimetypes.guess_type(filename)[0] or "application/octet-stream"
fields = [("model", self.model), ("language", language), ("response_format", "json")]
body = bytearray()
for name, value in fields:
body.extend(f"--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"\r\n\r\n{value}\r\n".encode())
body.extend(f"--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{filename}\"\r\nContent-Type: {mime}\r\n\r\n".encode())
body.extend(audio_bytes)
body.extend(f"\r\n--{boundary}--\r\n".encode())
req = request.Request(self.url, data=bytes(body), method="POST", headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": f"multipart/form-data; boundary={boundary}",
})
try:
with request.urlopen(req, timeout=self.timeout) as response:
payload = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise SpeechToTextError("Speech-to-text provider failed") from exc
text = payload.get("text", "").strip()
if not text:
raise SpeechToTextError("Speech-to-text returned no transcript")
return Transcript(text, payload.get("language", language), payload.get("confidence"), "openai-compatible")
def get_speech_to_text_provider():
base_url, api_key = os.getenv("STT_BASE_URL"), os.getenv("STT_API_KEY")
if base_url and api_key:
return OpenAICompatibleSpeechToTextProvider(base_url, api_key, os.getenv("STT_MODEL", "whisper-1"))
return DisabledSpeechToTextProvider()