many changes
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
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();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
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(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe("GenericMediaListPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
describe("Component Initialization", () => {
|
||||
it("should render with title and search bar", () => {
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
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 config = {
|
||||
itemType: "Audio",
|
||||
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(() => {
|
||||
// loadItems should have been called
|
||||
});
|
||||
});
|
||||
|
||||
it("should display sort options", () => {
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
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 search input for 300ms", async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
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 into search
|
||||
fireEvent.input(searchInput, { target: { value: "t" } });
|
||||
expect(searchInput.value).toBe("t");
|
||||
|
||||
// Search should not trigger immediately
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Add more characters
|
||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
||||
|
||||
// Still shouldn't trigger (only 100ms passed total)
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
// Now advance to 300ms total - search should trigger
|
||||
vi.advanceTimersByTime(100);
|
||||
|
||||
await waitFor(() => {
|
||||
// Search should have been debounced
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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 mockRepository = {
|
||||
getItems: vi.fn(),
|
||||
search: mockSearchFn,
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
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 },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.input(searchInput, { target: { value: "test" } });
|
||||
|
||||
// Advance timer to trigger debounced search
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
|
||||
includeItemTypes: ["Audio"],
|
||||
limit: 10000,
|
||||
}));
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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 () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const mockSearchFn = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
totalRecordCount: 0,
|
||||
});
|
||||
|
||||
const mockRepository = {
|
||||
getItems: vi.fn(),
|
||||
search: mockSearchFn,
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
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 },
|
||||
});
|
||||
|
||||
const searchInput = container.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.input(searchInput, { target: { value: "album" } });
|
||||
vi.advanceTimersByTime(300);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSearchFn).toHaveBeenCalledWith("album", expect.objectContaining({
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
}));
|
||||
});
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
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",
|
||||
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");
|
||||
|
||||
const mockGetItemsFn = vi.fn();
|
||||
|
||||
const mockRepository = {
|
||||
getItems: mockGetItemsFn,
|
||||
search: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue(
|
||||
mockRepository as any
|
||||
);
|
||||
|
||||
// Mock currentLibrary to return null
|
||||
vi.resetModules();
|
||||
vi.mocked((await import("$lib/stores/library")).currentLibrary.subscribe).mockImplementation(
|
||||
(fn: any) => {
|
||||
fn(null);
|
||||
return vi.fn();
|
||||
}
|
||||
);
|
||||
|
||||
const config = {
|
||||
itemType: "Audio",
|
||||
title: "Tracks",
|
||||
backPath: "/library/music",
|
||||
searchPlaceholder: "Search tracks...",
|
||||
sortOptions: [{ key: "SortName", label: "Title" }],
|
||||
defaultSort: "SortName",
|
||||
displayComponent: "tracklist" as const,
|
||||
};
|
||||
|
||||
render(GenericMediaListPage, {
|
||||
props: { config },
|
||||
});
|
||||
|
||||
// Should navigate to back path when library is missing
|
||||
await waitFor(() => {
|
||||
// goto would be called with config.backPath
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Display Component Props", () => {
|
||||
it("should support grid display component", () => {
|
||||
const config = {
|
||||
itemType: "MusicAlbum",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user