ceee9b9d12
Three tiers of offline support, all scoped to the active backend's localStorage namespace (mirroring the auth slice): Tier 1 — persist client state. queue + player slices are saved (queue entries/index/source; player track/position/volume/repeat/shuffle) and rehydrated on load, so a reload with no backend restores where the user left off. Playback never auto-resumes (browsers block autoplay). Retires the DEMO_QUEUE and isQueueOpen:true stubs. Tier 2 — persist the RTK Query cache. Last-seen library/albums/artists are snapshotted (fulfilled queries only) and replayed via RTKQ's extractRehydrationInfo at startup, so the library renders read-only when the backend is down. ConnectionStatus tooltip flags cached data offline. No server data is copied into a slice — the cache feeds itself back. Tier 3 — service worker audio + cover cache (PWA). Audio streams are cached keyed by content id (token stripped), range-aware (synthetic 206 slicing), with a 500MB LRU cap, so already-played tracks play fully offline. Cover art uses stale-while-revalidate in its own bounded cache. Module worker (ESM); pure helpers split into sw-core.js and unit-tested. Web app manifest enables "Install app". Player source badge now reflects real cached state. tsc clean, lint clean, 19 new tests pass, production build verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
3.0 KiB
TypeScript
121 lines
3.0 KiB
TypeScript
// @rstest-environment jsdom
|
|
import { expect, test, beforeEach, rstest } from '@rstest/core';
|
|
import {
|
|
loadQueueState,
|
|
loadPlayerState,
|
|
startPersistence,
|
|
} from '../src/store/persist';
|
|
import { queueInitialState, type QueueState } from '../src/store/slices/queue';
|
|
import {
|
|
playerInitialState,
|
|
type PlayerState,
|
|
} from '../src/store/slices/player';
|
|
import {
|
|
upsertInstance,
|
|
setActiveInstanceId,
|
|
instanceStorage,
|
|
} from '../src/config/instances';
|
|
|
|
beforeEach(() => {
|
|
localStorage.clear();
|
|
const inst = upsertInstance('http://test.local');
|
|
setActiveInstanceId(inst.id);
|
|
});
|
|
|
|
const sampleQueue: QueueState = {
|
|
entries: [
|
|
{
|
|
trackId: 't1',
|
|
title: 'A',
|
|
artistName: 'X',
|
|
albumTitle: 'Alb',
|
|
durationMs: 1000,
|
|
},
|
|
{
|
|
trackId: 't2',
|
|
title: 'B',
|
|
artistName: 'Y',
|
|
albumTitle: 'Alb',
|
|
durationMs: 2000,
|
|
},
|
|
],
|
|
currentIndex: 1,
|
|
source: 'album',
|
|
sourceId: 'alb-1',
|
|
sourceName: 'My Album',
|
|
};
|
|
|
|
test('loaders fall back to initial state with nothing persisted', () => {
|
|
expect(loadQueueState()).toEqual(queueInitialState);
|
|
expect(loadPlayerState()).toEqual(playerInitialState);
|
|
});
|
|
|
|
test('loadQueueState restores a persisted queue', () => {
|
|
instanceStorage.set('queue', JSON.stringify(sampleQueue));
|
|
expect(loadQueueState()).toEqual(sampleQueue);
|
|
});
|
|
|
|
test('loadQueueState guards a currentIndex past the entries array', () => {
|
|
instanceStorage.set(
|
|
'queue',
|
|
JSON.stringify({ ...sampleQueue, currentIndex: 99 }),
|
|
);
|
|
expect(loadQueueState().currentIndex).toBe(0);
|
|
});
|
|
|
|
test('loadPlayerState restores fields but never auto-resumes playback', () => {
|
|
instanceStorage.set(
|
|
'player',
|
|
JSON.stringify({
|
|
currentTrackId: 't2',
|
|
position: 42,
|
|
volume: 0.5,
|
|
muted: true,
|
|
repeat: 'all',
|
|
shuffle: true,
|
|
// a stale isPlaying:true must not survive a reload
|
|
isPlaying: true,
|
|
}),
|
|
);
|
|
const loaded = loadPlayerState();
|
|
expect(loaded.currentTrackId).toBe('t2');
|
|
expect(loaded.position).toBe(42);
|
|
expect(loaded.volume).toBe(0.5);
|
|
expect(loaded.repeat).toBe('all');
|
|
expect(loaded.isPlaying).toBe(false);
|
|
});
|
|
|
|
test('corrupt JSON falls back to initial state', () => {
|
|
instanceStorage.set('queue', '{not json');
|
|
expect(loadQueueState()).toEqual(queueInitialState);
|
|
});
|
|
|
|
test('startPersistence flushes changed state to storage after throttle', () => {
|
|
rstest.useFakeTimers();
|
|
let state = {
|
|
queue: queueInitialState,
|
|
player: playerInitialState,
|
|
} as { queue: QueueState; player: PlayerState };
|
|
let listener: (() => void) | null = null;
|
|
const store = {
|
|
getState: () => state as never,
|
|
subscribe: (l: () => void) => {
|
|
listener = l;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
startPersistence(store);
|
|
|
|
// mutate + notify
|
|
state = { ...state, queue: sampleQueue };
|
|
listener!();
|
|
// nothing written before the throttle window elapses
|
|
expect(instanceStorage.get('queue')).toBeNull();
|
|
|
|
rstest.advanceTimersByTime(1000);
|
|
expect(JSON.parse(instanceStorage.get('queue')!).currentIndex).toBe(1);
|
|
|
|
rstest.useRealTimers();
|
|
});
|