import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/svelte"; import GenericMediaListPage from "./GenericMediaListPage.svelte"; // Mock SvelteKit navigation vi.mock("$app/navigation", () => ({ goto: vi.fn(), })); // Mock stores vi.mock("$lib/stores/library", () => ({ currentLibrary: { subscribe: vi.fn((fn) => { fn({ id: "lib123", name: "Music" }); return vi.fn(); }), }, // Consumed as a store ($viewMode) by LibraryGrid, which this page renders. viewMode: { subscribe: vi.fn((fn) => { fn("grid"); return vi.fn(); }), }, })); vi.mock("$lib/stores/auth", () => ({ auth: { getRepository: vi.fn(() => ({ getItems: vi.fn(), search: vi.fn(), })), }, })); vi.mock("$lib/composables/useServerReachabilityReload", () => ({ useServerReachabilityReload: vi.fn(() => ({ markLoaded: vi.fn(), })), })); // The component lazily subscribes to the backend `search-event` when searching. vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}), })); describe("GenericMediaListPage", () => { // Component integration tests - core sorting/search/debouncing logic tested in backend-integration.test.ts beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.clearAllTimers(); }); describe("Component Initialization", () => { it("should render with title and search bar", () => { const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); const heading = screen.getByText("Tracks"); expect(heading).toBeTruthy(); const searchInput = container.querySelector('input[type="text"]'); expect(searchInput).toBeTruthy(); }); it("should load items on mount", async () => { const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({ getItems: mockGetItemsFn, search: vi.fn(), } as any); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.anything())); }); it("should display sort options", () => { const config = { itemType: "MusicAlbum" as const, title: "Albums", backPath: "/library/music", searchPlaceholder: "Search albums...", sortOptions: [ { key: "SortName", label: "Title" }, { key: "Artist", label: "Artist" }, { key: "ProductionYear", label: "Year" }, ], defaultSort: "SortName", displayComponent: "grid" as const, }; render(GenericMediaListPage, { props: { config }, }); // Check that all sort options are rendered const titleOption = screen.queryByText("Title"); expect(titleOption).toBeTruthy(); }); }); describe("Search Functionality", () => { it("should debounce rapid keystrokes into a single search for the final value", async () => { const mockSearchFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 }); const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 }); vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({ getItems: mockGetItemsFn, search: mockSearchFn, } as any); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); // Let the mount load settle so the debounce effect is armed. await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled()); // Drive the debounce window deterministically with fake timers, flushing // the async loadItems() microtasks after the timer fires. vi.useFakeTimers(); const searchInput = container.querySelector("input") as HTMLInputElement; fireEvent.input(searchInput, { target: { value: "t" } }); await vi.advanceTimersByTimeAsync(100); fireEvent.input(searchInput, { target: { value: "te" } }); await vi.advanceTimersByTimeAsync(100); fireEvent.input(searchInput, { target: { value: "tes" } }); await vi.advanceTimersByTimeAsync(100); fireEvent.input(searchInput, { target: { value: "test" } }); // 200ms after the final keystroke: still inside the 300ms window, so no // search has fired despite four keystrokes. await vi.advanceTimersByTimeAsync(200); expect(mockSearchFn).not.toHaveBeenCalled(); // Cross the threshold: exactly one search, for the final value. await vi.advanceTimersByTimeAsync(100); vi.useRealTimers(); expect(mockSearchFn).toHaveBeenCalledTimes(1); expect(mockSearchFn).toHaveBeenCalledWith("test", expect.anything(), expect.any(Number)); }); it("should use backend search when search query is provided", async () => { const mockSearchFn = vi.fn().mockResolvedValue({ items: [{ id: "item1", name: "Test Track" }], totalRecordCount: 1, }); const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: mockSearchFn, }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); // Wait for the initial mount load so the debounced search effect is armed. await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled()); const searchInput = container.querySelector("input") as HTMLInputElement; fireEvent.input(searchInput, { target: { value: "test" } }); // search() is called as search(query, options, requestId). await waitFor(() => { expect(mockSearchFn).toHaveBeenCalledWith( "test", expect.objectContaining({ includeItemTypes: ["Audio"], limit: 10000, }), expect.any(Number), ); }); }); it("should use getItems without search for empty query", async () => { const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => { expect(mockGetItemsFn).toHaveBeenCalledWith( "lib123", expect.objectContaining({ includeItemTypes: ["Audio"], sortBy: "SortName", sortOrder: "Ascending", }), ); }); }); it("should clear previous search when input becomes empty", async () => { vi.useFakeTimers(); const mockSearchFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: mockSearchFn, }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); const searchInput = container.querySelector("input") as HTMLInputElement; // Type search query fireEvent.input(searchInput, { target: { value: "test" } }); vi.advanceTimersByTime(300); // Clear search fireEvent.input(searchInput, { target: { value: "" } }); vi.advanceTimersByTime(300); await waitFor(() => { // Should call getItems when search is cleared expect(mockGetItemsFn).toHaveBeenCalled(); }); vi.useRealTimers(); }); }); describe("Sorting Functionality", () => { it("should pass sortBy parameter to backend", async () => { const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "MusicAlbum" as const, title: "Albums", backPath: "/library/music", searchPlaceholder: "Search albums...", sortOptions: [ { key: "SortName", label: "Title" }, { key: "Artist", label: "Artist" }, ], defaultSort: "SortName", displayComponent: "grid" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => { expect(mockGetItemsFn).toHaveBeenCalledWith( "lib123", expect.objectContaining({ sortBy: "SortName", sortOrder: "Ascending", }), ); }); }); it("should pass Jellyfin field names to backend (not custom compareFn)", async () => { const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [ { key: "SortName", label: "Title" }, { key: "Artist", label: "Artist" }, { key: "Album", label: "Album" }, { key: "DatePlayed", label: "Recent" }, ], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => { const lastCall = (mockGetItemsFn as any).mock.calls[0]; const options = lastCall[1]; // Should pass Jellyfin field names directly expect(typeof options.sortBy).toBe("string"); expect(["SortName", "Artist", "Album", "DatePlayed"]).toContain(options.sortBy); }); }); }); describe("ItemType Filtering", () => { it("should include correct itemType in getItems request", async () => { const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => { expect(mockGetItemsFn).toHaveBeenCalledWith( "lib123", expect.objectContaining({ includeItemTypes: ["Audio"], }), ); }); }); it("should include correct itemType in search request", async () => { const mockSearchFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockGetItemsFn = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0, }); const mockRepository = { getItems: mockGetItemsFn, search: mockSearchFn, }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "MusicAlbum" as const, title: "Albums", backPath: "/library/music", searchPlaceholder: "Search albums...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "grid" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled()); const searchInput = container.querySelector("input") as HTMLInputElement; fireEvent.input(searchInput, { target: { value: "album" } }); await waitFor(() => { expect(mockSearchFn).toHaveBeenCalledWith( "album", expect.objectContaining({ includeItemTypes: ["MusicAlbum"], }), expect.any(Number), ); }); }); }); describe("Loading State", () => { it("should show loading indicator during data fetch", async () => { const mockGetItemsFn = vi.fn( () => new Promise((resolve) => setTimeout(() => resolve({ items: [], totalRecordCount: 0 }), 100), ), ); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; vi.useFakeTimers(); const { container } = render(GenericMediaListPage, { props: { config }, }); // Component should be rendering (will show loading state internally) expect(container).toBeTruthy(); vi.advanceTimersByTime(100); vi.useRealTimers(); }); }); describe("Error Handling", () => { it("should handle backend errors gracefully", async () => { const mockGetItemsFn = vi.fn().mockRejectedValue(new Error("Network error")); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); await waitFor(() => { // Should handle error without throwing expect(mockGetItemsFn).toHaveBeenCalled(); }); }); it("should handle missing library gracefully", async () => { const { goto } = await import("$app/navigation"); vi.mocked(goto).mockClear(); const mockGetItemsFn = vi.fn(); const mockRepository = { getItems: mockGetItemsFn, search: vi.fn(), }; vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue( mockRepository as any, ); // Deliver a null current library for this test only. const currentLibrary = vi.mocked((await import("$lib/stores/library")).currentLibrary); currentLibrary.subscribe.mockImplementation((fn: any) => { fn(null); return vi.fn(); }); const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; render(GenericMediaListPage, { props: { config }, }); // With no current library, loadItems bails out to the back path and never // queries the repository. await waitFor(() => expect(goto).toHaveBeenCalledWith("/library/music")); expect(mockGetItemsFn).not.toHaveBeenCalled(); // Restore the default (non-null) library for subsequent tests. currentLibrary.subscribe.mockImplementation((fn: any) => { fn({ id: "lib123", name: "Music" }); return vi.fn(); }); }); }); describe("Display Component Props", () => { it("should support grid display component", () => { const config = { itemType: "MusicAlbum" as const, title: "Albums", backPath: "/library/music", searchPlaceholder: "Search albums...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "grid" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); expect(container).toBeTruthy(); }); it("should support tracklist display component", () => { const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, }; const { container } = render(GenericMediaListPage, { props: { config }, }); expect(container).toBeTruthy(); }); }); describe("Config Simplification", () => { it("should not require searchFields in config", () => { const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [{ key: "SortName", label: "Title" }], defaultSort: "SortName", displayComponent: "tracklist" as const, // Note: searchFields is NOT present }; // Should render without searchFields expect(() => { render(GenericMediaListPage, { props: { config }, }); }).not.toThrow(); }); it("should not require compareFn in sort options", () => { const config = { itemType: "Audio" as const, title: "Tracks", backPath: "/library/music", searchPlaceholder: "Search tracks...", sortOptions: [ { key: "SortName", label: "Title" }, // Note: no compareFn property ], defaultSort: "SortName", displayComponent: "tracklist" as const, }; // Should render without compareFn in sort options expect(() => { render(GenericMediaListPage, { props: { config }, }); }).not.toThrow(); }); }); });