import { afterEach, describe, expect, test, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/svelte'; import { flushSync } from 'svelte'; import { mockInfiniteQuery } from '../test-utils/query'; import type { ArtistRef, Page } from '$lib/api/types'; const state = vi.hoisted(() => ({ pageUrl: new URL('http://localhost/') })); vi.mock('$app/state', () => ({ page: { get url() { return state.pageUrl; } } })); vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$lib/api/queries', () => ({ qk: { artists: (sort: string) => ['artists', { sort }] }, createArtistsQuery: vi.fn() })); import ArtistsPage from './+page.svelte'; import { createArtistsQuery } from '$lib/api/queries'; import { goto } from '$app/navigation'; function page(items: T[], total: number, offset = 0, limit = 50): Page { return { items, total, offset, limit }; } afterEach(() => { vi.clearAllMocks(); state.pageUrl = new URL('http://localhost/'); }); describe('artists list page', () => { test('renders a row per artist', () => { const alice: ArtistRef = { id: 'a', name: 'Alice', album_count: 3 }; const bob: ArtistRef = { id: 'b', name: 'Bob', album_count: 1 }; (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([alice, bob], 2)] }) ); render(ArtistsPage); expect(screen.getByRole('link', { name: /Alice/ })).toHaveAttribute('href', '/artists/a'); expect(screen.getByRole('link', { name: /Bob/ })).toHaveAttribute('href', '/artists/b'); }); test('renders the total count from the first page', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 1247)] }) ); render(ArtistsPage); expect(screen.getByText(/1247 artists/i)).toBeInTheDocument(); }); test('Load more button calls fetchNextPage when hasNextPage', async () => { const fetchNextPage = vi.fn(); (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 100)], hasNextPage: true, fetchNextPage }) ); render(ArtistsPage); await fireEvent.click(screen.getByRole('button', { name: /load more/i })); expect(fetchNextPage).toHaveBeenCalledTimes(1); }); test('"End of library" when hasNextPage is false and at least one page loaded', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([{ id: 'a', name: 'A', album_count: 1 }], 1)] }) ); render(ArtistsPage); expect(screen.getByText(/end of library/i)).toBeInTheDocument(); }); test('empty total renders empty-library message', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 0)] }) ); render(ArtistsPage); expect(screen.getByText(/no artists yet/i)).toBeInTheDocument(); }); test('changing the sort dropdown calls goto with ?sort=newest', async () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ pages: [page([], 0)] }) ); render(ArtistsPage); const select = screen.getByLabelText(/sort/i) as HTMLSelectElement; await fireEvent.change(select, { target: { value: 'newest' } }); expect(goto).toHaveBeenCalledWith('?sort=newest', { replaceState: true }); }); test('pending state renders the list skeleton after the useDelayed window', () => { (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ isPending: true }) ); vi.useFakeTimers(); try { render(ArtistsPage); vi.advanceTimersByTime(200); flushSync(); // flush the $effect that reads delayed.value expect(screen.getByTestId('skeleton-list')).toBeInTheDocument(); } finally { vi.useRealTimers(); } }); test('error state renders the error banner with retry', async () => { const refetch = vi.fn(); (createArtistsQuery as ReturnType).mockReturnValue( mockInfiniteQuery({ isError: true, error: { code: 'server_error', message: 'db down', status: 500 }, refetch }) ); render(ArtistsPage); expect(screen.getByRole('alert')).toHaveTextContent('db down'); await fireEvent.click(screen.getByRole('button', { name: /try again/i })); expect(refetch).toHaveBeenCalledTimes(1); }); });