import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { flushSync } from 'svelte'; vi.mock('$lib/api/client', () => ({ api: { post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' }) } })); vi.mock('./clientId', () => ({ getOrCreateClientId: () => 'test-client' })); import { useEventsDispatcher } from './events.svelte'; import { playQueue, reportStateFromAudio, reportTimeUpdate, reportDuration, skipNext, } from './store.svelte'; import { api } from '$lib/api/client'; import type { TrackRef } from '$lib/api/types'; function track(id: string, dur = 200): TrackRef { return { id, title: `T ${id}`, album_id: 'a', album_title: 'A', artist_id: 'ar', artist_name: 'Ar', track_number: Number(id), disc_number: 1, duration_sec: dur, stream_url: `/api/tracks/${id}/stream` }; } beforeEach(() => { vi.clearAllMocks(); // Reset the player store to idle. playQueue([]); }); afterEach(() => { // sendBeacon spies are restored automatically per test. }); describe('useEventsDispatcher', () => { test('first transition into playing for a new track POSTs play_started', async () => { const cleanup = $effect.root(() => useEventsDispatcher()); playQueue([track('1')]); flushSync(); reportStateFromAudio('playing'); flushSync(); // Microtask drain so the api.post promise settles. await Promise.resolve(); await Promise.resolve(); const calls = (api.post as ReturnType).mock.calls; const startedCalls = calls.filter((c) => (c[1] as { type: string }).type === 'play_started'); expect(startedCalls.length).toBe(1); expect(startedCalls[0][0]).toBe('/api/events'); expect(startedCalls[0][1]).toMatchObject({ type: 'play_started', track_id: '1', client_id: 'test-client' }); cleanup(); }); test('track end (duration reached, paused) POSTs play_ended', async () => { const cleanup = $effect.root(() => useEventsDispatcher()); playQueue([track('1', 200)]); flushSync(); reportDuration(200); reportStateFromAudio('playing'); flushSync(); await Promise.resolve(); await Promise.resolve(); // Reach end of track, then natural pause. reportTimeUpdate(200); flushSync(); reportStateFromAudio('ended'); flushSync(); await Promise.resolve(); const calls = (api.post as ReturnType).mock.calls; const types = calls.map((c) => (c[1] as { type: string }).type); expect(types).toContain('play_started'); expect(types).toContain('play_ended'); cleanup(); }); test('user-initiated skip mid-track POSTs play_skipped', async () => { const cleanup = $effect.root(() => useEventsDispatcher()); playQueue([track('1'), track('2')]); flushSync(); reportStateFromAudio('playing'); flushSync(); await Promise.resolve(); await Promise.resolve(); skipNext(); flushSync(); await Promise.resolve(); const calls = (api.post as ReturnType).mock.calls; const types = calls.map((c) => (c[1] as { type: string }).type); expect(types).toContain('play_skipped'); cleanup(); }); test('pagehide fires sendBeacon when a play is open', async () => { // jsdom doesn't ship navigator.sendBeacon — install a stub before spy. if (typeof navigator.sendBeacon !== 'function') { Object.defineProperty(navigator, 'sendBeacon', { value: () => true, configurable: true }); } const cleanup = $effect.root(() => useEventsDispatcher()); const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true); playQueue([track('1')]); flushSync(); reportStateFromAudio('playing'); flushSync(); await Promise.resolve(); await Promise.resolve(); window.dispatchEvent(new Event('pagehide')); expect(beacon).toHaveBeenCalled(); expect(beacon.mock.calls[0][0]).toBe('/api/events'); cleanup(); beacon.mockRestore(); }); });