From 59cabcb56c488e56991cfbfccbe7d7c9bc71cea5 Mon Sep 17 00:00:00 2001 From: DEV-bot Date: Sun, 1 Mar 2026 21:25:56 +0000 Subject: [PATCH] feat(i18n): add shared-contract architecture + bilingual MVP flow smoke --- docs/I18N_ARCHITECTURE.md | 48 +++++++++++++++ ...ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md | 35 +++++++++++ .../src/app/i18n-mvp-flow-smoke.spec.ts | 61 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 docs/I18N_ARCHITECTURE.md create mode 100644 docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md create mode 100644 frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts diff --git a/docs/I18N_ARCHITECTURE.md b/docs/I18N_ARCHITECTURE.md new file mode 100644 index 0000000..c6bfb4d --- /dev/null +++ b/docs/I18N_ARCHITECTURE.md @@ -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` diff --git a/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md b/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md new file mode 100644 index 0000000..4807bd0 --- /dev/null +++ b/docs/ISSUE-175-I18N-SHARED-CONTRACT-ARTIFACT.md @@ -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`). diff --git a/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts b/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts new file mode 100644 index 0000000..6e130fb --- /dev/null +++ b/frontend/angular/src/app/i18n-mvp-flow-smoke.spec.ts @@ -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(); + }); +}); -- 2.39.5