feat(library): render from locally-cached data when offline
The Library showed a blocking error with the backend unreachable. Now it composes a read-only library from everything already in the RTK Query cache (Tier-2 rehydrated last-seen data + anything fetched this session), so it keeps rendering offline instead of erroring. - selectors: `selectLocalTracks/Albums/Artists` — memoized, union + dedupe across getTracks/getAlbums/getArtists, the per-album/artist list endpoints, and single-entity fetches; skips pending/rejected entries - LibraryPage: when offline, fall back to the composed lists (live data still wins online), filter client-side for search, show an offline banner, and never show the retry-only ErrorState - i18n: `library.offline.*` (en + ru) - test: selector composition / dedup / status filtering (3 cases) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from '@rstest/core';
|
||||
import {
|
||||
selectLocalTracks,
|
||||
selectLocalAlbums,
|
||||
selectLocalArtists,
|
||||
} from '../src/store/selectors/localLibrary';
|
||||
import type { RootState } from '../src/store/index';
|
||||
|
||||
function stateWith(queries: Record<string, unknown>): RootState {
|
||||
return { api: { queries } } as unknown as RootState;
|
||||
}
|
||||
|
||||
const track = (id: string, over: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
title: `Track ${id}`,
|
||||
artistName: 'A',
|
||||
albumTitle: 'Alb',
|
||||
...over,
|
||||
});
|
||||
|
||||
test('selectLocalTracks unions getTracks pages, list endpoints and single tracks', () => {
|
||||
const state = stateWith({
|
||||
'getTracks(undefined)': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getTracks',
|
||||
data: { items: [track('1'), track('2')], total: 2 },
|
||||
},
|
||||
'getArtistTracks("x")': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getArtistTracks',
|
||||
data: [track('2'), track('3')], // 2 is a dupe
|
||||
},
|
||||
'getTrack("4")': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getTrack',
|
||||
data: track('4'),
|
||||
},
|
||||
});
|
||||
|
||||
const ids = selectLocalTracks(state)
|
||||
.map((t) => t.id)
|
||||
.sort();
|
||||
expect(ids).toEqual(['1', '2', '3', '4']);
|
||||
});
|
||||
|
||||
test('selectLocalTracks ignores pending/rejected and null-data entries', () => {
|
||||
const state = stateWith({
|
||||
'getTracks(a)': {
|
||||
status: 'rejected',
|
||||
endpointName: 'getTracks',
|
||||
data: undefined,
|
||||
},
|
||||
'getTracks(b)': { status: 'pending', endpointName: 'getTracks' },
|
||||
'getTracks(c)': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getTracks',
|
||||
data: { items: [track('9')], total: 1 },
|
||||
},
|
||||
});
|
||||
expect(selectLocalTracks(state).map((t) => t.id)).toEqual(['9']);
|
||||
});
|
||||
|
||||
test('selectLocalAlbums and selectLocalArtists compose and dedupe', () => {
|
||||
const state = stateWith({
|
||||
'getAlbums(undefined)': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getAlbums',
|
||||
data: { items: [{ id: 'al1' }, { id: 'al2' }], total: 2 },
|
||||
},
|
||||
'getArtistAlbums("x")': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getArtistAlbums',
|
||||
data: [{ id: 'al2' }], // dupe
|
||||
},
|
||||
'getArtists(undefined)': {
|
||||
status: 'fulfilled',
|
||||
endpointName: 'getArtists',
|
||||
data: { items: [{ id: 'ar1' }], total: 1 },
|
||||
},
|
||||
});
|
||||
expect(
|
||||
selectLocalAlbums(state)
|
||||
.map((a) => a.id)
|
||||
.sort(),
|
||||
).toEqual(['al1', 'al2']);
|
||||
expect(selectLocalArtists(state).map((a) => a.id)).toEqual(['ar1']);
|
||||
});
|
||||
Reference in New Issue
Block a user