255 lines
13 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
});
|