Layout and search fix
🏗️ Build and Test JellyTau / Run Tests (push) Failing after 2m4s
🏗️ Build and Test JellyTau / Android Compile Check (push) Has been skipped
Traceability Validation / Check Requirement Traces (push) Successful in 23s
Build & Release / Run Tests (push) Failing after 2m45s
Build & Release / Build Linux (push) Has been skipped
Build & Release / Build Android (push) Has been skipped
Build & Release / Create Release (push) Has been skipped

This commit is contained in:
2026-07-11 19:55:55 +02:00
parent a2cd9978f0
commit 2a1f1689b4
20 changed files with 991 additions and 995 deletions
@@ -32,7 +32,12 @@ vi.mock("$lib/composables/useServerReachabilityReload", () => ({
})),
}));
describe.skip("GenericMediaListPage", () => {
// 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();
@@ -66,6 +71,16 @@ describe.skip("GenericMediaListPage", () => {
});
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",
@@ -80,9 +95,7 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
await waitFor(() => {
// loadItems should have been called
});
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalledWith("lib123", expect.anything()));
});
it("should display sort options", () => {
@@ -111,8 +124,14 @@ describe.skip("GenericMediaListPage", () => {
});
describe("Search Functionality", () => {
it("should debounce search input for 300ms", async () => {
vi.useFakeTimers();
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,
@@ -128,29 +147,33 @@ describe.skip("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;
// 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
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" } });
// 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
});
// 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 () => {
@@ -159,8 +182,13 @@ describe.skip("GenericMediaListPage", () => {
totalRecordCount: 1,
});
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: vi.fn(),
getItems: mockGetItemsFn,
search: mockSearchFn,
};
@@ -178,25 +206,27 @@ describe.skip("GenericMediaListPage", () => {
displayComponent: "tracklist" as const,
};
vi.useFakeTimers();
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" } });
// Advance timer to trigger debounced search
vi.advanceTimersByTime(300);
// search() is called as search(query, options, requestId).
await waitFor(() => {
expect(mockSearchFn).toHaveBeenCalledWith("test", expect.objectContaining({
includeItemTypes: ["Audio"],
limit: 10000,
}));
expect(mockSearchFn).toHaveBeenCalledWith(
"test",
expect.objectContaining({
includeItemTypes: ["Audio"],
limit: 10000,
}),
expect.any(Number)
);
});
vi.useRealTimers();
});
it("should use getItems without search for empty query", async () => {
@@ -416,15 +446,18 @@ describe.skip("GenericMediaListPage", () => {
});
it("should include correct itemType in search request", async () => {
vi.useFakeTimers();
const mockSearchFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockGetItemsFn = vi.fn().mockResolvedValue({
items: [],
totalRecordCount: 0,
});
const mockRepository = {
getItems: vi.fn(),
getItems: mockGetItemsFn,
search: mockSearchFn,
};
@@ -446,17 +479,20 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
await waitFor(() => expect(mockGetItemsFn).toHaveBeenCalled());
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"],
}));
expect(mockSearchFn).toHaveBeenCalledWith(
"album",
expect.objectContaining({
includeItemTypes: ["MusicAlbum"],
}),
expect.any(Number)
);
});
vi.useRealTimers();
});
});
@@ -536,6 +572,7 @@ describe.skip("GenericMediaListPage", () => {
it("should handle missing library gracefully", async () => {
const { goto } = await import("$app/navigation");
vi.mocked(goto).mockClear();
const mockGetItemsFn = vi.fn();
@@ -548,14 +585,12 @@ describe.skip("GenericMediaListPage", () => {
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();
}
);
// 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,
@@ -571,9 +606,15 @@ describe.skip("GenericMediaListPage", () => {
props: { config },
});
// Should navigate to back path when library is missing
await waitFor(() => {
// goto would be called with config.backPath
// 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();
});
});
});