refactor(playback): move thin-state queue resolver out of utils/library into the feature

The queue-resolver family (queueTrackResolver, queueRestore, queueItemRef,
queueTrackView) runtime-imports usePlayerStore / playerStoreTypes — it is the
playback engine's thin-state queue subsystem, not shared library infra. It sat
in utils/library only because it queries the local index. Co-locate into
features/playback/store so those edges become intra-feature; its remaining deps
(advancedSearchLocal, libraryReady, serverLookup, serverIndexKey, authStore) are
plain core/infra (feature -> infra, no inversion).

Removes 4 of the 6 store/utils -> feature runtime inversions that blocked the
utils/library -> lib move. 8 files moved, 46 consumers rewritten. tsc 0, lint 0,
targeted suites green.
This commit is contained in:
Psychotoxical
2026-06-30 16:50:29 +02:00
parent cb1a110afb
commit f41005682d
50 changed files with 73 additions and 73 deletions
@@ -0,0 +1,143 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { onInvoke } from '@/test/mocks/tauri';
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { TrackRefDto } from '@/lib/api/library';
import type { QueueItemRef } from '@/features/playback/store/playerStoreTypes';
import * as subsonic from '@/lib/api/subsonicLibrary';
import {
resolveBatch,
resolveVisibleRange,
getCachedTrack,
placeholderTrack,
applyQueueOverrides,
seedQueueResolver,
invalidateQueueResolver,
subscribeQueueResolver,
_resetQueueResolverForTest,
} from './queueTrackResolver';
const ready = () =>
onInvoke('library_get_status', () => ({
serverId: 's1', libraryScope: '', syncPhase: 'ready',
capabilityFlags: 0, libraryTier: 'unknown', syncedAt: 0,
}));
const notReady = () =>
onInvoke('library_get_status', () => ({ serverId: 's1', libraryScope: '', syncPhase: 'initial_sync' }));
const echoBatch = () =>
onInvoke('library_get_tracks_batch', (args) =>
(args as { refs: TrackRefDto[] }).refs.map(r => ({
serverId: r.serverId, id: r.trackId, title: `T-${r.trackId}`,
album: 'A', durationSec: 1, syncedAt: 0, rawJson: {},
})),
);
const ref = (trackId: string, extra: Partial<QueueItemRef> = {}): QueueItemRef => ({ serverId: 's1', trackId, ...extra });
describe('queueTrackResolver', () => {
beforeEach(() => {
_resetQueueResolverForTest();
useLibraryIndexStore.setState({ masterEnabled: true });
usePlayerStore.setState({ starredOverrides: {}, userRatingOverrides: {} });
vi.restoreAllMocks();
});
it('getCachedTrack returns undefined on a miss (no fetch)', () => {
expect(getCachedTrack(ref('x'))).toBeUndefined();
});
it('resolveBatch fills the cache from the index; getCachedTrack reads it', async () => {
ready();
echoBatch();
await resolveBatch([ref('t1'), ref('t2')]);
expect(getCachedTrack(ref('t1'))?.title).toBe('T-t1');
expect(getCachedTrack(ref('t2'))?.title).toBe('T-t2');
});
it('carries queue-only flags from the ref onto the resolved track', async () => {
ready();
echoBatch();
await resolveBatch([ref('t1', { radioAdded: true }), ref('t2', { autoAdded: true, playNextAdded: true })]);
expect(getCachedTrack(ref('t1'))?.radioAdded).toBe(true);
expect(getCachedTrack(ref('t2'))?.autoAdded).toBe(true);
expect(getCachedTrack(ref('t2'))?.playNextAdded).toBe(true);
});
it('falls back to network getSongForServer when the index is not ready', async () => {
notReady();
const spy = vi.spyOn(subsonic, 'getSongForServer').mockResolvedValue({
id: 't9', title: 'Net Song', album: 'A', duration: 1,
} as never);
await resolveBatch([ref('t9')]);
expect(spy).toHaveBeenCalledWith('s1', 't9');
expect(getCachedTrack(ref('t9'))?.title).toBe('Net Song');
});
it('notifies subscribers when a fetch lands', async () => {
ready();
echoBatch();
const cb = vi.fn();
const unsub = subscribeQueueResolver(cb);
await resolveBatch([ref('t1')]);
expect(cb).toHaveBeenCalled();
unsub();
});
it('does not re-fetch already-cached refs', async () => {
ready();
const batch = vi.fn((args: { refs: TrackRefDto[] }) =>
args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} })));
onInvoke('library_get_tracks_batch', batch as never);
await resolveBatch([ref('t1')]);
await resolveBatch([ref('t1')]); // cached → no second batch call
expect(batch).toHaveBeenCalledTimes(1);
});
it('seedQueueResolver caches known tracks without a fetch', () => {
seedQueueResolver('s1', [{ id: 't1', title: 'Seeded', artist: '', album: 'A', albumId: 'A', duration: 1 }]);
expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded');
});
it('invalidateQueueResolver drops the cached entry', async () => {
ready();
echoBatch();
await resolveBatch([ref('t1')]);
expect(getCachedTrack(ref('t1'))).toBeDefined();
invalidateQueueResolver('t1');
expect(getCachedTrack(ref('t1'))).toBeUndefined();
});
it('applyQueueOverrides merges session star/rating overrides', () => {
usePlayerStore.setState({ starredOverrides: { t1: true }, userRatingOverrides: { t1: 4 } });
const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1 });
expect(!!merged.starred).toBe(true);
expect(merged.userRating).toBe(4);
});
it('applyQueueOverrides clears starred when the override is false', () => {
usePlayerStore.setState({ starredOverrides: { t1: false }, userRatingOverrides: {} });
const merged = applyQueueOverrides({ id: 't1', title: 'X', artist: '', album: 'A', albumId: 'A', duration: 1, starred: '2020' });
expect(merged.starred).toBeUndefined();
});
it('placeholderTrack preserves identity + queue flags', () => {
const p = placeholderTrack(ref('t1', { radioAdded: true }));
expect(p.id).toBe('t1');
expect(p.radioAdded).toBe(true);
});
it('resolveVisibleRange prefetches the window around the visible rows', async () => {
ready();
const batch = vi.fn((args: { refs: TrackRefDto[] }) =>
args.refs.map(r => ({ serverId: r.serverId, id: r.trackId, title: r.trackId, album: 'A', durationSec: 1, syncedAt: 0, rawJson: {} })));
onInvoke('library_get_tracks_batch', batch as never);
const refs = Array.from({ length: 400 }, (_, i) => ref(`t${i}`));
resolveVisibleRange(refs, 100, 120);
await vi.waitFor(() => expect(getCachedTrack(ref('t120'))).toBeDefined());
// back window: t50 in range (100 - 50); t49 out of range
expect(getCachedTrack(ref('t50'))).toBeDefined();
expect(getCachedTrack(ref('t49'))).toBeUndefined();
});
});