11 Commits

Author SHA1 Message Date
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
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
13 changed files with 265 additions and 18 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`

View File

@@ -55,7 +55,7 @@ type LeaderboardResponse = FinishGameResponse;
<pre *ngIf="scoreboardPayload">{{ scoreboardPayload }}</pre>
<div *ngIf="finalLeaderboard.length">
<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>
<li *ngFor="let entry of finalLeaderboard">{{ entry.nickname }}: {{ entry.score }}</li>
</ol>

View File

@@ -350,4 +350,29 @@ describe('PlayerShellComponent gameplay wiring', () => {
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');
});
});

View File

@@ -113,6 +113,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSyncTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeLocale: (() => void) | null = null;
private restoreAudioGuard: (() => void) | null = null;
constructor() {
if (typeof navigator !== 'undefined' && !navigator.onLine) {
@@ -129,6 +130,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.unsubscribeLocale = subscribeToLocaleChanges((locale) => {
this.locale = locale;
});
this.installSecondaryDeviceAudioGuard();
const hashRoute = window.location.hash.replace(/^#\/?/, '');
const match = hashRoute.match(/^player(?:\/[^/]+)?(?:\/([^/?#]+))?/i);
@@ -158,6 +160,8 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
this.clearStateSyncTimer();
this.unsubscribeLocale?.();
this.unsubscribeLocale = null;
this.restoreAudioGuard?.();
this.restoreAudioGuard = null;
}
private readonly handleOnline = (): void => {
@@ -185,6 +189,25 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
}
}
private installSecondaryDeviceAudioGuard(): void {
if (!this.clientHasNoAudioOutput || typeof window === 'undefined') {
return;
}
const mediaPrototype = (window as Window & { HTMLMediaElement?: { prototype?: { play?: () => Promise<void> } } }).HTMLMediaElement
?.prototype;
if (!mediaPrototype || typeof mediaPrototype.play !== 'function') {
return;
}
const originalPlay = mediaPrototype.play;
mediaPrototype.play = () => Promise.resolve();
this.restoreAudioGuard = () => {
mediaPrototype.play = originalPlay;
};
}
private scheduleStateSync(): void {
this.clearStateSyncTimer();
@@ -232,7 +255,7 @@ export class PlayerShellComponent implements OnInit, OnDestroy {
if (error instanceof Error && error.message) {
return error.message;
}
return 'Unknown error';
return this.copy('common.unknown_error');
}
private markOnline(): void {

View File

@@ -45,6 +45,22 @@ describe('lobby i18n locale propagation', () => {
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 () => {
vi.stubGlobal('window', {
location: { search: '' },

View File

@@ -32,11 +32,14 @@ export function resolvePreferredLocale(): SupportedLocale {
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 storedLocale = window.localStorage?.getItem?.('wpp.locale');
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;
}

View File

@@ -14,10 +14,16 @@ describe('shared i18n keyspace contract', () => {
}
});
it('keeps backend error-code keyspace aligned with backend translations', () => {
for (const [code, key] of Object.entries(lobbyCatalog.backend.error_codes)) {
expect(code).toBe(key);
expect(lobbyCatalog.backend.errors[key as keyof typeof lobbyCatalog.backend.errors]).toBeDefined();
it('keeps backend error-code keyspace aligned with shared backend→frontend map and backend translations', () => {
for (const [code, backendKey] of Object.entries(lobbyCatalog.backend.error_codes)) {
const frontendKey =
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)) {
@@ -42,7 +48,7 @@ describe('lobbyMessage locale handling', () => {
).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 when backend key differs from frontend copy key', () => {
expect(
lobbyMessageFromApiPayload(
{ error_code: 'session_not_joinable', locale: 'da' },
@@ -50,4 +56,13 @@ describe('lobbyMessage locale handling', () => {
),
).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',
),
).toBe('Kunne ikke joine. Tjek kode eller kaldenavn og prøv igen.');
});
});

View File

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

View File

@@ -20,7 +20,7 @@ from fupogfakta.models import (
RoundConfig,
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()
@@ -1216,6 +1216,27 @@ class SmokeStagingCommandTests(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_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):
self.assertEqual(resolve_error_message(key="missing_key", locale="da"), "missing_key")
@@ -1252,10 +1273,14 @@ class I18nResolverTests(TestCase):
self.assertTrue(translations.get("en"), f"frontend key {key} missing en")
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()
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():
self.assertEqual(code, key)
self.assertIn(key, backend_errors)
for code, backend_key in catalog["backend"]["error_codes"].items():
frontend_key = shared_map.get(code)
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 .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):
@@ -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_version": getattr(settings, "WPP_SPA_ASSET_VERSION", "dev"),
"lobby_i18n": lobby_i18n_catalog(),
"shell_locale": resolve_locale(request),
},
)

View File

@@ -74,6 +74,14 @@
"round": {
"en": "round",
"da": "runde"
},
"points_short": {
"en": "pts",
"da": "point"
},
"unknown_error": {
"en": "Unknown error",
"da": "Ukendt fejl"
}
},
"app": {
@@ -313,5 +321,31 @@
"da": "Runden er allerede konfigureret"
}
}
},
"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"
}
}
}
}