feat(web): radio queue auto-refreshes at 80% via ?exclude= param
Adds _radioSeedId state and a module-level $effect.root that triggers a /api/radio?seed_track=…&exclude=… fetch when queue consumption reaches 80%, appending new tracks without resetting the radio seed. Clears seed on any non-radio enqueue (playQueue/enqueueTrack/enqueueTracks). Five new vitest cases cover the trigger threshold, append logic, in-flight guard, and manual-enqueue reset path.
This commit is contained in:
@@ -333,3 +333,81 @@ describe('player store — enqueue + playRadio', () => {
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('M4c radio auto-refresh at 80%', () => {
|
||||
test('refresh fires at 80% queue consumption', async () => {
|
||||
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||
await playRadio('seed');
|
||||
apiGet.mockClear();
|
||||
apiGet.mockResolvedValueOnce({
|
||||
tracks: [track('seed'), track('new1')]
|
||||
});
|
||||
// Advance to index 3 (4 of 5 = 80%)
|
||||
skipNext(); skipNext(); skipNext();
|
||||
// Allow $effect to flush
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(apiGet).toHaveBeenCalled();
|
||||
const callArg = apiGet.mock.calls[0][0] as string;
|
||||
expect(callArg).toContain('seed_track=seed');
|
||||
expect(callArg).toContain('exclude=');
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
|
||||
test('refresh below 80% does not fire', async () => {
|
||||
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||
await playRadio('seed');
|
||||
apiGet.mockClear();
|
||||
skipNext(); skipNext(); // index = 2 (3 of 5 = 60%)
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
|
||||
test('refresh appends new tracks excluding seed at index 0', async () => {
|
||||
const initial = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks: initial });
|
||||
await playRadio('seed');
|
||||
apiGet.mockClear();
|
||||
// Refresh response: 5 tracks where the first is the seed (will be stripped).
|
||||
const refreshTracks = [track('seed'), track('n1'), track('n2'), track('n3'), track('n4')];
|
||||
apiGet.mockResolvedValueOnce({ tracks: refreshTracks });
|
||||
skipNext(); skipNext(); skipNext(); // index = 3 (80%)
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Initial 5 + 4 new (5 in response - 1 seed stripped) = 9
|
||||
expect(player.queue.length).toBe(9);
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
|
||||
test('refresh does NOT double-fire while in-flight', async () => {
|
||||
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||
await playRadio('seed');
|
||||
apiGet.mockClear();
|
||||
// Hang the refresh call so the in-flight flag stays set.
|
||||
let resolveFn!: (v: unknown) => void;
|
||||
const hanging = new Promise((r) => { resolveFn = r; });
|
||||
apiGet.mockReturnValueOnce(hanging as unknown as Promise<unknown>);
|
||||
skipNext(); skipNext(); skipNext(); // 80% — fires refresh
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
// Now advance further while in-flight; should NOT fire a second call.
|
||||
skipNext();
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(apiGet).toHaveBeenCalledTimes(1);
|
||||
resolveFn({ tracks: [] });
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
|
||||
test('refresh resets when user enqueues from non-radio source', async () => {
|
||||
const tracks = [track('seed'), track('t1'), track('t2'), track('t3'), track('t4')];
|
||||
const apiGet = vi.spyOn(client.api, 'get').mockResolvedValueOnce({ tracks });
|
||||
await playRadio('seed');
|
||||
enqueueTrack(track('manual1'));
|
||||
apiGet.mockClear();
|
||||
skipNext(); skipNext(); skipNext(); skipNext(); // advance past 80% on the 6-track queue
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(apiGet).not.toHaveBeenCalled();
|
||||
apiGet.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user