import { AxiosError } from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Track } from '../types'; import { usePlaybackStore } from '../store/usePlaybackStore'; import { useVibeStore } from '../store/useVibeStore'; const { start, next, advancePastUnplayable, event, end, getTrack } = vi.hoisted(() => ({ start: vi.fn(), next: vi.fn(), advancePastUnplayable: vi.fn(), event: vi.fn(), end: vi.fn(), getTrack: vi.fn(), })); vi.mock('./vibeService', () => ({ vibeService: { start, next, advancePastUnplayable, event, end } })); vi.mock('./trackService', () => ({ trackService: { getTrack } })); import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession'; const track = (id: string): Track => ({ id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist', album_id: 'album', duration: 180, state: 'LIBRARY', source_type: 'MANUAL', play_count: 0, skip_count: 0, dislike_count: 0, }); const item = (track_id: string, committed = false, ordinal = 0) => ({ plan_version_id: 'plan', ordinal, track_id, slot_role: null, candidate_source: 'test', score: 1, score_breakdown: {}, explanation: [], committed, }); const response = (planVersion: number, now = item('one', true), preview = [item('two')]) => ({ sessionId: 'session-a', planVersion, now, preview, state: {}, replanned: false, replanReason: null, }); describe('durable Vibe session client', () => { beforeEach(() => { vi.clearAllMocks(); useVibeStore.getState().reset(); usePlaybackStore.setState({ currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary', }); getTrack.mockImplementation((id: string) => Promise.resolve(track(id))); }); it('starts by version-serving and hydrating the first durable plan item', async () => { start.mockResolvedValue(response(1, item('one'), [item('two')])); next.mockResolvedValue(response(1, item('one', true), [item('two')])); await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('one'), track('two')] }); expect(next).toHaveBeenCalledWith('session-a', 1); expect(useVibeStore.getState()).toMatchObject({ activeSessionId: 'session-a', planVersion: 1, buffer: [track('two')] }); expect(usePlaybackStore.getState().currentTrack).toEqual(track('one')); }); it('replans, version-serves, and removes stale prefetched tracks before advancing', async () => { start.mockResolvedValue(response(1, item('one'), [item('stale')])); next .mockResolvedValueOnce(response(1, item('one', true), [item('stale')])) .mockResolvedValueOnce(response(2, item('two', true), [item('three')])); event.mockResolvedValue({ ...response(2, item('two'), [item('three')]), replanned: true, event: {}, idempotent: false }); await startVibeSession(track('seed')); await advanceVibe('skipped'); expect(next).toHaveBeenLastCalledWith('session-a', 2); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'two', 'three']); expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); expect(useVibeStore.getState().buffer.map((entry) => entry.id)).toEqual(['three']); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('stale'); expect(event).toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one', eventId: expect.any(String) })); }); it('replaces only the future when a keep event replans', async () => { start.mockResolvedValue(response(1, item('one'), [item('stale')])); next.mockResolvedValue(response(1, item('one', true), [item('stale')])); event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: true, event: {}, idempotent: false }); await startVibeSession(track('seed')); await reportVibeEvent('kept', 'one'); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']); expect(usePlaybackStore.getState().currentTrack?.id).toBe('one'); expect(useVibeStore.getState().planVersion).toBe(2); }); it('reconciles the canonical replacement returned by an idempotent material-event retry', async () => { start.mockResolvedValue(response(1, item('one'), [item('stale')])); next.mockResolvedValue(response(1, item('one', true), [item('stale')])); // The first response was lost after it published revision 2. Retrying the // same client event returns that revision with replanned=false. event.mockResolvedValue({ ...response(2, item('fresh'), [item('fresh'), item('later')]), replanned: false, event: {}, idempotent: true, }); await startVibeSession(track('seed')); await reportVibeEvent('kept', 'one'); expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh'), track('later')] }); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh', 'later']); }); it('cleans up local playback when the durable session is gone', async () => { start.mockResolvedValue(response(1, item('one'), [item('stale')])); next.mockResolvedValue(response(1, item('one', true), [item('stale')])); event.mockRejectedValue(new AxiosError('gone', undefined, undefined, undefined, { data: {}, status: 404, statusText: 'Not Found', headers: {}, config: {} as never, })); await startVibeSession(track('seed')); await expect(reportVibeEvent('kept', 'one')).rejects.toThrow('gone'); expect(useVibeStore.getState().activeSessionId).toBeNull(); expect(usePlaybackStore.getState()).toMatchObject({ currentTrack: null, queue: [], isPlaying: false }); }); it('hands ordinary playback back to browse queues without Vibe reporting or next interception', async () => { start.mockResolvedValue(response(1, item('one'), [item('two')])); next.mockResolvedValue(response(1, item('one', true), [item('two')])); await startVibeSession(track('seed')); const ordinary = track('ordinary'); const playback = usePlaybackStore.getState(); playback.setQueue([ordinary, track('ordinary-next')]); playback.playTrack(ordinary); playback.nextWithReason('completed'); expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', currentTrack: track('ordinary-next'), vibeAdvanceHandler: null, }); expect(event).not.toHaveBeenCalled(); }); it('serializes material events and ignores an older plan revision', async () => { start.mockResolvedValue(response(1, item('one'), [item('old')])); next.mockResolvedValue(response(1, item('one', true), [item('old')])); await startVibeSession(track('seed')); let resolveFirst!: (value: ReturnType & { event: object; idempotent: boolean }) => void; event.mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })); event.mockResolvedValueOnce({ ...response(1, item('stale'), [item('stale')]), replanned: true, event: {}, idempotent: false }); const first = reportVibeEvent('kept', 'one'); const second = reportVibeEvent('completed', 'one'); await Promise.resolve(); expect(event).toHaveBeenCalledTimes(1); resolveFirst({ ...response(2, item('fresh'), [item('fresh')]), replanned: true, event: {}, idempotent: false }); await Promise.all([first, second]); expect(event).toHaveBeenCalledTimes(2); expect(useVibeStore.getState()).toMatchObject({ planVersion: 2, buffer: [track('fresh')] }); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).toEqual(['one', 'fresh']); }); it('retries a failed event with the same idempotency key until it is acknowledged', async () => { start.mockResolvedValue(response(1, item('one'), [item('two')])); next.mockResolvedValue(response(1, item('one', true), [item('two')])); event.mockRejectedValueOnce(new Error('network dropped')).mockResolvedValueOnce({ ...response(1), event: {}, idempotent: true, }); await startVibeSession(track('seed')); await reportVibeEvent('progress', 'one', 30000, 180000); expect(event).toHaveBeenCalledTimes(2); expect(event.mock.calls[0][1].eventId).toBe(event.mock.calls[1][1].eventId); }); it('skips a hidden plan item and starts from the next playable item', async () => { start.mockResolvedValue(response(1, item('hidden'), [item('good')])); next .mockResolvedValueOnce(response(1, item('hidden', true), [item('good')])); advancePastUnplayable.mockResolvedValueOnce(response(1, item('good', true), [item('later')])); getTrack.mockImplementation((id: string) => id === 'hidden' ? Promise.resolve({ ...track(id), state: 'HIDDEN' }) : Promise.resolve(track(id))); await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')] }); expect(next).toHaveBeenCalledTimes(1); expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({ planVersionId: 'plan', ordinal: 0, trackId: 'hidden', eventId: expect.any(String), })); expect(usePlaybackStore.getState().currentTrack?.id).toBe('good'); expect(usePlaybackStore.getState().queue.map((entry) => entry.id)).not.toContain('hidden'); }); it('advances consecutive hidden replacements directly without replaying an older served cursor', async () => { start.mockResolvedValue(response(1, item('hidden-one'), [item('hidden-two', false, 1)])); next.mockResolvedValueOnce(response(1, item('hidden-one', true), [item('hidden-two', false, 1)])); advancePastUnplayable .mockResolvedValueOnce(response(1, item('hidden-two', true, 1), [item('good', false, 2)])) .mockResolvedValueOnce(response(1, item('good', true, 2), [item('later', false, 3)])); getTrack.mockImplementation((id: string) => ['hidden-one', 'hidden-two'].includes(id) ? Promise.resolve({ ...track(id), state: 'HIDDEN' }) : Promise.resolve(track(id))); await expect(startVibeSession(track('seed'))).resolves.toMatchObject({ status: 'complete', tracks: [track('good'), track('later')], }); expect(next).toHaveBeenCalledTimes(1); expect(advancePastUnplayable).toHaveBeenCalledTimes(2); expect(advancePastUnplayable.mock.calls.map(([, , input]) => [input.ordinal, input.trackId])) .toEqual([[0, 'hidden-one'], [1, 'hidden-two']]); expect(usePlaybackStore.getState().currentTrack?.id).toBe('good'); }); it('retries an unplayable advancement with its original event id after a lost response', async () => { start.mockResolvedValue(response(1, item('hidden'), [item('good')])); next.mockResolvedValueOnce(response(1, item('hidden', true), [item('good')])); advancePastUnplayable .mockRejectedValueOnce(new Error('response dropped')) .mockResolvedValueOnce(response(1, item('good', true), [item('later')])); getTrack.mockImplementation((id: string) => id === 'hidden' ? Promise.resolve({ ...track(id), state: 'MISSING' }) : Promise.resolve(track(id))); await startVibeSession(track('seed')); expect(advancePastUnplayable).toHaveBeenCalledTimes(2); expect(advancePastUnplayable.mock.calls[0][2].eventId) .toBe(advancePastUnplayable.mock.calls[1][2].eventId); }); it('advances a stream-error track through its stored durable cursor without ordinary feedback', async () => { start.mockResolvedValue(response(1, item('one'), [item('two', false, 1)])); next.mockResolvedValueOnce(response(1, item('one', true), [item('two', false, 1)])); advancePastUnplayable.mockResolvedValueOnce(response(1, item('two', true, 1), [item('later', false, 2)])); await startVibeSession(track('seed')); await advancePastUnplayableVibeTrack('one'); expect(advancePastUnplayable).toHaveBeenCalledWith('session-a', 1, expect.objectContaining({ planVersionId: 'plan', ordinal: 0, trackId: 'one', eventId: expect.any(String), })); expect(event).not.toHaveBeenCalledWith('session-a', expect.objectContaining({ type: 'skipped', trackId: 'one' })); expect(usePlaybackStore.getState().currentTrack?.id).toBe('two'); expect(useVibeStore.getState().currentPlanItem).toMatchObject({ track_id: 'two', ordinal: 1 }); }); it('ends a Vibe by removing Vibe ownership and clearing the local queue', async () => { start.mockResolvedValue(response(1, item('one'), [item('two')])); next.mockResolvedValue(response(1, item('one', true), [item('two')])); end.mockResolvedValue(response(1)); await startVibeSession(track('seed')); await endVibeSession(); expect(end).toHaveBeenCalledWith('session-a'); expect(useVibeStore.getState().activeSessionId).toBeNull(); expect(usePlaybackStore.getState()).toMatchObject({ queueOwner: 'ordinary', vibeAdvanceHandler: null, currentTrack: null, queue: [], isPlaying: false, }); }); });