Files
muzick/frontend/src/services/vibeSession.test.ts
T

255 lines
13 KiB
TypeScript

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<typeof response> & { 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,
});
});
});