import type { AuthSession, Track } from '@pipu/core'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { Player } from './player'; const track: Track = { id: 'a', title: 'A', duration_ms: 200_000, artists: [], album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] }, genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/a', reaction: null, features: null, }; const ok = (data: unknown) => ({ data, response: new Response() }); function fixture() { localStorage.removeItem('pipu.player'); const client = { GET: vi.fn().mockResolvedValue(ok({ track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000, volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21', })), POST: vi.fn().mockResolvedValue(ok({ items: [track] })), PUT: vi.fn().mockResolvedValue(ok({})), }; const audio = document.createElement('audio'); const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio); return { player, audio, client }; } afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); }); describe('restored audio preparation', () => { it('buffers at the saved position without playback or listening events, then reuses the source', async () => { const { player, audio, client } = fixture(); const play = vi.spyOn(audio, 'play'); const source = vi.spyOn(audio, 'src', 'set'); await player.restore(); expect(audio.getAttribute('src')).toBe('/s/a'); expect(audio.currentTime).toBe(42); expect(audio.preload).toBe('auto'); expect(play).not.toHaveBeenCalled(); expect(client.PUT).not.toHaveBeenCalled(); expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 }); player.onHide(); expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events. player.play(); expect(play).toHaveBeenCalledTimes(1); expect(source).toHaveBeenCalledTimes(1); expect(player.getState().playing).toBe(true); player.dispose(); }); it('does not autoplay or skip after a preload failure; Play retries the source', async () => { const { player, audio } = fixture(); const play = vi.spyOn(audio, 'play'); await player.restore(); audio.dispatchEvent(new Event('error')); audio.dispatchEvent(new Event('ended')); expect(play).not.toHaveBeenCalled(); expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false }); player.play(); expect(audio.getAttribute('src')).toBe('/s/a'); expect(play).toHaveBeenCalledTimes(1); player.dispose(); }); it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => { const { player, client, audio } = fixture(); let resolve!: (value: unknown) => void; client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; })); const restoring = player.restore(); await vi.waitFor(() => expect(client.POST).toHaveBeenCalled()); if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' }); else player.dispose(); resolve(ok({ items: [track] })); await restoring; expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null); if (action === 'selection') player.dispose(); }); });