import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/svelte'; import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js'; import { makeTrack } from '#test-utils/fixtures/track.js'; import { emptyLikesMock } from '../../../test-utils/mocks/likes'; import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine'; const mkTrack = (id: string) => makeTrack({ id: `track-${id}`, title: `Song ${id}` }); const mkEvent = (id: string, playedAt: Date): HistoryEvent => ({ id: `event-${id}`, played_at: playedAt.toISOString(), track: mkTrack(id) }); let mockData: { pages: HistoryResponse[] } = { pages: [] }; let mockHasNextPage = false; let mockIsPending = false; let mockIsError = false; let mockIsFetchingNextPage = false; const mockFetchNextPage = vi.fn(); const mockRefetch = vi.fn(); vi.mock('#lib/api/history.js', async () => { const actual = await vi.importActual( '#lib/api/history.js' ); return { ...actual, createHistoryQuery: () => ({ subscribe: (run: (v: unknown) => void) => { run({ data: mockData, isPending: mockIsPending, isError: mockIsError, error: mockIsError ? new Error('test') : null, hasNextPage: mockHasNextPage, isFetchingNextPage: mockIsFetchingNextPage, fetchNextPage: mockFetchNextPage, refetch: mockRefetch }); return () => {}; } }) }; }); // Rows are TrackListRows, so LikeButton and TrackMenu render too. vi.mock('#lib/player/store.svelte.js', () => ({ playQueue: vi.fn(), playNext: vi.fn(), enqueueTrack: vi.fn(), playRadio: vi.fn(), player: { current: undefined } })); vi.mock('#lib/api/likes.js', () => emptyLikesMock()); vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock()); vi.mock('#lib/branding.js', () => ({ pageTitle: (s: string) => `Minstrel ยท ${s}` })); // Imported after the mocks: the component pulls in LikeButton, whose // mocked module must already be initialised. import HistoryPage from './+page.svelte'; describe('library/history page', () => { beforeEach(() => { mockData = { pages: [] }; mockHasNextPage = false; mockIsPending = false; mockIsError = false; mockIsFetchingNextPage = false; mockFetchNextPage.mockClear(); mockRefetch.mockClear(); }); it('renders Today and Yesterday section headers across page boundary', () => { const today = new Date(); const yesterday = new Date(); yesterday.setDate(yesterday.getDate() - 1); mockData = { pages: [ { events: [mkEvent('a', today), mkEvent('b', today)], has_more: true }, { events: [mkEvent('c', yesterday)], has_more: false } ] }; render(HistoryPage); expect(screen.getByText('Today')).toBeInTheDocument(); expect(screen.getByText('Yesterday')).toBeInTheDocument(); expect(screen.getByText('Song a')).toBeInTheDocument(); expect(screen.getByText('Song b')).toBeInTheDocument(); expect(screen.getByText('Song c')).toBeInTheDocument(); }); it('renders empty state when query data is empty', () => { mockData = { pages: [{ events: [], has_more: false }] }; render(HistoryPage); expect(screen.getByText(/no listening history yet/i)).toBeInTheDocument(); }); it('renders error banner on isError', () => { mockIsError = true; render(HistoryPage); // ApiErrorBanner renders a role="alert" container. Assert by role // rather than by message text, since the message comes from the // error object's `.message` property and varies. expect(screen.getByRole('alert')).toBeInTheDocument(); }); it('does not render the InfiniteScrollSentinel when hasNextPage is false', () => { mockData = { pages: [{ events: [mkEvent('a', new Date())], has_more: false }] }; mockHasNextPage = false; const { container } = render(HistoryPage); // InfiniteScrollSentinel renders