`q.source` was written by SetQueue and cleared in exactly one place, Clear, so no append path touched it: adding a track to a queue built from an album left the page still offering "Playing from <that album>", and since the source is persisted alongside the queue state the wrong label outlived the session that earned it. Every add and insert path drops it now. Removing and reordering deliberately do not — a queue with a track taken out of it is still that album, and the link still goes somewhere true. Only the arrival of a track from elsewhere makes the claim false. The delta event carries the source for the same reason it carries the current index: an append emits nothing else, so the frontend would keep the label it was last given until something forced a full state. Closes #14 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
372 lines
9.6 KiB
TypeScript
372 lines
9.6 KiB
TypeScript
/**
|
|
* The queue store's delta reducer is the most intricate pure logic in
|
|
* the frontend: four mutation actions arriving as events, applied to a
|
|
* cached array that must stay identical to the Go queue's own. `move` in
|
|
* particular adjusts its insertion index for elements removed before it,
|
|
* and gets that wrong silently.
|
|
*/
|
|
import { describe, expect, it, beforeEach } from 'vitest';
|
|
|
|
import { queueStore, type QueueTrack } from '@store/queue-store';
|
|
import { Events } from '../../src/events';
|
|
import { emit, flush, lastArgs, calls } from '@test/support/harness';
|
|
|
|
function track(n: number): QueueTrack {
|
|
return {
|
|
id: n,
|
|
audioFileId: n,
|
|
filePath: `/music/${n}.mp3`,
|
|
position: n,
|
|
title: `Track ${n}`,
|
|
artist: 'Artist',
|
|
album: 'Album',
|
|
coverArtPath: '',
|
|
artistMbid: '',
|
|
releaseGroupMbid: '',
|
|
recordingMbid: '',
|
|
};
|
|
}
|
|
|
|
/** Titles of the cached queue, the cheapest readable assertion. */
|
|
function titles(): string[] {
|
|
return queueStore.getState().tracks.map((t) => t.title);
|
|
}
|
|
|
|
/** Push an authoritative full-state sync, as the backend does on
|
|
* startup and after SetQueue. */
|
|
function sync(tracks: QueueTrack[], currentIndex = 0): void {
|
|
emit(Events.QueueChanged, {
|
|
tracks,
|
|
currentIndex,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
source: { type: '', id: 0, label: '' },
|
|
});
|
|
}
|
|
|
|
describe('queue store: full-state sync', () => {
|
|
beforeEach(() => {
|
|
sync([]);
|
|
});
|
|
|
|
it('replaces cached state wholesale', () => {
|
|
sync([track(1), track(2)], 1);
|
|
|
|
expect(queueStore.getState()).toEqual({
|
|
tracks: [track(1), track(2)],
|
|
currentIndex: 1,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
source: { type: '', id: 0, label: '' },
|
|
});
|
|
});
|
|
|
|
it('carries a non-empty source through from the backend', () => {
|
|
emit(Events.QueueChanged, {
|
|
tracks: [track(1)],
|
|
currentIndex: 0,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
source: { type: 'album', id: 7, label: 'Abbey Road' },
|
|
});
|
|
|
|
expect(queueStore.getState().source).toEqual({
|
|
type: 'album',
|
|
id: 7,
|
|
label: 'Abbey Road',
|
|
});
|
|
});
|
|
|
|
it('tolerates a null track list, which Go sends for an empty queue', () => {
|
|
emit(Events.QueueChanged, {
|
|
tracks: null,
|
|
currentIndex: -1,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
source: { type: '', id: 0, label: '' },
|
|
});
|
|
|
|
expect(queueStore.getState().tracks).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('queue store: track deltas', () => {
|
|
beforeEach(() => {
|
|
sync([track(1), track(2), track(3)], 0);
|
|
});
|
|
|
|
it('appends on add', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'add',
|
|
tracks: [track(4)],
|
|
index: 0,
|
|
currentIndex: 0,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 1', 'Track 2', 'Track 3', 'Track 4']);
|
|
});
|
|
|
|
it('splices at the index on insert', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'insert',
|
|
tracks: [track(9)],
|
|
index: 1,
|
|
currentIndex: 0,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 1', 'Track 9', 'Track 2', 'Track 3']);
|
|
});
|
|
|
|
it('removes every listed position at once, not one at a time', () => {
|
|
// Removing 0 then 2 sequentially would take the wrong second track;
|
|
// the reducer must treat the positions as indices into the original.
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'remove',
|
|
positions: [0, 2],
|
|
index: 0,
|
|
currentIndex: 0,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 2']);
|
|
});
|
|
|
|
it('adopts the backend current index from every delta', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'remove',
|
|
positions: [0],
|
|
index: 0,
|
|
currentIndex: 1,
|
|
});
|
|
|
|
expect(queueStore.getState().currentIndex).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('queue store: move', () => {
|
|
beforeEach(() => {
|
|
sync([track(1), track(2), track(3), track(4)], 0);
|
|
});
|
|
|
|
it('moves a track forward, compensating for its own removal', () => {
|
|
// Move index 0 to index 2. After removing it, the target shifts
|
|
// down by one, so track 1 lands between 2 and 3 — not after 3.
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'move',
|
|
tracks: [track(1)],
|
|
positions: [0],
|
|
index: 2,
|
|
currentIndex: 1,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 2', 'Track 1', 'Track 3', 'Track 4']);
|
|
});
|
|
|
|
it('moves a track backward without compensating', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'move',
|
|
tracks: [track(4)],
|
|
positions: [3],
|
|
index: 1,
|
|
currentIndex: 0,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 1', 'Track 4', 'Track 2', 'Track 3']);
|
|
});
|
|
|
|
it('moves a multi-selection, compensating once per element before the target', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'move',
|
|
tracks: [track(1), track(2)],
|
|
positions: [0, 1],
|
|
index: 3,
|
|
currentIndex: 0,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 3', 'Track 1', 'Track 2', 'Track 4']);
|
|
});
|
|
|
|
it('clamps a target past the end of the shortened list', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'move',
|
|
tracks: [track(1)],
|
|
positions: [0],
|
|
index: 99,
|
|
currentIndex: 3,
|
|
});
|
|
|
|
expect(titles()).toEqual(['Track 2', 'Track 3', 'Track 4', 'Track 1']);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* "Playing from X" is a claim that everything queued came from X, and
|
|
* appending a track from anywhere else makes it false. The backend
|
|
* clears the source on every add/insert path — but the delta is the
|
|
* only event those paths emit, so the label corrects itself here or
|
|
* not at all.
|
|
*/
|
|
describe('queue store: the source travels on the delta', () => {
|
|
function syncWithAlbum(): void {
|
|
emit(Events.QueueChanged, {
|
|
tracks: [track(1), track(2)],
|
|
currentIndex: 0,
|
|
shuffleMode: false,
|
|
repeatMode: 'off',
|
|
source: { type: 'album', id: 7, label: 'Abbey Road' },
|
|
});
|
|
}
|
|
|
|
beforeEach(() => {
|
|
syncWithAlbum();
|
|
});
|
|
|
|
it('drops the label when an append clears it backend-side', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'add',
|
|
tracks: [track(3)],
|
|
index: 2,
|
|
currentIndex: 0,
|
|
source: { type: '', id: 0, label: '' },
|
|
});
|
|
|
|
expect(queueStore.getState().source).toEqual({
|
|
type: '',
|
|
id: 0,
|
|
label: '',
|
|
});
|
|
});
|
|
|
|
it('keeps a label the backend still reports, as on a removal', () => {
|
|
emit(Events.QueueTracksModified, {
|
|
action: 'remove',
|
|
positions: [1],
|
|
index: 0,
|
|
currentIndex: 0,
|
|
source: { type: 'album', id: 7, label: 'Abbey Road' },
|
|
});
|
|
|
|
expect(queueStore.getState().source).toEqual({
|
|
type: 'album',
|
|
id: 7,
|
|
label: 'Abbey Road',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('queue store: mode deltas', () => {
|
|
beforeEach(() => {
|
|
sync([track(1)], 0);
|
|
});
|
|
|
|
it('applies shuffle and repeat together', () => {
|
|
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'one' });
|
|
|
|
const state = queueStore.getState();
|
|
|
|
expect([state.shuffleMode, state.repeatMode]).toEqual([true, 'one']);
|
|
});
|
|
|
|
it('leaves the track list untouched', () => {
|
|
emit(Events.QueueModeChanged, { shuffleMode: true, repeatMode: 'all' });
|
|
|
|
expect(titles()).toEqual(['Track 1']);
|
|
});
|
|
|
|
it('applies an index-only delta', () => {
|
|
emit(Events.QueueIndexChanged, { currentIndex: 7 });
|
|
|
|
expect(queueStore.getState().currentIndex).toBe(7);
|
|
});
|
|
});
|
|
|
|
describe('queue store: subscriber notification', () => {
|
|
beforeEach(() => {
|
|
sync([]);
|
|
});
|
|
|
|
it('coalesces a burst of events into one notification', async () => {
|
|
let notifications = 0;
|
|
const unsubscribe = queueStore.subscribe(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
emit(Events.QueueIndexChanged, { currentIndex: 1 });
|
|
emit(Events.QueueIndexChanged, { currentIndex: 2 });
|
|
emit(Events.QueueIndexChanged, { currentIndex: 3 });
|
|
await flush();
|
|
|
|
unsubscribe();
|
|
|
|
expect(notifications).toBe(1);
|
|
});
|
|
|
|
it('stops notifying after unsubscribe', async () => {
|
|
let notifications = 0;
|
|
const unsubscribe = queueStore.subscribe(() => {
|
|
notifications += 1;
|
|
});
|
|
|
|
unsubscribe();
|
|
emit(Events.QueueIndexChanged, { currentIndex: 1 });
|
|
await flush();
|
|
|
|
expect(notifications).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('queue store: actions reach the backend', () => {
|
|
it('forwards setQueue with its default shuffleStart and no source', () => {
|
|
queueStore.setQueue(['/a.mp3', '/b.mp3'], 1);
|
|
|
|
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
|
['/a.mp3', '/b.mp3'],
|
|
1,
|
|
false,
|
|
{ type: '', id: 0, label: '' },
|
|
]);
|
|
});
|
|
|
|
it('forwards setQueue with the given source', () => {
|
|
queueStore.setQueue(['/a.mp3'], 0, true, {
|
|
type: 'playlist',
|
|
id: 7,
|
|
label: 'Road Trip',
|
|
});
|
|
|
|
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
|
|
['/a.mp3'],
|
|
0,
|
|
true,
|
|
{ type: 'playlist', id: 7, label: 'Road Trip' },
|
|
]);
|
|
});
|
|
|
|
it('maps each mutation onto its own bound method', () => {
|
|
queueStore.addToQueue('/a.mp3');
|
|
queueStore.playNext('/b.mp3');
|
|
queueStore.removeFromQueue(2);
|
|
queueStore.moveTracksInQueue([0, 1], 3);
|
|
queueStore.toggleShuffle();
|
|
queueStore.cycleRepeat();
|
|
queueStore.clearQueue();
|
|
|
|
expect(calls().map((c) => c.path)).toEqual([
|
|
'queue.Queue.AddTrack',
|
|
'queue.Queue.InsertNext',
|
|
'queue.Queue.RemoveTrack',
|
|
'queue.Queue.MoveQueueTracks',
|
|
'queue.Queue.ToggleShuffle',
|
|
'queue.Queue.CycleRepeat',
|
|
'queue.Queue.Clear',
|
|
]);
|
|
});
|
|
|
|
it('does not optimistically mutate cached state', () => {
|
|
sync([track(1)], 0);
|
|
queueStore.clearQueue();
|
|
|
|
// The backend is the only writer; the cache waits for the event.
|
|
expect(titles()).toEqual(['Track 1']);
|
|
});
|
|
});
|