mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
9058abd340
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.
132 lines
4.8 KiB
TypeScript
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();
|
|
});
|
|
});
|