import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { flushSync } from 'svelte'; import type { TrackRef } from '#lib/api/types.js'; import { makeTrack } from '#test-utils/fixtures/track.js'; function track(id: string): TrackRef { return makeTrack({ id, title: `T ${id}`, stream_url: `/api/tracks/${id}/stream` }); } type Handler = (details: MediaSessionActionDetails) => void; // Spy MediaSession replaces navigator.mediaSession for the duration of each test. function makeSpyMediaSession() { const handlers: Record = {}; const spy = { metadata: null as MediaMetadata | null, playbackState: 'none' as MediaSessionPlaybackState, setActionHandler: vi.fn((action: string, h: Handler | null) => { if (h) handlers[action] = h; else delete handlers[action]; }), setPositionState: vi.fn(), handlers }; return spy; } describe('useMediaSession', () => { let spy: ReturnType; let originalMS: unknown; beforeEach(async () => { spy = makeSpyMediaSession(); originalMS = (navigator as unknown as { mediaSession?: unknown }).mediaSession; Object.defineProperty(navigator, 'mediaSession', { configurable: true, value: spy }); // Also stub MediaMetadata so we can construct it in jsdom. (globalThis as unknown as { MediaMetadata: typeof Object }).MediaMetadata = class { constructor(public init: unknown) {} } as unknown as typeof Object; const store = await import('./store.svelte'); store.playQueue([]); }); afterEach(() => { if (originalMS === undefined) { // eslint-disable-next-line @typescript-eslint/no-explicit-any delete (navigator as any).mediaSession; } else { Object.defineProperty(navigator, 'mediaSession', { configurable: true, value: originalMS }); } vi.restoreAllMocks(); }); test('sets metadata when current track changes; clears when queue empties', async () => { const store = await import('./store.svelte'); const { useMediaSession } = await import('./mediaSession.svelte'); const cleanup = $effect.root(() => useMediaSession()); store.playQueue([track('1')]); flushSync(); expect(spy.metadata).not.toBeNull(); store.playQueue([]); flushSync(); expect(spy.metadata).toBeNull(); cleanup(); }); test('registers play/pause/previoustrack/nexttrack/seekto handlers', async () => { const { useMediaSession } = await import('./mediaSession.svelte'); const cleanup = $effect.root(() => useMediaSession()); flushSync(); expect(spy.handlers['play']).toBeTypeOf('function'); expect(spy.handlers['pause']).toBeTypeOf('function'); expect(spy.handlers['previoustrack']).toBeTypeOf('function'); expect(spy.handlers['nexttrack']).toBeTypeOf('function'); expect(spy.handlers['seekto']).toBeTypeOf('function'); cleanup(); }); test('playbackState mirrors player.isPlaying', async () => { const store = await import('./store.svelte'); const { useMediaSession } = await import('./mediaSession.svelte'); const cleanup = $effect.root(() => useMediaSession()); store.playQueue([track('1')]); store.reportStateFromAudio('playing'); flushSync(); expect(spy.playbackState).toBe('playing'); store.reportStateFromAudio('paused'); flushSync(); expect(spy.playbackState).toBe('paused'); cleanup(); }); test('setPositionState called when duration > 0', async () => { const store = await import('./store.svelte'); const { useMediaSession } = await import('./mediaSession.svelte'); const cleanup = $effect.root(() => useMediaSession()); store.playQueue([track('1')]); store.reportDuration(240); store.reportTimeUpdate(30); flushSync(); expect(spy.setPositionState).toHaveBeenCalled(); const call = spy.setPositionState.mock.calls.at(-1)?.[0]; expect(call?.duration).toBe(240); expect(call?.position).toBeLessThanOrEqual(240); cleanup(); }); });