[Need-to-have] #175 Shared i18n contract docs + bilingual MVP flow smoke #218
48
docs/I18N_ARCHITECTURE.md
Normal file
48
docs/I18N_ARCHITECTURE.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Shared i18n architecture (frontend + backend)
|
||||
|
||||
## Scope
|
||||
Issue #175 requires one shared i18n contract for MVP host/player flows across frontend and backend.
|
||||
|
||||
## Source of truth
|
||||
- Catalog: `shared/i18n/lobby.json`
|
||||
- Locales:
|
||||
- supported: `en`, `da`
|
||||
- default/fallback: `en`
|
||||
|
||||
Both Angular (`frontend/angular/src/app/lobby-i18n.ts`) and Django (`lobby/i18n.py`) read from this catalog.
|
||||
|
||||
## Key naming convention
|
||||
- Domain-first namespaces:
|
||||
- `frontend.ui.common.*`
|
||||
- `frontend.ui.host.*`
|
||||
- `frontend.ui.player.*`
|
||||
- `frontend.errors.*`
|
||||
- `backend.errors.*`
|
||||
- `backend.error_codes.*`
|
||||
- UI lookup keys in Angular are shortened aliases mapped under `frontend.ui`, e.g.:
|
||||
- `host.start_round`
|
||||
- `player.submit_guess`
|
||||
- `common.session_code`
|
||||
- Backend API errors return stable code + localized message:
|
||||
- `error_code` = machine-stable key from `backend.error_codes`
|
||||
- `error` = localized message from `backend.errors`
|
||||
- `locale` = resolved request locale
|
||||
|
||||
## Fallback model (robust)
|
||||
1. Resolve requested locale (`Accept-Language` on backend, user/browser preference on frontend).
|
||||
2. If locale unsupported -> use default `en`.
|
||||
3. If key missing -> return key and log warning.
|
||||
4. If locale translation missing for key -> fallback to `en` translation.
|
||||
|
||||
## Audio-routing policy
|
||||
- Catalog capability: `frontend.capabilities.client_has_no_audio_output = true`
|
||||
- Host/player clients expose this as a read-only capability flag.
|
||||
- Policy: phone clients must not play audio directly; only primary/host output is allowed.
|
||||
|
||||
## Verification
|
||||
- Backend tests: `lobby/tests.py` i18n coverage for locale selection + fallback + error-code/message matrix.
|
||||
- Frontend smoke/e2e-level unit coverage:
|
||||
- `frontend/angular/src/app/lobby-i18n.spec.ts`
|
||||
- `frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts`
|
||||
- `frontend/angular/src/app/features/host/host-shell.component.spec.ts`
|
||||
- `frontend/angular/src/app/features/player/player-shell.component.spec.ts`
|
||||
35
docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md
Normal file
35
docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# ISSUE-175 Artifact — shared i18n contract + MVP host/player flow
|
||||
|
||||
## Delivered
|
||||
- Shared i18n contract is centralized in `shared/i18n/lobby.json` (da/en, default `en`, robust fallback).
|
||||
- Frontend host/player MVP copy is read via shared keys (no hardcoded Danish strings in Angular MVP flow components).
|
||||
- Backend error messages resolve from shared keyspace with locale-aware API payload (`error_code`, `error`, `locale`).
|
||||
- Audio-routing policy is explicit and shared: `frontend.capabilities.client_has_no_audio_output=true`.
|
||||
- Architecture + key naming documented in `docs/I18N_ARCHITECTURE.md`.
|
||||
|
||||
## Smoke / e2e evidence
|
||||
### Backend locale + fallback
|
||||
Command:
|
||||
```bash
|
||||
. .venv/bin/activate && python manage.py test \
|
||||
lobby.tests.LobbyFlowTests.test_join_error_localizes_to_danish_with_accept_language_header \
|
||||
lobby.tests.LobbyFlowTests.test_join_error_falls_back_to_english_for_unsupported_locale \
|
||||
lobby.tests.I18nResolverTests
|
||||
```
|
||||
Result:
|
||||
- PASS (7 tests)
|
||||
- Confirms `da` localization and fallback to `en` for unsupported locale.
|
||||
|
||||
### Frontend host/player + language switch + audio policy
|
||||
Command:
|
||||
```bash
|
||||
cd frontend/angular
|
||||
npm test -- --run \
|
||||
src/app/lobby-i18n.spec.ts \
|
||||
src/app/i18n-mvp-flow-smoke.spec.ts \
|
||||
src/app/features/host/host-shell.component.spec.ts \
|
||||
src/app/features/player/player-shell.component.spec.ts
|
||||
```
|
||||
Result:
|
||||
- PASS (22 tests)
|
||||
- Includes smoke for host/player copy in **both en and da** and verifies primary-only audio policy flag (`clientHasNoAudioOutput=true`).
|
||||
61
frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts
Normal file
61
frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HostShellComponent } from './features/host/host-shell.component';
|
||||
import { PlayerShellComponent } from './features/player/player-shell.component';
|
||||
import { setPreferredLocale } from './lobby-i18n';
|
||||
|
||||
describe('i18n MVP flow smoke (host/player + audio policy)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('resolves host/player copy in en and da from shared catalog', () => {
|
||||
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() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
|
||||
const host = new HostShellComponent();
|
||||
const player = new PlayerShellComponent();
|
||||
host.ngOnInit();
|
||||
player.ngOnInit();
|
||||
|
||||
expect(host.copy('host.start_round')).toBe('Start round');
|
||||
expect(player.copy('player.submit_guess')).toBe('Submit guess');
|
||||
|
||||
setPreferredLocale('da');
|
||||
|
||||
expect(host.copy('host.start_round')).toBe('Start runde');
|
||||
expect(player.copy('player.submit_guess')).toBe('Send gæt');
|
||||
|
||||
player.ngOnDestroy();
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
|
||||
it('keeps audio routing policy primary-only (client has no audio output)', () => {
|
||||
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() },
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
vi.stubGlobal('navigator', { language: 'en-US', onLine: true });
|
||||
|
||||
const host = new HostShellComponent();
|
||||
const player = new PlayerShellComponent();
|
||||
|
||||
expect(host.clientHasNoAudioOutput).toBe(true);
|
||||
expect(player.clientHasNoAudioOutput).toBe(true);
|
||||
|
||||
player.ngOnDestroy();
|
||||
host.ngOnDestroy();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user