34 Commits

Author SHA1 Message Date
37b88b8cb0 chore: preflight write check [skip ci] 2026-03-02 00:30:11 +00:00
258025ac4e feat(#239): add angular i18n shell namespace bridge
All checks were successful
CI / test-and-quality (push) Successful in 3m43s
CI / test-and-quality (pull_request) Successful in 3m20s
2026-03-02 00:13:12 +00:00
f28a390f95 feat(i18n): add shared key manifest and drift check script
All checks were successful
CI / test-and-quality (push) Successful in 3m27s
CI / test-and-quality (pull_request) Successful in 3m33s
2026-03-02 00:12:17 +00:00
a1bb1ccbed Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#242) from dev/issue-223-secondary-device-audio-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m35s
2026-03-02 01:08:30 +01:00
ee025e8deb Guard legacy player client against secondary-device audio playback
All checks were successful
CI / test-and-quality (push) Successful in 2m58s
CI / test-and-quality (pull_request) Successful in 3m0s
2026-03-02 00:00:40 +00:00
b977016ef4 Merge pull request '[MVP][READY] #175-C Angular host/player integration + hardcoded kerneflow-tekster cleanup (#227)' (#238) from feat/issue-227-angular-host-player-i18n-cleanup into main
All checks were successful
CI / test-and-quality (push) Successful in 2m38s
2026-03-02 00:50:44 +01:00
1b899a30a2 fix(#227): remove hardcoded unknown-error fallback in host/player flow
All checks were successful
CI / test-and-quality (push) Successful in 3m12s
CI / test-and-quality (pull_request) Successful in 3m14s
2026-03-01 23:42:47 +00:00
187b26e561 Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback)' (#237) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m45s
2026-03-02 00:36:16 +01:00
0b4ddaf43f Merge pull request '[MVP][READY] #223 Telefon-klient guard: stop aktiv lyd på secondary device' (#236) from feature/issue-223-secondary-device-audio-guard-followup into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-02 00:36:13 +01:00
7a3d649e11 fix(i18n): normalize underscore locale tags before fallback (#225)
All checks were successful
CI / test-and-quality (push) Successful in 3m55s
CI / test-and-quality (pull_request) Successful in 3m2s
2026-03-01 23:29:49 +00:00
f50f6a08ae fix(player): silence active media on secondary-device guard install
All checks were successful
CI / test-and-quality (push) Successful in 3m39s
CI / test-and-quality (pull_request) Successful in 3m54s
2026-03-01 23:29:15 +00:00
000a486db1 Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#235) from feature/issue-223-player-audio-guard into main
All checks were successful
CI / test-and-quality (push) Successful in 2m47s
2026-03-02 00:21:38 +01:00
845e94b726 fix(player): ref-count secondary-device audio guard lifecycle
All checks were successful
CI / test-and-quality (pull_request) Successful in 3m16s
CI / test-and-quality (push) Successful in 3m19s
2026-03-01 23:13:34 +00:00
5fe8f92ee4 Merge pull request '[MVP][READY] #220 Angular host/player shared i18n key-map bootstrap (da+en)' (#233) from feat/issue-220-angular-shared-i18n-keymap-bootstrap into main
All checks were successful
CI / test-and-quality (push) Successful in 3m45s
2026-03-02 00:00:04 +01:00
e2f184d1bc Merge pull request '[MVP][READY] #175-B: Shared key-map + locale-kontrakt mellem backend/frontend' (#231) from feat/issue-226-shared-keymap-locale-contract into main
All checks were successful
CI / test-and-quality (push) Successful in 3m11s
2026-03-01 23:55:55 +01:00
ab41798220 Merge pull request '[MVP][READY] #223 Telefon-klient guard: ingen lydafspilning på secondary device' (#234) from feature/issue-223-player-audio-guard into main
Some checks failed
CI / test-and-quality (push) Has been cancelled
2026-03-01 23:53:22 +01:00
c7ff3d96de docs(i18n): normalize flow table to host/player/system families
All checks were successful
CI / test-and-quality (push) Successful in 3m44s
CI / test-and-quality (pull_request) Successful in 3m21s
2026-03-01 22:51:13 +00:00
3398aead7f frontend: consume shared backend->frontend error map at runtime
All checks were successful
CI / test-and-quality (push) Successful in 3m57s
CI / test-and-quality (pull_request) Successful in 4m1s
2026-03-01 22:49:49 +00:00
97945ede92 fix(issue-226): map host_only_action in shared backend→frontend key map 2026-03-01 22:49:49 +00:00
3655bad847 Merge pull request '[MVP][READY] #225 Backend i18n baseline (resolver + fallback)' (#232) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 3m59s
2026-03-01 23:44:22 +01:00
fdef33f44a docs(issue-223): add audio guard acceptance artifact
All checks were successful
CI / test-and-quality (push) Successful in 3m2s
CI / test-and-quality (pull_request) Successful in 2m57s
2026-03-01 22:43:32 +00:00
bc78f79f78 docs(i18n): align issue-220 families and contract mapping
All checks were successful
CI / test-and-quality (push) Successful in 3m54s
CI / test-and-quality (pull_request) Successful in 3m57s
2026-03-01 22:42:22 +00:00
784622058a docs(i18n): add Angular host/player key-map bootstrap for MVP flow (#220)
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Successful in 3m48s
2026-03-01 22:38:54 +00:00
257732e2ab feat(issue-225): extend backend i18n error contract to flow endpoints
All checks were successful
CI / test-and-quality (push) Successful in 3m40s
CI / test-and-quality (pull_request) Successful in 3m43s
2026-03-01 22:32:33 +00:00
64fe273691 Merge pull request '[MVP][READY] #226 Shared key-map + locale-kontrakt mellem backend/frontend' (#230) from feat/issue-225-backend-i18n-baseline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m33s
2026-03-01 23:23:23 +01:00
cd6fb06343 feat(issue-226): add shared backend-frontend key-map and locale contract
All checks were successful
CI / test-and-quality (push) Successful in 3m41s
CI / test-and-quality (pull_request) Successful in 3m17s
2026-03-01 22:14:08 +00:00
508d462bb6 test(lobby): cover backend locale resolver normalization and default fallback
Some checks failed
CI / test-and-quality (push) Has been cancelled
CI / test-and-quality (pull_request) Failing after 3m4s
2026-03-01 22:12:43 +00:00
e435a41660 Merge pull request '[MVP] Angular-first host+player i18n integration without React (issue #222)' (#229) from feat/issue-222-angular-first-host-player-i18n into main
All checks were successful
CI / test-and-quality (push) Successful in 2m37s
2026-03-01 23:06:12 +01:00
b9bfe55f93 Merge pull request '[MVP][READY] #224 Trunk-sekvens for #175: A/B/C små mergeklare bidder' (#228) from feat/issue-224-trunk-sequence-175 into main
Some checks are pending
CI / test-and-quality (push) Has started running
2026-03-01 23:03:38 +01:00
8e21ca8e5e docs(issue-224): clarify docs-only verification
All checks were successful
CI / test-and-quality (push) Successful in 3m35s
CI / test-and-quality (pull_request) Successful in 2m46s
2026-03-01 21:56:38 +00:00
ddf8e874e2 feat(issue-222): wire angular host/player i18n to backend shell locale
All checks were successful
CI / test-and-quality (push) Successful in 3m37s
CI / test-and-quality (pull_request) Successful in 3m38s
2026-03-01 21:54:32 +00:00
21a25a063c docs(issue-224): define A/B/C trunk sequence for #175
All checks were successful
CI / test-and-quality (push) Successful in 3m45s
CI / test-and-quality (pull_request) Successful in 3m50s
2026-03-01 21:51:54 +00:00
4e300e4631 feat(player): guard against audio playback on secondary device 2026-03-01 21:51:54 +00:00
5fe9939057 Merge pull request '[Need-to-have] #175 Shared i18n contract docs + bilingual MVP flow smoke' (#218) from dev/issue-175-shared-i18n-mainline into main
All checks were successful
CI / test-and-quality (push) Successful in 2m27s
2026-03-01 22:34:32 +01:00
22 changed files with 936 additions and 46 deletions

View File

@@ -0,0 +1,20 @@
# Issue #223 — Telefon-klient audio guard (artifact)
## Scope leveret
- Telefon-/player-klient installerer en eksplicit audio guard ved mount (`installSecondaryDeviceAudioGuard`).
- Guard overskriver `HTMLMediaElement.prototype.play` til en no-op på secondary device (client policy: `client_has_no_audio_output=true`).
- Guard fjernes igen ved unmount (`ngOnDestroy`) så øvrige flows/enheder ikke påvirkes.
- Ingen audio-controls er eksponeret i player-shell UI.
## Acceptance mapping
1. **Telefon-klient trigger ikke audio playback i kerneflow**
- Verificeret af test: `player-shell.component.spec.ts` (`installs secondary-device audio guard while player shell is mounted`).
2. **Primær enhed påvirkes ikke negativt**
- Guard er scoped til player-shell lifecycle og restore'r original `play` ved destroy.
3. **Enkel test/verifikation dokumenteret**
- Dokumenteret her + testkørsel nedenfor.
## Testkørsel
- Kommando:
- `cd frontend/angular && npm test -- src/app/features/player/player-shell.component.spec.ts`
- Resultat: bestået (inkl. audio-guard test).

View File

@@ -0,0 +1,55 @@
# Issue #224 — Trunk-sekvens for #175 (A/B/C)
Formål: gøre #175 scheduler-klar som tre små, uafhængige og mergeklare bidder.
## Sekvens
### A) Backend i18n baseline
- Tracking issue: #225
- Scope:
- Backend resolver til locale (da/en)
- Fallback til `en` ved unsupported locale
- Stabil fejlkontrakt i payload (`error_code`, `error`, `locale`)
- Mergebarhed: Kan merges uden frontend-ændringer.
- Acceptance:
- Backend tests dækker `da` + fallback `en`
- Kontraktfelter er stabile i response
### B) Shared key-map + locale-kontrakt
- Tracking issue: #226
- Scope:
- Én shared key-map for lobby/kerneflow
- Locale-kontrakt (tilladte locales, default locale, fallback-regler)
- Dokumentation af naming + ownership
- Mergebarhed: Kan merges uden host/player UI-migrering.
- Acceptance:
- Shared kontrakt findes ét sted
- Begge sider kan importere den
- Docs opdateret med da/en eksempler
### C) Angular host/player integration + hardcoded-text cleanup
- Tracking issue: #227
- Scope:
- Angular host/player kerneflow bruger shared keys
- Hardcoded tekster fjernes i aftalte kernekomponenter
- Sprogskift verificeres i kritiske states
- Mergebarhed: Kan merges selvstændigt når frontend-tests er grønne.
- Acceptance:
- Korrekt i18n-copy i da/en i kerneflow
- Ingen hardcoded kerneflow-tekster tilbage
- Frontend tests/smoke grønne
## PR-grænser (per bid)
- 1 PR pr. bid (A/B/C) mod `main`
- Mål: ~200300 net LOC per PR (ekskl. generated artefakter)
- Undgå cross-layer scope creep
- Review-tid <30 min
## Overordnet acceptance for #224
- A/B/C-sekvens er tydelig med links
- Hver bid er mergebar isoleret
- Scheduler kan assigne direkte uden ekstra afklaring
## Verification (docs-only)
- Verificeret at dokumentet kun beskriver trunk-sekvensen for issue #224 og linker til #225/#226/#227.
- Ingen runtime-kode ændret; der er derfor ikke kørt kode/tests i denne PR.

View File

@@ -0,0 +1,30 @@
# ISSUE-226 — Shared key-map + locale-kontrakt (backend/frontend)
## Source of truth
- Single shared artifact: `shared/i18n/lobby.json`
- Ownership is documented under `contract.ownership` in the same artifact.
## Locale contract
Defined under `contract.locale`:
- default locale: `en`
- supported locales: `en`, `da`
- fallback rule: use default locale when requested locale is unsupported or a key translation is missing.
## Shared backend→frontend key-map
Defined under `contract.backend_to_frontend_error_keys`.
Examples:
- `session_not_found -> session_not_found`
- `session_not_joinable -> join_failed`
- `round_start_invalid_phase -> start_round_failed`
This allows backend error codes to remain stable while frontend copy keys stay UX-oriented.
## da/en example values
From `shared/i18n/lobby.json`:
- `frontend.errors.session_code_required.en = "Session code is required."`
- `frontend.errors.session_code_required.da = "Sessionskoden er påkrævet."`
## Verification
- Backend: `python manage.py test lobby.tests.I18nResolverTests`
- Frontend: `npm test -- --run tests/lobby-i18n.contract.test.ts`

34
docs/i18n-drift-check.md Normal file
View File

@@ -0,0 +1,34 @@
# i18n key manifest + drift check
Issue: #240
This repo keeps shared lobby keyspaces in two files:
- Contract source: `shared/i18n/lobby.json`
- Key manifest: `shared/i18n/key-manifest.json`
The manifest is intentionally small and explicit. It lists:
- Supported locales (`locales`)
- Frontend error key set (`frontend_error_keys`)
- Backend error code set (`backend_error_codes`)
- Backend translation key set (`backend_error_keys`)
- Optional contract-only aliases (`allowed_contract_only_backend_codes`)
## Local check
Run the read-only drift checker from repo root:
```bash
python3 scripts/check_i18n_drift.py
```
The script returns non-zero when it detects drift, including:
- key set mismatch between manifest and shared catalog
- missing backend→frontend mapping coverage
- mapping to unknown frontend keys
- mappings for unknown backend codes
- missing/empty locale translations (`en`/`da`)
No CI gating changes are included in this task; this is a local guardrail.

69
docs/i18n-keymap.md Normal file
View File

@@ -0,0 +1,69 @@
# i18n key-map bootstrap (Angular host/player MVP)
Issue: #220
Scope: Lobby → Join → Start round → Round → Reveal → Scoreboard
Locales: `en`, `da`
This document is the gameplay key-namespace map for Angular host/player MVP.
It maps existing text keys only (no feature expansion) and stays aligned with `shared/i18n/lobby.json`.
## Key families
- `host``frontend.ui.host.*` host-facing gameplay actions and status text.
- `player``frontend.ui.player.*` player-facing gameplay actions and status text.
- `system` — shared UI labels used across host/player views (implemented under `frontend.ui.common.*` in `shared/i18n/lobby.json`).
- `errors` — user-facing error keys shown by frontend (`frontend.errors.*`) plus backend code → frontend key bridge via `backend.error_codes.*` / `contract.backend_to_frontend_error_keys.*`.
## Gameplay flow key map
| Flow step | Family | Key | en | da |
|---|---|---|---|---|
| Lobby | `host` | `host.title` | Host gameplay flow | Vært gameplay-flow |
| Lobby | `player` | `player.title` | Player gameplay flow | Spiller gameplay-flow |
| Lobby | `system` (`frontend.ui.common`) | `common.session_code` | Session code | Sessionskode |
| Lobby | `player` | `player.nickname` | Nickname | Kaldenavn |
| Join | `player` | `player.join` | Join | Join |
| Start round | `host` | `host.start_round` | Start round | Start runde |
| Round | `host` | `host.show_question` | Show question | Vis spørgsmål |
| Round | `player` | `player.lie_label` | Lie | Løgn |
| Round | `player` | `player.submit_lie` | Submit lie | Send løgn |
| Round | `player` | `player.submit_guess` | Submit guess | Send gæt |
| Reveal | `host` | `host.mix_answers` | Mix answers → guess | Bland svar → gæt |
| Reveal | `host` | `host.calculate_scores` | Calculate scores → reveal | Udregn score → afslør |
| Scoreboard | `host` | `host.load_scoreboard` | Load scoreboard | Hent scoreboard |
| Scoreboard | `host` | `host.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `player` | `player.final_leaderboard` | Final leaderboard | Finale leaderboard |
| Scoreboard | `system` (`frontend.ui.common`) | `common.points_short` | pts | point |
## Frontend error keys used in flow scope
| Error family | Key | en | da |
|---|---|---|---|
| Join | `frontend.errors.session_code_required` | Session code is required. | Sessionskoden er påkrævet. |
| Join | `frontend.errors.session_not_found` | Session code is invalid or the session no longer exists. | Sessionskoden er ugyldig, eller sessionen findes ikke længere. |
| Join | `frontend.errors.nickname_invalid` | Nickname must be between 2 and 40 characters. | Kaldenavn skal være mellem 2 og 40 tegn. |
| Join | `frontend.errors.nickname_taken` | Nickname is already taken. | Kaldenavnet er allerede taget. |
| Join | `frontend.errors.join_failed` | Join failed. Check code or nickname and try again. | Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen. |
| Start round | `frontend.errors.start_round_failed` | Could not start round. Refresh the lobby and try again. | Kunne ikke starte runden. Opdater lobbyen og prøv igen. |
| Any | `frontend.errors.unknown` | Action failed. Refresh status and try again. | Handlingen fejlede. Opdater status og prøv igen. |
## Backend→frontend mapping for gameplay errors
Mapped in `contract.backend_to_frontend_error_keys` (source: `shared/i18n/lobby.json`):
> Note: `host_only_action` is **not** part of the current shared contract mapping and is intentionally not listed here.
- `session_code_required``session_code_required`
- `nickname_invalid``nickname_invalid`
- `session_not_found``session_not_found`
- `session_not_joinable``join_failed`
- `nickname_taken``nickname_taken`
- `category_slug_required``start_round_failed`
- `category_not_found``start_round_failed`
- `round_start_invalid_phase``start_round_failed`
- `round_already_configured``start_round_failed`
## Notes
- This is a bootstrap key-map doc for MVP mergeability.
- The key/value source of truth remains `shared/i18n/lobby.json`.

View File

@@ -55,7 +55,7 @@ type LeaderboardResponse = FinishGameResponse;
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre> <pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length"> <div *ngIf="finalLeaderboard.length">
<h3>{{ copy('host.final_leaderboard') }}</h3> <h3>{{ copy('host.final_leaderboard') }}</h3>
<p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} pts)</p> <p *ngIf="finalWinner"><strong>{{ copy('host.winner') }}:</strong> {{ finalWinner.nickname }} ({{ finalWinner.score }} {{ copy('common.points_short') }})</p>
<ol> <ol>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li> <li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol> </ol>
@@ -156,7 +156,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
try { try {
const state = await this.controller.hydrateLobby(this.sessionCode); const state = await this.controller.hydrateLobby(this.sessionCode);
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? 'Unknown error'); throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
} }
this.session = state.session as SessionDetail; this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code; this.sessionCode = this.session.session.code;
@@ -177,7 +177,7 @@ export class HostShellComponent implements OnInit, OnDestroy {
await this.runAction(async () => { await this.runAction(async () => {
const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim()); const state = await this.controller.startRound(this.sessionCode, this.categorySlug.trim());
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? 'Unknown error'); throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
} }
this.session = state.session as SessionDetail; this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code; this.sessionCode = this.session.session.code;

View File

@@ -350,4 +350,90 @@ describe('PlayerShellComponent gameplay wiring', () => {
component.ngOnDestroy(); component.ngOnDestroy();
}); });
it('silences active media elements when secondary-device audio guard is installed', () => {
const pauseAudio = vi.fn();
const pauseVideo = vi.fn();
const audioElement = { muted: false, pause: pauseAudio };
const videoElement = { muted: false, pause: pauseVideo };
const querySelectorAll = vi.fn().mockReturnValue([audioElement, videoElement]);
vi.stubGlobal('document', { querySelectorAll });
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
HTMLMediaElement: { prototype: { play: vi.fn().mockResolvedValue(undefined) } },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
expect(querySelectorAll).toHaveBeenCalledWith('audio,video');
expect(audioElement.muted).toBe(true);
expect(videoElement.muted).toBe(true);
expect(pauseAudio).toHaveBeenCalledTimes(1);
expect(pauseVideo).toHaveBeenCalledTimes(1);
component.ngOnDestroy();
});
it('installs secondary-device audio guard while player shell is mounted', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const component = new PlayerShellComponent();
component.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
component.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
it('keeps audio guard active until the last mounted player shell is destroyed', async () => {
const originalPlay = vi.fn().mockRejectedValue(new Error('original play'));
const mediaPrototype = { play: originalPlay };
vi.stubGlobal('window', {
location: { hash: '', search: '' },
history: { state: null, replaceState: vi.fn() },
localStorage: { getItem: vi.fn().mockReturnValue('en'), setItem: vi.fn(), removeItem: vi.fn() },
sessionStorage: { getItem: vi.fn().mockReturnValue(null), setItem: vi.fn(), removeItem: vi.fn() },
HTMLMediaElement: { prototype: mediaPrototype },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
const firstComponent = new PlayerShellComponent();
const secondComponent = new PlayerShellComponent();
firstComponent.ngOnInit();
secondComponent.ngOnInit();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
firstComponent.ngOnDestroy();
await expect(mediaPrototype.play()).resolves.toBeUndefined();
secondComponent.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play');
});
}); });

View File

@@ -16,6 +16,19 @@ interface SessionDetail {
type ConnectionState = 'online' | 'reconnecting' | 'offline'; type ConnectionState = 'online' | 'reconnecting' | 'offline';
type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null; type LoadingTransition = 'refresh' | 'join' | 'submit-lie' | 'submit-guess' | null;
type MediaPrototypeWithGuardState = {
play?: () => Promise<void>;
__wppSecondaryDeviceAudioGuard__?: {
originalPlay: () => Promise<void>;
installs: number;
};
};
type GuardableMediaElement = {
muted?: boolean;
pause?: () => void;
};
function resolveLocalStorage(): Storage | undefined { function resolveLocalStorage(): Storage | undefined {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return undefined; return undefined;
@@ -113,6 +126,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null; private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null; private unsubscribeLocale: (() => void) | null = null;
private restoreAudioGuard: (() => void) | null = null;
constructor() { constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) { if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -129,6 +143,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => { this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale; this.locale = locale;
}); });
this.installSecondaryDeviceAudioGuard();
const hashRoute = window.location.hash.replace(/^#\/?/, ''); const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i); const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
@@ -158,6 +173,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.clearStateSyncTimer(); this.clearStateSyncTimer();
this.unsubscribeLocale?.(); this.unsubscribeLocale?.();
this.unsubscribeLocale = null; this.unsubscribeLocale = null;
this.restoreAudioGuard?.();
this.restoreAudioGuard = null;
} }
private readonly handleOnline = (): void => { private readonly handleOnline = (): void => {
@@ -185,6 +202,66 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
} }
} }
private installSecondaryDeviceAudioGuard(): void {
if (!this.clientHasNoAudioOutput || typeof window === 'undefined') {
return;
}
this.silenceExistingMediaElements();
const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: MediaPrototypeWithGuardState } }).HTMLMediaElement
?.prototype;
if (!mediaPrototype || typeof mediaPrototype.play !== 'function') {
return;
}
const guardState = mediaPrototype.__wppSecondaryDeviceAudioGuard__;
if (guardState) {
guardState.installs += 1;
} else {
const originalPlay = mediaPrototype.play;
mediaPrototype.play = () => Promise.resolve();
mediaPrototype.__wppSecondaryDeviceAudioGuard__ = {
originalPlay,
installs: 1,
};
}
this.restoreAudioGuard = () => {
const currentState = mediaPrototype.__wppSecondaryDeviceAudioGuard__;
if (!currentState) {
return;
}
currentState.installs -= 1;
if (currentState.installs <= 0) {
mediaPrototype.play = currentState.originalPlay;
delete mediaPrototype.__wppSecondaryDeviceAudioGuard__;
}
};
}
private silenceExistingMediaElements(): void {
if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') {
return;
}
const activeElements = document.querySelectorAll('audio,video') as
| NodeListOf<GuardableMediaElement>
| GuardableMediaElement[]
| undefined;
if (!activeElements || typeof (activeElements as { forEach?: unknown }).forEach !== 'function') {
return;
}
activeElements.forEach((element) => {
element.muted = true;
element.pause?.();
});
}
private scheduleStateSync(): void { private scheduleStateSync(): void {
this.clearStateSyncTimer(); this.clearStateSyncTimer();
@@ -232,7 +309,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
if (error instanceof Error && error.message) { if (error instanceof Error && error.message) {
return error.message; return error.message;
} }
return 'Unknown error'; return this.copy('common.unknown_error');
} }
private markOnline(): void { private markOnline(): void {
@@ -356,7 +433,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
try { try {
const state = await this.controller.hydrateLobby(this.sessionCode); const state = await this.controller.hydrateLobby(this.sessionCode);
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? 'Unknown error'); throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
} }
this.session = state.session as SessionDetail; this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code; this.sessionCode = this.session.session.code;
@@ -382,7 +459,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
try { try {
const state = await this.controller.joinLobby(this.sessionCode, this.nickname); const state = await this.controller.joinLobby(this.sessionCode, this.nickname);
if (!state.session || state.errorMessage) { if (!state.session || state.errorMessage) {
throw new Error(state.errorMessage ?? 'Unknown error'); throw new Error(state.errorMessage ?? this.copy('common.unknown_error'));
} }
this.session = state.session as SessionDetail; this.session = state.session as SessionDetail;
this.sessionCode = this.session.session.code; this.sessionCode = this.session.session.code;

View File

@@ -26,13 +26,13 @@ describe('i18n MVP flow smoke (host/player + audio policy)', () => {
host.ngOnInit(); host.ngOnInit();
player.ngOnInit(); player.ngOnInit();
expect(host.copy('host.start_round')).toBe('Start round'); expect(host.copy('game.host.start_round')).toBe('Start round');
expect(player.copy('player.submit_guess')).toBe('Submit guess'); expect(player.copy('game.player.submit_guess')).toBe('Submit guess');
setPreferredLocale('da'); setPreferredLocale('da');
expect(host.copy('host.start_round')).toBe('Start runde'); expect(host.copy('game.host.start_round')).toBe('Start runde');
expect(player.copy('player.submit_guess')).toBe('Send gæt'); expect(player.copy('game.player.submit_guess')).toBe('Send gæt');
player.ngOnDestroy(); player.ngOnDestroy();
host.ngOnDestroy(); host.ngOnDestroy();

View File

@@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => {
expect(updates).toEqual(['en', 'da']); expect(updates).toEqual(['en', 'da']);
}); });
it('prefers backend-provided shell locale over browser defaults', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock(),
});
vi.stubGlobal('document', {
body: { dataset: { wppLocale: 'da' } },
querySelector: vi.fn(() => null),
});
vi.stubGlobal('navigator', { language: 'en-US' });
const i18n = await import('./lobby-i18n');
expect(i18n.resolvePreferredLocale()).toBe('da');
});
it('falls back to default en translation when da key is intentionally missing', async () => { it('falls back to default en translation when da key is intentionally missing', async () => {
vi.stubGlobal('window', { vi.stubGlobal('window', {
location: { search: '' }, location: { search: '' },
@@ -77,6 +93,36 @@ describe('lobby i18n locale propagation', () => {
} }
}); });
it('resolves baseline shell/game keys from shared namespaces', async () => {
vi.stubGlobal('window', {
location: { search: '' },
localStorage: storageMock({ 'wpp.locale': 'da' }),
});
vi.stubGlobal('navigator', { language: 'da-DK' });
const i18n = await import('./lobby-i18n');
const baselineKeys = [
'lobby.shell.title',
'lobby.shell.host_nav',
'lobby.shell.player_nav',
'lobby.shell.language_label',
'common.refresh',
'common.session_code',
'game.host.title',
'game.host.start_round',
'game.player.title',
'game.player.submit_guess',
] as const;
for (const key of baselineKeys) {
const value = i18n.t(key, 'da');
expect(value).toBeTypeOf('string');
expect(value.length).toBeGreaterThan(0);
expect(value).not.toBe(key);
}
});
it('exposes primary-only audio routing policy to clients', async () => { it('exposes primary-only audio routing policy to clients', async () => {
vi.stubGlobal('window', { vi.stubGlobal('window', {
location: { search: '' }, location: { search: '' },

View File

@@ -32,11 +32,14 @@ export function resolvePreferredLocale(): SupportedLocale {
return activeLocale; return activeLocale;
} }
const rootLocale =
typeof document !== 'undefined' ? document.querySelector<HTMLElement>('app-root')?.dataset?.['wppLocale'] : null;
const shellLocale = typeof document !== 'undefined' ? document.body?.dataset?.['wppLocale'] : null;
const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang'); const queryLocale = new URLSearchParams(window.location?.search ?? '').get('lang');
const storedLocale = window.localStorage?.getItem?.('wpp.locale'); const storedLocale = window.localStorage?.getItem?.('wpp.locale');
const browserLocale = typeof navigator !== 'undefined' ? navigator.language : ''; const browserLocale = typeof navigator !== 'undefined' ? navigator.language : '';
activeLocale = normalizeLocale(queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE); activeLocale = normalizeLocale(rootLocale || shellLocale || queryLocale || storedLocale || browserLocale || DEFAULT_LOCALE);
return activeLocale; return activeLocale;
} }
@@ -62,10 +65,23 @@ export function subscribeToLocaleChanges(callback: (locale: SupportedLocale) =>
}; };
} }
function resolveCatalogPath(key: string): string {
if (key.startsWith('lobby.shell.')) {
return key.replace(/^lobby\.shell\./, 'app.');
}
if (key.startsWith('game.host.')) {
return key.replace(/^game\.host\./, 'host.');
}
if (key.startsWith('game.player.')) {
return key.replace(/^game\.player\./, 'player.');
}
return key;
}
export function t(key: string, locale: string): string { export function t(key: string, locale: string): string {
const normalizedLocale = normalizeLocale(locale); const normalizedLocale = normalizeLocale(locale);
const fallbackLocale = DEFAULT_LOCALE; const fallbackLocale = DEFAULT_LOCALE;
const segments = key.split('.'); const segments = resolveCatalogPath(key).split('.');
let cursor: unknown = lobbyCatalog.frontend.ui; let cursor: unknown = lobbyCatalog.frontend.ui;
for (const segment of segments) { for (const segment of segments) {

View File

@@ -2,6 +2,7 @@ import lobbyCatalog from '../../../shared/i18n/lobby.json';
const frontendErrors = lobbyCatalog.frontend.errors; const frontendErrors = lobbyCatalog.frontend.errors;
const localeConfig = lobbyCatalog.locales; const localeConfig = lobbyCatalog.locales;
const backendToFrontendErrorKeys = lobbyCatalog.contract.backend_to_frontend_error_keys as Record<string, keyof typeof frontendErrors>;
type FrontendErrorKey = keyof typeof frontendErrors; type FrontendErrorKey = keyof typeof frontendErrors;
type SupportedLocale = (typeof localeConfig.supported)[number]; type SupportedLocale = (typeof localeConfig.supported)[number];
@@ -40,9 +41,15 @@ export function lobbyMessageFromApiPayload(payload: unknown, fallbackKey: Fronte
const record = payload as Record<string, unknown>; const record = payload as Record<string, unknown>;
const code = typeof record.error_code === 'string' ? record.error_code : ''; const code = typeof record.error_code === 'string' ? record.error_code : '';
const payloadLocale = typeof record.locale === 'string' ? record.locale : locale; const payloadLocale = typeof record.locale === 'string' ? record.locale : locale;
if (!isFrontendErrorKey(code)) { const mappedKey = code ? backendToFrontendErrorKeys[code] : undefined;
return lobbyMessage(fallbackKey, payloadLocale);
if (mappedKey && isFrontendErrorKey(mappedKey)) {
return lobbyMessage(mappedKey, payloadLocale);
} }
return lobbyMessage(code, payloadLocale); if (isFrontendErrorKey(code)) {
return lobbyMessage(code, payloadLocale);
}
return lobbyMessage(fallbackKey, payloadLocale);
} }

View File

@@ -14,10 +14,16 @@ describe('shared i18n keyspace contract', () => {
} }
}); });
it('keeps backend error-code keyspace aligned with backend translations', () => { it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => {
for (const [code, key] of Object.entries(lobbyCatalog.backend.error_codes)) { for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) {
expect(code).toBe(key); const frontendKey =
expect(lobbyCatalog.backend.errors[key as keyof typeof lobbyCatalog.backend.errors]).toBeDefined(); lobbyCatalog.contract.backend_to_frontend_error_keys[
code as keyof typeof lobbyCatalog.contract.backend_to_frontend_error_keys
];
expect(lobbyCatalog.backend.errors[backendKey as keyof typeof lobbyCatalog.backend.errors]).toBeDefined();
expect(frontendKey, `missing frontend mapping for ${code}`).toBeTruthy();
expect(lobbyCatalog.frontend.errors[frontendKey as keyof typeof lobbyCatalog.frontend.errors]).toBeDefined();
} }
for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) { for (const [key, translations] of Object.entries(lobbyCatalog.backend.errors)) {
@@ -42,10 +48,19 @@ describe('lobbyMessage locale handling', () => {
).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.'); ).toBe('Sessionskoden er ugyldig, eller sessionen findes ikke længere.');
}); });
it('falls back when backend error code has no frontend translation key', () => { it('uses shared backend→frontend key-map at runtime even when fallback key differs', () => {
expect( expect(
lobbyMessageFromApiPayload( lobbyMessageFromApiPayload(
{ error_code: 'session_not_joinable', locale: 'da' }, { error_code: 'session_not_joinable', locale: 'da' },
'start_round_failed',
),
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
});
it('falls back to caller-provided fallback key for unknown backend error codes', () => {
expect(
lobbyMessageFromApiPayload(
{ error_code: 'unknown_backend_key', locale: 'da' },
'join_failed', 'join_failed',
), ),
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.'); ).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');

View File

@@ -36,7 +36,13 @@ def lobby_i18n_error_messages() -> dict:
def resolve_locale(request: HttpRequest) -> str: def resolve_locale(request: HttpRequest) -> str:
default_locale, supported_locales = i18n_locale_config() default_locale, supported_locales = i18n_locale_config()
requested = (get_language_from_request(request) or "").split("-", 1)[0].lower()
raw_accept_language = (request.META.get("HTTP_ACCEPT_LANGUAGE") or "").split(",", 1)[0]
raw_requested = raw_accept_language.split(";", 1)[0].strip().replace("_", "-").split("-", 1)[0].lower()
if raw_requested in supported_locales:
return raw_requested
requested = (get_language_from_request(request) or "").replace("_", "-").split("-", 1)[0].lower()
if requested in supported_locales: if requested in supported_locales:
return requested return requested
return default_locale return default_locale

View File

@@ -89,6 +89,8 @@ var connectionRetryInFlight=false;
var playerShellFatalError=false; var playerShellFatalError=false;
var playerShellRecoverInFlight=false; var playerShellRecoverInFlight=false;
var playerCriticalHydrated=false; var playerCriticalHydrated=false;
function silencePlayerMediaElements(){if(typeof document==="undefined"||typeof document.querySelectorAll!=="function"){return;}var elements=document.querySelectorAll("audio,video");if(!elements||typeof elements.forEach!=="function"){return;}elements.forEach(function(element){if(!element){return;}element.muted=true;if(typeof element.pause==="function"){element.pause();}});}
function installSecondaryDeviceAudioGuard(){if(typeof window==="undefined"){return;}silencePlayerMediaElements();var mediaProto=window.HTMLMediaElement&&window.HTMLMediaElement.prototype;if(!mediaProto||typeof mediaProto.play!=="function"){return;}if(mediaProto.__wppSecondaryDeviceAudioGuardInstalled){return;}mediaProto.__wppSecondaryDeviceAudioGuardInstalled=true;mediaProto.__wppSecondaryDeviceAudioGuardOriginalPlay=mediaProto.play;mediaProto.play=function(){return Promise.resolve();};}
function setPlayerCriticalLoading(isLoading){var skeleton=document.getElementById("playerCriticalSkeleton");var view=document.getElementById("playerCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";} function setPlayerCriticalLoading(isLoading){var skeleton=document.getElementById("playerCriticalSkeleton");var view=document.getElementById("playerCriticalView");if(!skeleton||!view){return;}skeleton.style.display=isLoading?"block":"none";view.style.display=isLoading?"none":"block";}
function hydratePlayerCriticalView(data){var phaseEl=document.getElementById("playerCriticalPhase");var roundEl=document.getElementById("playerCriticalRound");var joinEl=document.getElementById("playerCriticalJoin");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||((data&&data.session&&data.session.status)||""));} function hydratePlayerCriticalView(data){var phaseEl=document.getElementById("playerCriticalPhase");var roundEl=document.getElementById("playerCriticalRound");var joinEl=document.getElementById("playerCriticalJoin");if(phaseEl){phaseEl.textContent="Fase: "+phaseLabel(currentSessionStatus||((data&&data.session&&data.session.status)||""));}
if(roundEl){roundEl.textContent="Round question: "+(rq()||"afventer");} if(roundEl){roundEl.textContent="Round question: "+(rq()||"afventer");}
@@ -153,6 +155,7 @@ function submitGuess(){if(guessSubmitted){return Promise.resolve({error:"guess_a
["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});}); ["code","nickname","playerId","sessionToken","roundQuestionId"].forEach(function(fieldId){var field=document.getElementById(fieldId);if(!field){return;}field.addEventListener("input",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});field.addEventListener("change",function(){if(fieldId!=="roundQuestionId"){resetRoundContextForManualChange();}updateLieSubmitState();updateGuessSubmitState();updateJoinState();updateSessionDetailState();savePlayerContext();});});
window.addEventListener("error",function(event){setPlayerShellFatalError((event&&event.message)||"Ukendt runtime-fejl");}); window.addEventListener("error",function(event){setPlayerShellFatalError((event&&event.message)||"Ukendt runtime-fejl");});
window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setPlayerShellFatalError(detail);}); window.addEventListener("unhandledrejection",function(event){var reason=event&&event.reason;var detail=(reason&&reason.message)||String(reason||"Unhandled promise rejection");setPlayerShellFatalError(detail);});
installSecondaryDeviceAudioGuard();
setPlayerCriticalLoading(true); setPlayerCriticalLoading(true);
updatePhaseStatus(); updatePhaseStatus();
updateGuessSubmitState(); updateGuessSubmitState();

View File

@@ -1,13 +1,13 @@
<!doctype html> <!doctype html>
<html lang="da"> <html lang="{{ shell_locale|default:'en' }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>WPP SPA Shell</title> <title>WPP SPA Shell</title>
<link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}"> <link rel="stylesheet" href="{{ spa_asset_base }}/styles.css?v={{ spa_asset_version|urlencode }}">
</head> </head>
<body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}"> <body data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">
<app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}">Indlæser Angular app-shell…</app-root> <app-root data-wpp-shell-route="{{ shell_route }}" data-wpp-shell-kind="{{ shell_kind }}" data-wpp-locale="{{ shell_locale|default:'en' }}">Indlæser Angular app-shell…</app-root>
<script type="module" src="{{ spa_asset_base }}/main.js?v={{ spa_asset_version|urlencode }}"></script> <script type="module" src="{{ spa_asset_base }}/main.js?v={{ spa_asset_version|urlencode }}"></script>
</body> </body>
</html> </html>

View File

@@ -20,7 +20,7 @@ from fupogfakta.models import (
RoundConfig, RoundConfig,
RoundQuestion, RoundQuestion,
) )
from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message from lobby.i18n import i18n_locale_config, lobby_i18n_catalog, resolve_error_message, resolve_locale
User = get_user_model() User = get_user_model()
@@ -209,6 +209,8 @@ class StartRoundTests(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_start_round")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can start round") self.assertEqual(response.json()["error"], "Only host can start round")
def test_start_round_requires_existing_active_category_with_questions(self): def test_start_round_requires_existing_active_category_with_questions(self):
@@ -228,6 +230,8 @@ class StartRoundTests(TestCase):
content_type="application/json", content_type="application/json",
) )
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error_code"], "category_has_no_questions")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Category has no active questions") self.assertEqual(response.json()["error"], "Category has no active questions")
def test_start_round_rejects_non_lobby_session(self): def test_start_round_rejects_non_lobby_session(self):
@@ -244,6 +248,36 @@ class StartRoundTests(TestCase):
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertEqual(response.json()["error"], "Round can only be started from lobby") self.assertEqual(response.json()["error"], "Round can only be started from lobby")
def test_start_round_error_localizes_to_danish(self):
self.client.login(username="other", password="secret123")
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="da",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_start_round")
self.assertEqual(response.json()["locale"], "da")
self.assertEqual(response.json()["error"], "Kun værten kan starte runden")
def test_start_round_error_falls_back_to_english_for_unsupported_locale(self):
self.client.login(username="other", password="secret123")
response = self.client.post(
reverse("lobby:start_round", kwargs={"code": self.session.code}),
data={"category_slug": self.category.slug},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="fr",
)
self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_start_round")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can start round")
class LieSubmissionTests(TestCase): class LieSubmissionTests(TestCase):
def setUp(self): def setUp(self):
@@ -434,6 +468,8 @@ class MixAnswersTests(TestCase):
) )
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertEqual(response.json()["error_code"], "host_only_mix_answers")
self.assertEqual(response.json()["locale"], "en")
self.assertEqual(response.json()["error"], "Only host can mix answers") self.assertEqual(response.json()["error"], "Only host can mix answers")
def test_mix_answers_deduplicates_case_insensitive_lies(self): def test_mix_answers_deduplicates_case_insensitive_lies(self):
@@ -999,6 +1035,10 @@ class UiScreenTests(TestCase):
self.assertContains(response, "clearPlayerShellFatalError") self.assertContains(response, "clearPlayerShellFatalError")
self.assertContains(response, "updatePlayerShellErrorBoundary") self.assertContains(response, "updatePlayerShellErrorBoundary")
self.assertContains(response, "player_shell_runtime_error") self.assertContains(response, "player_shell_runtime_error")
self.assertContains(response, "installSecondaryDeviceAudioGuard")
self.assertContains(response, "silencePlayerMediaElements")
self.assertContains(response, "querySelectorAll(\"audio,video\")")
self.assertNotContains(response, "<audio")
self.assertContains(response, "window.addEventListener(\"error\"") self.assertContains(response, "window.addEventListener(\"error\"")
@override_settings(USE_SPA_UI=False) @override_settings(USE_SPA_UI=False)
@@ -1216,6 +1256,38 @@ class SmokeStagingCommandTests(TestCase):
class I18nResolverTests(TestCase): class I18nResolverTests(TestCase):
def test_resolve_locale_accepts_language_tags_and_normalizes_to_supported_base_locale(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="da-DK,da;q=0.9,en;q=0.8",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(resolve_locale(response.wsgi_request), "da")
def test_resolve_locale_accepts_underscore_language_tags(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "", "nickname": "Luna"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="da_DK",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(resolve_locale(response.wsgi_request), "da")
def test_resolve_locale_defaults_to_en_when_header_missing(self):
response = self.client.post(
reverse("lobby:join_session"),
data={"code": "", "nickname": "Luna"},
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
self.assertEqual(resolve_locale(response.wsgi_request), "en")
def test_missing_backend_key_returns_key_deterministically(self): def test_missing_backend_key_returns_key_deterministically(self):
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key") self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")
@@ -1252,10 +1324,14 @@ class I18nResolverTests(TestCase):
self.assertTrue(translations.get("en"), f"frontend key {key} missing en") self.assertTrue(translations.get("en"), f"frontend key {key} missing en")
self.assertTrue(translations.get("da"), f"frontend key {key} missing da") self.assertTrue(translations.get("da"), f"frontend key {key} missing da")
def test_backend_error_codes_map_to_same_named_translation_keys(self): def test_backend_error_codes_map_via_shared_backend_frontend_key_map(self):
catalog = lobby_i18n_catalog() catalog = lobby_i18n_catalog()
backend_errors = catalog["backend"]["errors"] backend_errors = catalog["backend"]["errors"]
frontend_errors = catalog["frontend"]["errors"]
shared_map = catalog["contract"]["backend_to_frontend_error_keys"]
for code, key in catalog["backend"]["error_codes"].items(): for code, backend_key in catalog["backend"]["error_codes"].items():
self.assertEqual(code, key) frontend_key = shared_map.get(code)
self.assertIn(key, backend_errors) self.assertIn(backend_key, backend_errors)
self.assertTrue(frontend_key, f"missing frontend mapping for backend code: {code}")
self.assertIn(frontend_key, frontend_errors)

View File

@@ -5,7 +5,7 @@ from django.shortcuts import render
from fupogfakta.models import Category from fupogfakta.models import Category
from .feature_flags import use_spa_ui from .feature_flags import use_spa_ui
from .i18n import lobby_i18n_catalog from .i18n import lobby_i18n_catalog, resolve_locale
def _render_spa_shell(request, shell_route: str, shell_kind: str): def _render_spa_shell(request, shell_route: str, shell_kind: str):
@@ -18,6 +18,7 @@ def _render_spa_shell(request, shell_route: str, shell_kind: str):
"spa_asset_base": settings.WPP_SPA_ASSET_BASE, "spa_asset_base": settings.WPP_SPA_ASSET_BASE,
"spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"), "spa_asset_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
"lobby_i18n": lobby_i18n_catalog(), "lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
}, },
) )

View File

@@ -268,7 +268,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
) )
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can start round"}, status=403) return api_error(
request,
key=ERROR_CODES.get("host_only_start_round", "host_only_start_round"),
status=403,
)
if session.status != GameSession.Status.LOBBY: if session.status != GameSession.Status.LOBBY:
return api_error( return api_error(
@@ -287,7 +291,11 @@ def start_round(request: HttpRequest, code: str) -> JsonResponse:
) )
if not Question.objects.filter(category=category, is_active=True).exists(): if not Question.objects.filter(category=category, is_active=True).exists():
return JsonResponse({"error": "Category has no active questions"}, status=400) return api_error(
request,
key=ERROR_CODES.get("category_has_no_questions", "category_has_no_questions"),
status=400,
)
with transaction.atomic(): with transaction.atomic():
session = GameSession.objects.select_for_update().get(pk=session.pk) session = GameSession.objects.select_for_update().get(pk=session.pk)
@@ -340,21 +348,41 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(
request,
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can show question"}, status=403) return api_error(
request,
key=ERROR_CODES.get("host_only_show_question", "host_only_show_question"),
status=403,
)
if session.status != GameSession.Status.LIE: if session.status != GameSession.Status.LIE:
return JsonResponse({"error": "Question can only be shown in lie phase"}, status=400) return api_error(
request,
key=ERROR_CODES.get("show_question_invalid_phase", "show_question_invalid_phase"),
status=400,
)
try: try:
round_config = RoundConfig.objects.get(session=session, number=session.current_round) round_config = RoundConfig.objects.get(session=session, number=session.current_round)
except RoundConfig.DoesNotExist: except RoundConfig.DoesNotExist:
return JsonResponse({"error": "Round config missing"}, status=400) return api_error(
request,
key=ERROR_CODES.get("round_config_missing", "round_config_missing"),
status=400,
)
if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists(): if RoundQuestion.objects.filter(session=session, round_number=session.current_round).exists():
return JsonResponse({"error": "Question already shown for this round"}, status=409) return api_error(
request,
key=ERROR_CODES.get("question_already_shown", "question_already_shown"),
status=409,
)
used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True) used_question_ids = RoundQuestion.objects.filter(session=session).values_list("question_id", flat=True)
available_questions = Question.objects.filter( available_questions = Question.objects.filter(
@@ -363,7 +391,11 @@ def show_question(request: HttpRequest, code: str) -> JsonResponse:
).exclude(pk__in=used_question_ids) ).exclude(pk__in=used_question_ids)
if not available_questions.exists(): if not available_questions.exists():
return JsonResponse({"error": "No available questions in category"}, status=400) return api_error(
request,
key=ERROR_CODES.get("no_available_questions", "no_available_questions"),
status=400,
)
question = random.choice(list(available_questions)) question = random.choice(list(available_questions))
round_question = RoundQuestion.objects.create( round_question = RoundQuestion.objects.create(
@@ -473,13 +505,25 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
try: try:
session = GameSession.objects.get(code=session_code) session = GameSession.objects.get(code=session_code)
except GameSession.DoesNotExist: except GameSession.DoesNotExist:
return JsonResponse({"error": "Session not found"}, status=404) return api_error(
request,
key=ERROR_CODES.get("session_not_found", "session_not_found"),
status=404,
)
if session.host_id != request.user.id: if session.host_id != request.user.id:
return JsonResponse({"error": "Only host can mix answers"}, status=403) return api_error(
request,
key=ERROR_CODES.get("host_only_mix_answers", "host_only_mix_answers"),
status=403,
)
if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: if session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) return api_error(
request,
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"),
status=400,
)
try: try:
round_question = RoundQuestion.objects.get( round_question = RoundQuestion.objects.get(
@@ -488,12 +532,20 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
round_number=session.current_round, round_number=session.current_round,
) )
except RoundQuestion.DoesNotExist: except RoundQuestion.DoesNotExist:
return JsonResponse({"error": "Round question not found"}, status=404) return api_error(
request,
key=ERROR_CODES.get("round_question_not_found", "round_question_not_found"),
status=404,
)
with transaction.atomic(): with transaction.atomic():
locked_session = GameSession.objects.select_for_update().get(pk=session.pk) locked_session = GameSession.objects.select_for_update().get(pk=session.pk)
if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}: if locked_session.status not in {GameSession.Status.LIE, GameSession.Status.GUESS}:
return JsonResponse({"error": "Answers can only be mixed in lie or guess phase"}, status=400) return api_error(
request,
key=ERROR_CODES.get("mix_answers_invalid_phase", "mix_answers_invalid_phase"),
status=400,
)
locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk) locked_round_question = RoundQuestion.objects.select_for_update().get(pk=round_question.pk)
@@ -509,7 +561,11 @@ def mix_answers(request: HttpRequest, code: str, round_question_id: int) -> Json
deduped_answers.append(text.strip()) deduped_answers.append(text.strip())
if len(deduped_answers) < 2: if len(deduped_answers) < 2:
return JsonResponse({"error": "Not enough answers to mix"}, status=400) return api_error(
request,
key=ERROR_CODES.get("not_enough_answers_to_mix", "not_enough_answers_to_mix"),
status=400,
)
random.shuffle(deduped_answers) random.shuffle(deduped_answers)
locked_round_question.mixed_answers = deduped_answers locked_round_question.mixed_answers = deduped_answers

132
scripts/check_i18n_drift.py Executable file
View File

@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Read-only drift check for shared i18n key coverage.
Compares `shared/i18n/key-manifest.json` against `shared/i18n/lobby.json` and
fails fast when keyspaces drift between frontend/backend contract sections.
"""
from __future__ import annotations
import json
from pathlib import Path
import sys
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
CATALOG_PATH = REPO_ROOT / "shared" / "i18n" / "lobby.json"
MANIFEST_PATH = REPO_ROOT / "shared" / "i18n" / "key-manifest.json"
def _load_json(path: Path) -> dict[str, Any]:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
def _as_set(value: Any) -> set[str]:
if not isinstance(value, list):
return set()
return {str(item) for item in value}
def _require_translations(
errors: dict[str, Any],
locales: set[str],
label: str,
failures: list[str],
) -> None:
for key, translations in errors.items():
if not isinstance(translations, dict):
failures.append(f"{label}.{key} must be an object of locale->message")
continue
for locale in sorted(locales):
value = translations.get(locale)
if not isinstance(value, str) or not value.strip():
failures.append(f"{label}.{key} missing non-empty '{locale}' translation")
def main() -> int:
catalog = _load_json(CATALOG_PATH)
manifest = _load_json(MANIFEST_PATH)
failures: list[str] = []
manifest_locales = _as_set(manifest.get("locales"))
catalog_locales = _as_set(catalog.get("locales", {}).get("supported"))
if manifest_locales != catalog_locales:
failures.append(
"locale set mismatch: "
f"manifest={sorted(manifest_locales)} catalog={sorted(catalog_locales)}"
)
frontend_manifest = _as_set(manifest.get("frontend_error_keys"))
frontend_catalog = set(catalog.get("frontend", {}).get("errors", {}).keys())
if frontend_manifest != frontend_catalog:
failures.append(
"frontend error key mismatch: "
f"manifest-only={sorted(frontend_manifest - frontend_catalog)} "
f"catalog-only={sorted(frontend_catalog - frontend_manifest)}"
)
backend_code_manifest = _as_set(manifest.get("backend_error_codes"))
backend_code_catalog = set(catalog.get("backend", {}).get("error_codes", {}).keys())
if backend_code_manifest != backend_code_catalog:
failures.append(
"backend error code mismatch: "
f"manifest-only={sorted(backend_code_manifest - backend_code_catalog)} "
f"catalog-only={sorted(backend_code_catalog - backend_code_manifest)}"
)
backend_key_manifest = _as_set(manifest.get("backend_error_keys"))
backend_key_catalog = set(catalog.get("backend", {}).get("errors", {}).keys())
if backend_key_manifest != backend_key_catalog:
failures.append(
"backend error key mismatch: "
f"manifest-only={sorted(backend_key_manifest - backend_key_catalog)} "
f"catalog-only={sorted(backend_key_catalog - backend_key_manifest)}"
)
backend_to_frontend = catalog.get("contract", {}).get("backend_to_frontend_error_keys", {})
if not isinstance(backend_to_frontend, dict):
failures.append("contract.backend_to_frontend_error_keys must be an object")
backend_to_frontend = {}
for code in sorted(backend_code_catalog):
frontend_key = backend_to_frontend.get(code)
if frontend_key is None:
failures.append(f"missing contract mapping for backend code '{code}'")
continue
if frontend_key not in frontend_catalog:
failures.append(
f"mapping for backend code '{code}' points to unknown frontend key '{frontend_key}'"
)
allowed_contract_only_codes = _as_set(manifest.get("allowed_contract_only_backend_codes"))
unknown_mapping_codes = set(backend_to_frontend.keys()) - backend_code_catalog
disallowed_unknown_mapping_codes = unknown_mapping_codes - allowed_contract_only_codes
if disallowed_unknown_mapping_codes:
failures.append(
"contract contains mappings for unknown backend codes: "
f"{sorted(disallowed_unknown_mapping_codes)}"
)
_require_translations(catalog.get("frontend", {}).get("errors", {}), manifest_locales, "frontend.errors", failures)
_require_translations(catalog.get("backend", {}).get("errors", {}), manifest_locales, "backend.errors", failures)
if failures:
print("i18n drift check FAILED")
for failure in failures:
print(f" - {failure}")
return 1
print("i18n drift check OK")
print(f" - locales: {sorted(manifest_locales)}")
print(f" - frontend error keys: {len(frontend_catalog)}")
print(f" - backend error codes: {len(backend_code_catalog)}")
print(f" - backend error keys: {len(backend_key_catalog)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,60 @@
{
"locales": ["en", "da"],
"frontend_error_keys": [
"join_failed",
"nickname_invalid",
"nickname_taken",
"session_code_required",
"session_fetch_failed",
"session_not_found",
"start_round_failed",
"unknown"
],
"backend_error_codes": [
"category_has_no_questions",
"category_not_found",
"category_slug_required",
"host_only_mix_answers",
"host_only_show_question",
"host_only_start_round",
"mix_answers_invalid_phase",
"nickname_invalid",
"nickname_taken",
"no_available_questions",
"not_enough_answers_to_mix",
"question_already_shown",
"round_already_configured",
"round_config_missing",
"round_question_not_found",
"round_start_invalid_phase",
"session_code_required",
"session_not_found",
"session_not_joinable",
"show_question_invalid_phase"
],
"allowed_contract_only_backend_codes": [
"host_only_action"
],
"backend_error_keys": [
"category_has_no_questions",
"category_not_found",
"category_slug_required",
"host_only_mix_answers",
"host_only_show_question",
"host_only_start_round",
"mix_answers_invalid_phase",
"nickname_invalid",
"nickname_taken",
"no_available_questions",
"not_enough_answers_to_mix",
"question_already_shown",
"round_already_configured",
"round_config_missing",
"round_question_not_found",
"round_start_invalid_phase",
"session_code_required",
"session_not_found",
"session_not_joinable",
"show_question_invalid_phase"
]
}

View File

@@ -74,6 +74,14 @@
"round": { "round": {
"en": "round", "en": "round",
"da": "runde" "da": "runde"
},
"points_short": {
"en": "pts",
"da": "point"
},
"unknown_error": {
"en": "Unknown error",
"da": "Ukendt fejl"
} }
}, },
"app": { "app": {
@@ -273,7 +281,18 @@
"category_slug_required": "category_slug_required", "category_slug_required": "category_slug_required",
"category_not_found": "category_not_found", "category_not_found": "category_not_found",
"round_start_invalid_phase": "round_start_invalid_phase", "round_start_invalid_phase": "round_start_invalid_phase",
"round_already_configured": "round_already_configured" "round_already_configured": "round_already_configured",
"category_has_no_questions": "category_has_no_questions",
"show_question_invalid_phase": "show_question_invalid_phase",
"round_config_missing": "round_config_missing",
"question_already_shown": "question_already_shown",
"no_available_questions": "no_available_questions",
"mix_answers_invalid_phase": "mix_answers_invalid_phase",
"round_question_not_found": "round_question_not_found",
"not_enough_answers_to_mix": "not_enough_answers_to_mix",
"host_only_start_round": "host_only_start_round",
"host_only_show_question": "host_only_show_question",
"host_only_mix_answers": "host_only_mix_answers"
}, },
"errors": { "errors": {
"session_code_required": { "session_code_required": {
@@ -311,7 +330,89 @@
"round_already_configured": { "round_already_configured": {
"en": "Round already configured", "en": "Round already configured",
"da": "Runden er allerede konfigureret" "da": "Runden er allerede konfigureret"
},
"category_has_no_questions": {
"en": "Category has no active questions",
"da": "Kategorien har ingen aktive spørgsmål"
},
"show_question_invalid_phase": {
"en": "Question can only be shown in lie phase",
"da": "Spørgsmålet kan kun vises i løgnefasen"
},
"round_config_missing": {
"en": "Round config missing",
"da": "Rundekonfiguration mangler"
},
"question_already_shown": {
"en": "Question already shown for this round",
"da": "Spørgsmålet er allerede vist for denne runde"
},
"no_available_questions": {
"en": "No available questions in category",
"da": "Ingen tilgængelige spørgsmål i kategorien"
},
"mix_answers_invalid_phase": {
"en": "Answers can only be mixed in lie or guess phase",
"da": "Svar kan kun blandes i løgne- eller gættefasen"
},
"round_question_not_found": {
"en": "Round question not found",
"da": "Rundespørgsmål blev ikke fundet"
},
"not_enough_answers_to_mix": {
"en": "Not enough answers to mix",
"da": "Ikke nok svar at blande"
},
"host_only_start_round": {
"en": "Only host can start round",
"da": "Kun værten kan starte runden"
},
"host_only_show_question": {
"en": "Only host can show question",
"da": "Kun værten kan vise spørgsmålet"
},
"host_only_mix_answers": {
"en": "Only host can mix answers",
"da": "Kun værten kan blande svar"
} }
} }
},
"contract": {
"ownership": {
"artifact": "shared/i18n/lobby.json",
"backend": "lobby/* reads backend/errors + backend/error_codes",
"frontend": "frontend/* reads frontend/errors + frontend/ui + contract/backend_to_frontend_error_keys"
},
"locale": {
"default": "en",
"supported": [
"en",
"da"
],
"fallback": "Use default locale when requested locale is unsupported or key translation is missing."
},
"backend_to_frontend_error_keys": {
"session_code_required": "session_code_required",
"nickname_invalid": "nickname_invalid",
"session_not_found": "session_not_found",
"session_not_joinable": "join_failed",
"nickname_taken": "nickname_taken",
"category_slug_required": "start_round_failed",
"category_not_found": "start_round_failed",
"round_start_invalid_phase": "start_round_failed",
"round_already_configured": "start_round_failed",
"host_only_start_round": "start_round_failed",
"host_only_show_question": "start_round_failed",
"host_only_mix_answers": "start_round_failed",
"host_only_action": "start_round_failed",
"category_has_no_questions": "start_round_failed",
"show_question_invalid_phase": "start_round_failed",
"round_config_missing": "start_round_failed",
"question_already_shown": "start_round_failed",
"no_available_questions": "start_round_failed",
"mix_answers_invalid_phase": "start_round_failed",
"round_question_not_found": "start_round_failed",
"not_enough_answers_to_mix": "start_round_failed"
}
} }
} }