Compare commits
34 Commits
dev/issue-
...
wpp-dev-la
| Author | SHA1 | Date | |
|---|---|---|---|
| 37b88b8cb0 | |||
| 258025ac4e | |||
| f28a390f95 | |||
| a1bb1ccbed | |||
| ee025e8deb | |||
| b977016ef4 | |||
| 1b899a30a2 | |||
| 187b26e561 | |||
| 0b4ddaf43f | |||
| 7a3d649e11 | |||
| f50f6a08ae | |||
| 000a486db1 | |||
| 845e94b726 | |||
| 5fe8f92ee4 | |||
| e2f184d1bc | |||
| ab41798220 | |||
| c7ff3d96de | |||
| 3398aead7f | |||
| 97945ede92 | |||
| 3655bad847 | |||
| fdef33f44a | |||
| bc78f79f78 | |||
| 784622058a | |||
| 257732e2ab | |||
| 64fe273691 | |||
| cd6fb06343 | |||
| 508d462bb6 | |||
| e435a41660 | |||
| b9bfe55f93 | |||
| 8e21ca8e5e | |||
| ddf8e874e2 | |||
| 21a25a063c | |||
| 4e300e4631 | |||
| 5fe9939057 |
20
docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md
Normal file
20
docs/ISSUE-223-PLAYER-AUDIO-GUARD-ARTIFACT.md
Normal 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).
|
||||||
55
docs/ISSUE-224-TRUNK-SEQUENCE-175.md
Normal file
55
docs/ISSUE-224-TRUNK-SEQUENCE-175.md
Normal 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: ~200–300 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 assign’e 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.
|
||||||
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal file
30
docs/ISSUE-226-SHARED-KEYMAP-LOCALE-CONTRACT.md
Normal 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
34
docs/i18n-drift-check.md
Normal 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
69
docs/i18n-keymap.md
Normal 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`.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: '' },
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
132
scripts/check_i18n_drift.py
Executable 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())
|
||||||
60
shared/i18n/key-manifest.json
Normal file
60
shared/i18n/key-manifest.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user