many changes
Traceability Validation / Check Requirement Traces (push) Failing after 1m18s
🏗️ Build and Test JellyTau / Build APK and Run Tests (push) Has been cancelled

This commit is contained in:
2026-02-14 00:09:47 +01:00
parent 6d1c618a3a
commit e3797f32ca
74 changed files with 6718 additions and 771 deletions
@@ -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();
});
});
});