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

This commit was merged in pull request #229.
This commit is contained in:
2026-03-01 23:06:12 +01:00
8 changed files with 83 additions and 7 deletions

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

@@ -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

@@ -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": {