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:
@@ -8,6 +8,7 @@ import {
|
|||||||
ScrollArea,
|
ScrollArea,
|
||||||
Card,
|
Card,
|
||||||
TextField,
|
TextField,
|
||||||
|
Callout,
|
||||||
} from '@olly/modern-sk';
|
} from '@olly/modern-sk';
|
||||||
import {
|
import {
|
||||||
useGetTracksQuery,
|
useGetTracksQuery,
|
||||||
@@ -18,13 +19,33 @@ import { TrackRow } from '../../components/track/TrackRow';
|
|||||||
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
import { LoadingSkeleton } from '../../components/common/LoadingSkeleton';
|
||||||
import { EmptyState } from '../../components/common/EmptyState';
|
import { EmptyState } from '../../components/common/EmptyState';
|
||||||
import { ErrorState } from '../../components/common/ErrorState';
|
import { ErrorState } from '../../components/common/ErrorState';
|
||||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
import { useAppDispatch, useAppSelector } from '../../hooks/useAppDispatch';
|
||||||
|
import { useIsOffline } from '../../hooks/useConnectionStatus';
|
||||||
|
import {
|
||||||
|
selectLocalTracks,
|
||||||
|
selectLocalAlbums,
|
||||||
|
selectLocalArtists,
|
||||||
|
} from '../../store/selectors/localLibrary';
|
||||||
import { setQueue } from '../../store/slices/queue';
|
import { setQueue } from '../../store/slices/queue';
|
||||||
import type { Track, Album, Artist } from '../../api/types';
|
import type { Track, Album, Artist } from '../../api/types';
|
||||||
import { getCoverUrl } from '../../api/endpoints/streaming';
|
import { getCoverUrl } from '../../api/endpoints/streaming';
|
||||||
import { formatDuration } from '../../lib/format';
|
import { formatDuration } from '../../lib/format';
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from 'use-debounce';
|
||||||
|
|
||||||
|
/** Case-insensitive substring match used for client-side search while offline
|
||||||
|
* (the server can't run the query, so we filter the locally-cached library). */
|
||||||
|
function matchTrack(tr: Track, q: string): boolean {
|
||||||
|
return (
|
||||||
|
tr.title.toLowerCase().includes(q) ||
|
||||||
|
tr.artistName.toLowerCase().includes(q) ||
|
||||||
|
tr.albumTitle.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const matchAlbum = (a: Album, q: string): boolean =>
|
||||||
|
a.title.toLowerCase().includes(q) || a.artistName.toLowerCase().includes(q);
|
||||||
|
const matchArtist = (a: Artist, q: string): boolean =>
|
||||||
|
a.name.toLowerCase().includes(q);
|
||||||
|
|
||||||
export function LibraryPage() {
|
export function LibraryPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -43,6 +64,26 @@ export function LibraryPage() {
|
|||||||
debouncedSearch ? { search } : undefined,
|
debouncedSearch ? { search } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Offline fallback: when the backend is unreachable, compose the library from
|
||||||
|
// whatever the RTKQ cache holds locally (rehydrated last-seen + this session),
|
||||||
|
// filtering client-side since the server can't run the search.
|
||||||
|
const offline = useIsOffline();
|
||||||
|
const localTracks = useAppSelector(selectLocalTracks);
|
||||||
|
const localAlbums = useAppSelector(selectLocalAlbums);
|
||||||
|
const localArtists = useAppSelector(selectLocalArtists);
|
||||||
|
const q = debouncedSearch.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Live server data wins; offline we fall back to the locally-composed list.
|
||||||
|
const tracksToShow =
|
||||||
|
tracksQuery.data?.items ??
|
||||||
|
(offline ? (q ? localTracks.filter((tr) => matchTrack(tr, q)) : localTracks) : undefined);
|
||||||
|
const albumsToShow =
|
||||||
|
albumsQuery.data?.items ??
|
||||||
|
(offline ? (q ? localAlbums.filter((a) => matchAlbum(a, q)) : localAlbums) : undefined);
|
||||||
|
const artistsToShow =
|
||||||
|
artistsQuery.data?.items ??
|
||||||
|
(offline ? (q ? localArtists.filter((a) => matchArtist(a, q)) : localArtists) : undefined);
|
||||||
|
|
||||||
const handlePlayAll = (tracks: Track[]) => {
|
const handlePlayAll = (tracks: Track[]) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
setQueue({
|
setQueue({
|
||||||
@@ -84,6 +125,12 @@ export function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offline && (
|
||||||
|
<div style={{ padding: '0.75rem 1.5rem 0', flexShrink: 0 }}>
|
||||||
|
<Callout variant="info">{t('library.offline.banner')}</Callout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={tab}
|
value={tab}
|
||||||
onValueChange={setTab}
|
onValueChange={setTab}
|
||||||
@@ -112,22 +159,28 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="tracks" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{tracksQuery.isLoading && <LoadingSkeleton rows={12} />}
|
{!tracksToShow && tracksQuery.isLoading && (
|
||||||
{tracksQuery.isError && (
|
<LoadingSkeleton rows={12} />
|
||||||
|
)}
|
||||||
|
{!tracksToShow && !offline && tracksQuery.isError && (
|
||||||
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
<ErrorState onRetry={() => tracksQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data && tracksQuery.data.items.length === 0 && (
|
{tracksToShow && tracksToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="♫"
|
icon="♫"
|
||||||
title={t('library.empty.tracks.title')}
|
title={t(
|
||||||
description={t('library.empty.tracks.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.tracks.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.tracks.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{tracksQuery.data &&
|
{tracksToShow && tracksToShow.length > 0 && (
|
||||||
tracksQuery.data.items.length > 0 &&
|
|
||||||
(() => {
|
|
||||||
const data = tracksQuery.data!;
|
|
||||||
return (
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -139,7 +192,7 @@ export function LibraryPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePlayAll(data.items)}
|
onClick={() => handlePlayAll(tracksToShow)}
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
@@ -149,37 +202,41 @@ export function LibraryPage() {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('library.playAll', { count: data.total })}
|
{t('library.playAll', { count: tracksToShow.length })}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{data.items.map((track, i) => (
|
{tracksToShow.map((track, i) => (
|
||||||
<TrackRow
|
<TrackRow key={track.id} track={track} index={i} showAlbum />
|
||||||
key={track.id}
|
|
||||||
track={track}
|
|
||||||
index={i}
|
|
||||||
showAlbum
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
})()}
|
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="albums" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{albumsQuery.isLoading && <LoadingSkeleton rows={8} height={72} />}
|
{!albumsToShow && albumsQuery.isLoading && (
|
||||||
{albumsQuery.isError && (
|
<LoadingSkeleton rows={8} height={72} />
|
||||||
|
)}
|
||||||
|
{!albumsToShow && !offline && albumsQuery.isError && (
|
||||||
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
<ErrorState onRetry={() => albumsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && albumsQuery.data.items.length === 0 && (
|
{albumsToShow && albumsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="💿"
|
icon="💿"
|
||||||
title={t('library.empty.albums.title')}
|
title={t(
|
||||||
description={t('library.empty.albums.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.albums.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.albums.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{albumsQuery.data && (
|
{albumsToShow && albumsToShow.length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
@@ -188,7 +245,7 @@ export function LibraryPage() {
|
|||||||
padding: '1.25rem 1.5rem',
|
padding: '1.25rem 1.5rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{albumsQuery.data.items.map((album) => (
|
{albumsToShow.map((album) => (
|
||||||
<AlbumCard
|
<AlbumCard
|
||||||
key={album.id}
|
key={album.id}
|
||||||
album={album}
|
album={album}
|
||||||
@@ -202,20 +259,30 @@ export function LibraryPage() {
|
|||||||
|
|
||||||
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
<TabsContent value="artists" style={{ flex: 1, overflow: 'hidden' }}>
|
||||||
<ScrollArea style={{ height: '100%' }}>
|
<ScrollArea style={{ height: '100%' }}>
|
||||||
{artistsQuery.isLoading && <LoadingSkeleton rows={8} />}
|
{!artistsToShow && artistsQuery.isLoading && (
|
||||||
{artistsQuery.isError && (
|
<LoadingSkeleton rows={8} />
|
||||||
|
)}
|
||||||
|
{!artistsToShow && !offline && artistsQuery.isError && (
|
||||||
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
<ErrorState onRetry={() => artistsQuery.refetch()} />
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && artistsQuery.data.items.length === 0 && (
|
{artistsToShow && artistsToShow.length === 0 && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon="🎤"
|
icon="🎤"
|
||||||
title={t('library.empty.artists.title')}
|
title={t(
|
||||||
description={t('library.empty.artists.description')}
|
offline
|
||||||
|
? 'library.offline.emptyTitle'
|
||||||
|
: 'library.empty.artists.title',
|
||||||
|
)}
|
||||||
|
description={t(
|
||||||
|
offline
|
||||||
|
? 'library.offline.emptyDesc'
|
||||||
|
: 'library.empty.artists.description',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{artistsQuery.data && (
|
{artistsToShow && artistsToShow.length > 0 && (
|
||||||
<div style={{ padding: '0.5rem 0' }}>
|
<div style={{ padding: '0.5rem 0' }}>
|
||||||
{artistsQuery.data.items.map((artist) => (
|
{artistsToShow.map((artist) => (
|
||||||
<ArtistRow
|
<ArtistRow
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
artist={artist}
|
artist={artist}
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ const en = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
meta: '{{albumCount}} albums · {{trackCount}} tracks',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
"You're offline — showing the library available locally. It may be incomplete and is read-only until the server is back.",
|
||||||
|
emptyTitle: 'Nothing available offline',
|
||||||
|
emptyDesc:
|
||||||
|
'No library data is cached on this device yet. Connect to the server once to browse offline.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Album',
|
type: 'Album',
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ const ru: Translations = {
|
|||||||
artistRow: {
|
artistRow: {
|
||||||
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
meta: '{{albumCount}} альб. · {{trackCount}} треков',
|
||||||
},
|
},
|
||||||
|
offline: {
|
||||||
|
banner:
|
||||||
|
'Нет связи с сервером — показана локально доступная библиотека. Она может быть неполной и доступна только для чтения, пока сервер недоступен.',
|
||||||
|
emptyTitle: 'Офлайн ничего нет',
|
||||||
|
emptyDesc:
|
||||||
|
'На этом устройстве ещё нет кэша библиотеки. Подключитесь к серверу хотя бы раз, чтобы просматривать офлайн.',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
type: 'Альбом',
|
type: 'Альбом',
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/*
|
||||||
|
* Offline library composition. When the active backend is unreachable, a single
|
||||||
|
* `getTracks` query may be `rejected` (or never matched a rehydrated arg), so we
|
||||||
|
* can't rely on it to render the library. Instead we compose the "locally
|
||||||
|
* available" library from *every* fulfilled entry in the RTK Query cache —
|
||||||
|
* last-seen lists rehydrated from localStorage (Tier 2) plus anything fetched
|
||||||
|
* this session. This is read-only derived data, not a server-data slice copy:
|
||||||
|
* it reads straight from the RTKQ cache the architecture already owns.
|
||||||
|
*/
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import type { RootState } from '../index';
|
||||||
|
import type { Album, Artist, PaginatedResponse, Track } from '../../api/types';
|
||||||
|
|
||||||
|
interface CacheEntry {
|
||||||
|
status: string;
|
||||||
|
endpointName?: string;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectQueries = (state: RootState): Record<string, unknown> =>
|
||||||
|
state.api.queries;
|
||||||
|
|
||||||
|
function fulfilled(queries: Record<string, unknown>): CacheEntry[] {
|
||||||
|
const out: CacheEntry[] = [];
|
||||||
|
for (const entry of Object.values(queries)) {
|
||||||
|
const e = entry as CacheEntry | undefined;
|
||||||
|
if (e && e.status === 'fulfilled' && e.data != null) out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every track known locally, deduped by id (last write wins). */
|
||||||
|
export const selectLocalTracks = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Track[] => {
|
||||||
|
const byId = new Map<string, Track>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getTracks':
|
||||||
|
for (const t of (e.data as PaginatedResponse<Track>).items)
|
||||||
|
byId.set(t.id, t);
|
||||||
|
break;
|
||||||
|
case 'getAlbumTracks':
|
||||||
|
case 'getArtistTracks':
|
||||||
|
for (const t of e.data as Track[]) byId.set(t.id, t);
|
||||||
|
break;
|
||||||
|
case 'getTrack':
|
||||||
|
byId.set((e.data as Track).id, e.data as Track);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Every album known locally, deduped by id. */
|
||||||
|
export const selectLocalAlbums = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Album[] => {
|
||||||
|
const byId = new Map<string, Album>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getAlbums':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Album>).items)
|
||||||
|
byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getArtistAlbums':
|
||||||
|
for (const a of e.data as Album[]) byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getAlbum':
|
||||||
|
byId.set((e.data as Album).id, e.data as Album);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Every artist known locally, deduped by id. */
|
||||||
|
export const selectLocalArtists = createSelector(
|
||||||
|
selectQueries,
|
||||||
|
(queries): Artist[] => {
|
||||||
|
const byId = new Map<string, Artist>();
|
||||||
|
for (const e of fulfilled(queries)) {
|
||||||
|
switch (e.endpointName) {
|
||||||
|
case 'getArtists':
|
||||||
|
for (const a of (e.data as PaginatedResponse<Artist>).items)
|
||||||
|
byId.set(a.id, a);
|
||||||
|
break;
|
||||||
|
case 'getArtist':
|
||||||
|
byId.set((e.data as Artist).id, e.data as Artist);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...byId.values()];
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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