diff --git a/web/src/routes/library/liked/+page.svelte b/web/src/routes/library/liked/+page.svelte new file mode 100644 index 00000000..1c722f70 --- /dev/null +++ b/web/src/routes/library/liked/+page.svelte @@ -0,0 +1,99 @@ + + +
+

Liked

+ +
+

Artists

+ {#if artistsTotal === 0} +

No liked artists yet.

+ {:else} +
+ {#each artists as a (a.id)} + + {/each} +
+ {#if artistsQuery?.hasNextPage} + + {/if} + {/if} +
+ +
+

Albums

+ {#if albumsTotal === 0} +

No liked albums yet.

+ {:else} +
+ {#each albums as al (al.id)} + + {/each} +
+ {#if albumsQuery?.hasNextPage} + + {/if} + {/if} +
+ +
+

Tracks

+ {#if tracksTotal === 0} +

No liked tracks yet.

+ {:else} +
+ {#each tracks as t, i (t.id)} + + {/each} +
+ {#if tracksQuery?.hasNextPage} + + {/if} + {/if} +
+
diff --git a/web/src/routes/library/liked/liked.test.ts b/web/src/routes/library/liked/liked.test.ts new file mode 100644 index 00000000..bcb2d29b --- /dev/null +++ b/web/src/routes/library/liked/liked.test.ts @@ -0,0 +1,102 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/svelte'; +import { mockInfiniteQuery } from '../../../test-utils/query'; +import type { ArtistRef, AlbumRef, TrackRef, Page } from '$lib/api/types'; +import { readable } from 'svelte/store'; + +vi.mock('$lib/api/likes', () => ({ + createLikedIdsQuery: () => readable({ + data: { track_ids: [], album_ids: [], artist_ids: [] }, + isPending: false, + isError: false + }), + createLikedTracksInfiniteQuery: vi.fn(), + createLikedAlbumsInfiniteQuery: vi.fn(), + createLikedArtistsInfiniteQuery: vi.fn(), + likeEntity: vi.fn(), + unlikeEntity: vi.fn() +})); + +vi.mock('@tanstack/svelte-query', async (orig) => { + const actual = (await orig()) as Record; + return { ...actual, useQueryClient: () => ({}) }; +}); + +vi.mock('$lib/api/client', () => ({ api: { get: vi.fn() } })); +vi.mock('$lib/player/store.svelte', () => ({ + playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn() +})); + +import LikedPage from './+page.svelte'; +import { + createLikedTracksInfiniteQuery, + createLikedAlbumsInfiniteQuery, + createLikedArtistsInfiniteQuery +} from '$lib/api/likes'; + +function page(items: T[], total: number, offset = 0, limit = 50): Page { + return { items, total, offset, limit }; +} + +afterEach(() => vi.clearAllMocks()); + +describe('liked library page', () => { + test('renders three sections when each has items', () => { + const ar: ArtistRef = { id: 'a1', name: 'X', album_count: 1 }; + const al: AlbumRef = { + id: 'al1', title: 'Y', artist_id: 'a1', artist_name: 'X', + year: 2020, track_count: 1, duration_sec: 100, cover_url: '/x' + }; + const tr: TrackRef = { + id: 't1', title: 'Z', album_id: 'al1', album_title: 'Y', + artist_id: 'a1', artist_name: 'X', + track_number: 1, disc_number: 1, duration_sec: 100, + stream_url: '/api/tracks/t1/stream' + }; + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([ar], 1)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([al], 1)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([tr], 1)] }) + ); + render(LikedPage); + expect(screen.getByRole('heading', { name: /artists/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /albums/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /tracks/i })).toBeInTheDocument(); + }); + + test('empty section renders "No liked X yet" hint', () => { + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(LikedPage); + expect(screen.getAllByText(/no liked/i)).toHaveLength(3); + }); + + test('Load more calls fetchNextPage on each section that has more', async () => { + const fetchTracks = vi.fn(); + (createLikedTracksInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 100)], hasNextPage: true, fetchNextPage: fetchTracks }) + ); + (createLikedAlbumsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + (createLikedArtistsInfiniteQuery as ReturnType).mockReturnValue( + mockInfiniteQuery({ pages: [page([], 0)] }) + ); + render(LikedPage); + const buttons = screen.getAllByRole('button', { name: /load more/i }); + expect(buttons).toHaveLength(1); + await fireEvent.click(buttons[0]); + expect(fetchTracks).toHaveBeenCalledTimes(1); + }); +});