Merge pull request 'Issue #250: MVP guardrail for phone-client audio playback policy' (#256) from dev/issue-250-primary-device-audio into main
All checks were successful
CI / test-and-quality (push) Successful in 2m31s

This commit was merged in pull request #256.
This commit is contained in:
2026-03-02 03:09:12 +01:00
5 changed files with 76 additions and 0 deletions

View File

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

View File

@@ -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 (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` - 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` - 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`.

View File

@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import lobbyCatalog from '../../../../../../shared/i18n/lobby.json';
import { PlayerShellComponent } from './player-shell.component'; import { PlayerShellComponent } from './player-shell.component';
type FetchMock = ReturnType<typeof vi.fn>; type FetchMock = ReturnType<typeof vi.fn>;
@@ -443,4 +444,51 @@ describe('PlayerShellComponent gameplay wiring', () => {
secondComponent.ngOnDestroy(); secondComponent.ngOnDestroy();
await expect(mediaPrototype.play()).rejects.toThrow('original play'); 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);
});
}); });

View File

@@ -45,6 +45,7 @@ function resolveLocalStorage(): Storage | undefined {
imports: [CommonModule, FormsModule], imports: [CommonModule, FormsModule],
template: ` template: `
<h2>{{ copy('player.title') }}</h2> <h2>{{ copy('player.title') }}</h2>
<p *ngIf="clientHasNoAudioOutput" class="hint">{{ copy('player.audio_policy_notice') }}</p>
<div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput"> <div class="panel" [attr.data-client-has-no-audio-output]="clientHasNoAudioOutput">
<label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label> <label>{{ copy('common.session_code') }} <input [(ngModel)]="sessionCode" /></label>

View File

@@ -264,6 +264,10 @@
"guess_submit_failed": { "guess_submit_failed": {
"en": "Guess submit failed", "en": "Guess submit failed",
"da": "Gætte-fejl" "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."
} }
} }
}, },