diff --git a/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md b/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md new file mode 100644 index 0000000..8239f2d --- /dev/null +++ b/docs/ISSUE-250-PRIMARY-DEVICE-AUDIO-POLICY-ARTIFACT.md @@ -0,0 +1,16 @@ +# Issue #250 Artifact — MVP guardrail (telefon-klient uden lydafspilning) + +## Scope +Implementeret guardrail for `primary-device only` audio policy i SPA player-flow. + +## Acceptance mapping +1. **Telefon-klient flow indeholder ingen audio-play init-path** + - Test: `player-shell.component.spec.ts` → `does not trigger original media play during player-shell init path`. +2. **Primær enhed policy er dokumenteret og testbar** + - Policy i contract: `shared/i18n/lobby.json` → `frontend.capabilities.client_has_no_audio_output=true`. + - Testbar via eksisterende guard-tests + init-path test i player-shell specs. +3. **Krav er refereret i SPA-plan/cutover-noter** + - Dokumenteret i `docs/spa-cutover-flag.md` under *MVP audio policy guardrail (telefon-klient)*. + +## UX/i18n note +- Player shell viser advarselstekst via i18n key: `frontend.ui.player.audio_policy_notice`. diff --git a/docs/spa-cutover-flag.md b/docs/spa-cutover-flag.md index 3f9a79c..edbf900 100644 --- a/docs/spa-cutover-flag.md +++ b/docs/spa-cutover-flag.md @@ -65,3 +65,10 @@ Target: rollback + sanity-verifikation inden for 10 minutter. - Flag ON (host deep-link): `UiScreenTests.test_host_screen_deeplink_preserves_spa_path_when_feature_flag_enabled` - Flag ON (player): `UiScreenTests.test_player_screen_can_render_angular_shell_when_feature_flag_enabled` - Smoke-checkliste for cutover paths: `docs/STAGING_GAMEPLAY_SMOKE_ARTIFACT.md` + `docs/UI_SMOKE.md` + + +## MVP audio policy guardrail (telefon-klient) +- Telefon-/player-klienten må ikke starte lydafspilning lokalt i MVP (`primary-device only`). +- Policy er bundet til capability-flaget `frontend.capabilities.client_has_no_audio_output=true` i `shared/i18n/lobby.json`. +- Brugeradvarsel i player UI leveres via i18n key: `frontend.ui.player.audio_policy_notice`. +- Acceptance-spec er dækket i Angular tests (`player-shell.component.spec.ts`), inkl. at init-path ikke kalder original media `play`. diff --git a/frontend/angular/src/app/features/player/player-shell.component.spec.ts b/frontend/angular/src/app/features/player/player-shell.component.spec.ts index a97fe0c..ed8b19a 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.spec.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import lobbyCatalog from '../../../../../../shared/i18n/lobby.json'; import { PlayerShellComponent } from './player-shell.component'; type FetchMock = ReturnType; @@ -443,4 +444,51 @@ describe('PlayerShellComponent gameplay wiring', () => { secondComponent.ngOnDestroy(); await expect(mediaPrototype.play()).rejects.toThrow('original play'); }); + + it('does not trigger original media play during player-shell init path', () => { + const originalPlay = vi.fn().mockResolvedValue(undefined); + 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(); + + expect(originalPlay).not.toHaveBeenCalled(); + + component.ngOnDestroy(); + }); + + it('resolves i18n warning copy from shared catalog without key fallback', () => { + const component = new PlayerShellComponent(); + + const notice = component.copy('player.audio_policy_notice'); + const expected = lobbyCatalog.frontend.ui.player.audio_policy_notice[component.locale]; + + expect(notice).toBe(expected); + expect(notice).not.toBe('player.audio_policy_notice'); + }); + + it('gates template warning notice on the no-audio-output capability flag', () => { + const templateSource = String((PlayerShellComponent as any).ɵcmp?.template); + + expect(templateSource).toContain('clientHasNoAudioOutput'); + + const component = new PlayerShellComponent(); + expect(component.copy('player.audio_policy_notice')).not.toBe('player.audio_policy_notice'); + expect(component.clientHasNoAudioOutput).toBe(true); + + (component as any).clientHasNoAudioOutput = false; + expect(component.clientHasNoAudioOutput).toBe(false); + }); + }); diff --git a/frontend/angular/src/app/features/player/player-shell.component.ts b/frontend/angular/src/app/features/player/player-shell.component.ts index 6542e4b..393723a 100644 --- a/frontend/angular/src/app/features/player/player-shell.component.ts +++ b/frontend/angular/src/app/features/player/player-shell.component.ts @@ -45,6 +45,7 @@ function resolveLocalStorage(): Storage | undefined { imports: [CommonModule, FormsModule], template: `

{{ copy('player.title') }}

+

{{ copy('player.audio_policy_notice') }}

diff --git a/shared/i18n/lobby.json b/shared/i18n/lobby.json index 39f4135..c7cf10f 100644 --- a/shared/i18n/lobby.json +++ b/shared/i18n/lobby.json @@ -264,6 +264,10 @@ "guess_submit_failed": { "en": "Guess submit failed", "da": "Gætte-fejl" + }, + "audio_policy_notice": { + "en": "Audio playback is disabled on phone clients. Sound is available on the primary host device.", + "da": "Lydafspilning er slået fra på telefon-klienten. Lyd afspilles kun på den primære værtsenhed." } } },