import type { AuthSession, Track } from '@pipu/core'; import { beforeEach, expect, it, vi } from 'vitest'; vi.mock('@react-native-community/netinfo', () => ({ default: { fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {}, } })); vi.mock('../src/lib/prefs', () => ({ qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) }, playerCache: { clearIfRequested: async () => {} }, })); vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} })); vi.mock('../src/offline/downloads', () => ({ downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] }, sizedCover: (s: string) => s, })); vi.mock('react-native-track-player', () => ({ default: { isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {}, addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined), add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined), skip: vi.fn().mockResolvedValue(undefined), }, AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {}, })); function track(id: string): Track { return { id, title: id, duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] }, genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null }; } function deferred() { let resolve!: (v: T) => void; const promise = new Promise((yes) => { resolve = yes; }); return { promise, resolve }; } const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() }); beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); }); it('ignores a wave creation response after the player is reset', async () => { const { player } = await import('../src/player/controller'); const native = (await import('react-native-track-player')).default; const pending = deferred>(); const post = vi.fn().mockReturnValue(pending.promise); player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null); const start = player.startWave(null, null); await vi.waitFor(() => expect(post).toHaveBeenCalled()); await player.reset(); pending.resolve(reply('old')); await start; expect(player.getState().wave).toBeNull(); expect(player.getState().queue).toEqual([]); expect(native.add).not.toHaveBeenCalled(); }); it('does not let a late context response replace a manually selected playlist', async () => { const { player } = await import('../src/player/controller'); const pending = deferred>(); const session = { client: { POST: vi.fn().mockResolvedValue(reply('wave')), PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}), } }; player.attach(session as unknown as AuthSession, 'http://localhost', null); await player.startWave(null, null); const change = player.setWaveContext('focus', null); await player.playList([track('manual')], 0, { type: 'album', id: 'album' }); pending.resolve(reply('stale')); await change; expect(player.getState().wave).toBeNull(); expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']); expect(player.getState().context?.type).toBe('album'); }); it('does not restore a stale saved queue after manual playback starts', async () => { const { player } = await import('../src/player/controller'); const saved = deferred(); const get = vi.fn().mockReturnValue(saved.promise); const post = vi.fn(); player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null); const restoring = player.restore(); await vi.waitFor(() => expect(get).toHaveBeenCalled()); await player.playList([track('chosen')], 0, { type: 'queue' }); saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() }); await restoring; expect(player.getState().track?.id).toBe('chosen'); expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything()); });