b6f8a0c390
useEventsDispatcher subscribes to player.current/state via \$effect: on first transition to playing for a new track, POSTs play_started and caches the id; on track-id change while a play is open, closes the prior row as play_skipped; on natural completion (state playing→paused with position >= duration > 0), POSTs play_ended; on pagehide, navigator.sendBeacon fires a final play_skipped. Stable per-tab client_id from sessionStorage.
143 lines
3.9 KiB
TypeScript
143 lines
3.9 KiB
TypeScript
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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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();
|
|
});
|
|
});
|