Files
Psychotoxical-psysonic/src/features/playback/store/queueUndo.test.ts
T
Psychotoxical 9058abd340 refactor(lib): extract Track/QueueItemRef domain model to lib/media/trackTypes
Track is the app's normalized song model and QueueItemRef its thin queue
identity -- consumed app-wide (queue, cover, context menus, sharing, lucky-mix,
library browse), not a playback-internal detail. They lived in
features/playback/store/playerStoreTypes, forcing ~115 core/feature files to
reach into the playback feature for the central media type.

Move the two interfaces to lib/media/trackTypes; playerStoreTypes keeps
PlayerState and now imports them from lib. 115 importers repointed (mixed
'PlayerState, Track' lines split so PlayerState stays). Pure type move (erased at
runtime). tsc 0, lint 0.
2026-06-30 17:16:56 +02:00

132 lines
4.8 KiB
TypeScript

/**
* Module-scoped queue undo / redo stack. The interesting behaviours are
* (a) max-size enforcement, (b) the redo stack being wiped on a fresh undo
* push, and (c) the scroll-top reader / consumer pair that QueuePanel uses
* to restore list scroll position after an undo/redo commit.
*/
import type { Track } from '@/lib/media/trackTypes';
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
import { beforeEach, describe, expect, it } from 'vitest';
import {
QUEUE_UNDO_MAX,
_resetQueueUndoStacksForTest,
consumePendingQueueListScrollTop,
popQueueRedoSnapshot,
popQueueUndoSnapshot,
pushQueueRedoSnapshot,
pushQueueUndoFromGetter,
pushQueueUndoSnapshot,
queueUndoSnapshotFromState,
registerQueueListScrollTopReader,
setPendingQueueListScrollTop,
} from '@/features/playback/store/queueUndo';
function track(id: string): Track {
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100 };
}
// Thin-state: the snapshot reads `queueItems`; the `tracks` arg is a convenience
// for tests — it's lowered to refs (with the currentTrack defaulting to the head).
function state(tracks: Track[], overrides: Partial<PlayerState> = {}): PlayerState {
return {
queueItems: toQueueItemRefs('', tracks),
queueIndex: 0,
currentTrack: tracks[0] ?? null,
currentTime: 0,
progress: 0,
isPlaying: false,
queueServerId: null,
...overrides,
} as PlayerState;
}
beforeEach(() => {
_resetQueueUndoStacksForTest();
registerQueueListScrollTopReader(null);
// Drain any leftover pending scroll-top
consumePendingQueueListScrollTop();
});
describe('queueUndoSnapshotFromState', () => {
it('captures the queue as thin refs and clones currentTrack', () => {
const original = state([track('a'), track('b')]);
const snap = queueUndoSnapshotFromState(original);
expect(snap.currentTrack).not.toBe(original.currentTrack);
expect(snap.queueItems.map(r => r.trackId)).toEqual(['a', 'b']);
});
it('preserves currentTrack=null', () => {
const snap = queueUndoSnapshotFromState(state([]));
expect(snap.currentTrack).toBeNull();
});
it('includes queueListScrollTop only when a reader is registered', () => {
expect(queueUndoSnapshotFromState(state([track('a')])).queueListScrollTop).toBeUndefined();
registerQueueListScrollTopReader(() => 240);
expect(queueUndoSnapshotFromState(state([track('a')])).queueListScrollTop).toBe(240);
});
});
describe('pushQueueUndoFromGetter', () => {
it('captures the current state on top of the undo stack', () => {
pushQueueUndoFromGetter(() => state([track('a')]));
const snap = popQueueUndoSnapshot();
expect(snap?.queueItems[0].trackId).toBe('a');
});
it('wipes the redo stack — a fresh action invalidates redo history', () => {
pushQueueRedoSnapshot(queueUndoSnapshotFromState(state([track('z')])));
pushQueueUndoFromGetter(() => state([track('a')]));
expect(popQueueRedoSnapshot()).toBeUndefined();
});
it(`caps the undo stack at QUEUE_UNDO_MAX (=${QUEUE_UNDO_MAX})`, () => {
for (let i = 0; i < QUEUE_UNDO_MAX + 5; i++) {
pushQueueUndoFromGetter(() => state([track(`t${i}`)]));
}
let depth = 0;
while (popQueueUndoSnapshot()) depth++;
expect(depth).toBe(QUEUE_UNDO_MAX);
});
});
describe('pushQueueUndoSnapshot / pushQueueRedoSnapshot', () => {
it('respect QUEUE_UNDO_MAX when pushing prebuilt snapshots', () => {
const snap = queueUndoSnapshotFromState(state([track('a')]));
for (let i = 0; i < QUEUE_UNDO_MAX + 3; i++) pushQueueRedoSnapshot(snap);
let depth = 0;
while (popQueueRedoSnapshot()) depth++;
expect(depth).toBe(QUEUE_UNDO_MAX);
});
it('undo-snapshot push keeps order LIFO', () => {
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('first')])));
pushQueueUndoSnapshot(queueUndoSnapshotFromState(state([track('second')])));
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('second');
expect(popQueueUndoSnapshot()?.queueItems[0].trackId).toBe('first');
});
});
describe('_resetQueueUndoStacksForTest', () => {
it('clears both stacks', () => {
pushQueueUndoFromGetter(() => state([track('a')]));
pushQueueRedoSnapshot(queueUndoSnapshotFromState(state([track('b')])));
_resetQueueUndoStacksForTest();
expect(popQueueUndoSnapshot()).toBeUndefined();
expect(popQueueRedoSnapshot()).toBeUndefined();
});
});
describe('pending queue-list scroll-top', () => {
it('returns undefined when nothing was set', () => {
expect(consumePendingQueueListScrollTop()).toBeUndefined();
});
it('round-trips a stored value once and then drains', () => {
setPendingQueueListScrollTop(512);
expect(consumePendingQueueListScrollTop()).toBe(512);
expect(consumePendingQueueListScrollTop()).toBeUndefined();
});
});