feat(vibe): reconcile mutable session previews in playback
This commit is contained in:
@@ -1,21 +1,16 @@
|
||||
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 { next, start } = vi.hoisted(() => ({ next: vi.fn(), start: vi.fn() }));
|
||||
vi.mock('./vibeService', () => ({
|
||||
vibeService: { start, next },
|
||||
fetchNextBatch: async (count: number, sessionId: string) => {
|
||||
const tracks: Track[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
try { tracks.push((await next(sessionId)).track); } catch { return { tracks, status: 'failed' as const }; }
|
||||
}
|
||||
return { tracks, status: 'complete' as const };
|
||||
},
|
||||
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 { startVibeSession } from './vibeSession';
|
||||
import { advancePastUnplayableVibeTrack, advanceVibe, endVibeSession, reportVibeEvent, startVibeSession } from './vibeSession';
|
||||
|
||||
const track = (id: string): Track => ({
|
||||
id, path: `/music/${id}.mp3`, hash: id, title: id, artist: 'Artist',
|
||||
@@ -23,34 +18,237 @@ const track = (id: string): Track => ({
|
||||
play_count: 0, skip_count: 0, dislike_count: 0,
|
||||
});
|
||||
|
||||
describe('startVibeSession', () => {
|
||||
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 });
|
||||
usePlaybackStore.setState({
|
||||
currentTrack: null, queue: [], currentIndex: -1, isPlaying: false, vibeAdvanceHandler: null, queueOwner: 'ordinary',
|
||||
});
|
||||
getTrack.mockImplementation((id: string) => Promise.resolve(track(id)));
|
||||
});
|
||||
|
||||
it('does not replace a working Vibe when the new plan cannot hydrate', async () => {
|
||||
const old = track('old');
|
||||
useVibeStore.getState().setActiveSession({ sessionId: 'old-session', seedTrackId: old.id });
|
||||
usePlaybackStore.getState().setQueue([old]);
|
||||
usePlaybackStore.getState().playTrack(old);
|
||||
start.mockResolvedValue({ sessionId: 'new-session', plan: [] });
|
||||
next.mockRejectedValue(new Error('missing session'));
|
||||
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({ tracks: [], status: 'failed' });
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('old-session');
|
||||
expect(usePlaybackStore.getState().currentTrack?.id).toBe('old');
|
||||
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('serializes rapid starts and hydrates only one session', async () => {
|
||||
const recommended = track('recommended');
|
||||
start.mockResolvedValue({ sessionId: 'session-a', plan: [] });
|
||||
next.mockResolvedValue({ track: recommended });
|
||||
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 Promise.all([startVibeSession(track('seed-a')), startVibeSession(track('seed-b'))]);
|
||||
expect(start).toHaveBeenCalledTimes(1);
|
||||
expect(next).toHaveBeenCalledWith('session-a');
|
||||
expect(useVibeStore.getState().activeSessionId).toBe('session-a');
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user