Files
jellytau/src/lib/components/library/GenericMediaListPage.searchEvent.test.ts
T
dtourolle 532ffa661a
🏗️ Build and Test JellyTau / Run Tests (push) Successful in 4m29s
Traceability Validation / Check Requirement Traces (push) Successful in 19s
🏗️ Build and Test JellyTau / Android Compile Check (push) Successful in 5m4s
Build & Release / Run Tests (push) Successful in 4m52s
Build & Release / Build Linux (push) Successful in 17m55s
Build & Release / Build Android (push) Successful in 22m13s
Build & Release / Create Release (push) Successful in 13s
Fix tests
2026-07-11 22:09:33 +02:00

155 lines
5.6 KiB
TypeScript

/**
* Regression test: media-list search must surface server results.
*
* `repository_search` is two-phase — `repo.search()` resolves instantly with
* cache-only (downloaded) results, and the merged cache+server union arrives
* later via a `search-event`. A consumer that ignores that event only ever
* shows downloaded content, so search "finds nothing" for un-downloaded media.
*
* This test models that two-phase backend faithfully and would fail against a
* version of GenericMediaListPage that does not subscribe to `search-event`.
*
* TRACES: UR-008
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/svelte";
import GenericMediaListPage from "./GenericMediaListPage.svelte";
import type { MediaListConfig } from "./GenericMediaListPage.svelte";
vi.mock("$app/navigation", () => ({ goto: vi.fn() }));
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() },
}));
vi.mock("$lib/composables/useServerReachabilityReload", () => ({
useServerReachabilityReload: vi.fn(() => ({ markLoaded: vi.fn() })),
}));
// Capture the `search-event` handler the component registers so the test can
// drive the deferred (server) phase manually.
let searchEventHandler: ((event: { payload: unknown }) => void) | null = null;
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async (name: string, handler: (event: { payload: unknown }) => void) => {
if (name === "search-event") searchEventHandler = handler;
return () => {};
}),
}));
const ALBUM_CONFIG: MediaListConfig = {
itemType: "MusicAlbum",
title: "Albums",
backPath: "/library/music",
searchPlaceholder: "Search albums...",
sortOptions: [{ key: "SortName", label: "Title" }],
defaultSort: "SortName",
displayComponent: "grid",
};
describe("GenericMediaListPage — two-phase search", () => {
beforeEach(() => {
searchEventHandler = null;
vi.clearAllMocks();
});
it("renders server results that arrive after the cache-only phase", async () => {
// Phase 1 (synchronous) returns cache-only — empty, as it is for a user who
// has downloaded nothing. This is the exact condition that used to show
// "nothing found" even though the server has matching albums.
let capturedRequestId: number | undefined;
const search = vi.fn(async (_q: string, _opts: unknown, requestId: number) => {
capturedRequestId = requestId;
return { items: [], totalRecordCount: 0 };
});
const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems,
search,
} as any);
const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } });
// Let the initial (mount) load finish so the debounced search effect is armed.
await waitFor(() => expect(getItems).toHaveBeenCalled());
const input = container.querySelector("input") as HTMLInputElement;
fireEvent.input(input, { target: { value: "Rumours" } });
// Debounced search fires after 300ms and returns the empty cache result.
// The `search-event` listener is registered lazily as part of searching.
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// Cache-only phase: nothing to show yet (the results counter reads zero).
await waitFor(() =>
expect(screen.getByText(/0 musicalbums matching/)).toBeTruthy()
);
// Phase 2: backend emits the merged cache+server union for this request.
expect(capturedRequestId).toBeTypeOf("number");
searchEventHandler!({
payload: {
requestId: capturedRequestId,
result: {
items: [{ id: "album1", name: "Rumours", type: "MusicAlbum" }],
totalRecordCount: 1,
},
},
});
// The server result must now be reflected in the list. Old code (no
// listener) never reached this state — the count stayed at zero.
await waitFor(() =>
expect(screen.getByText(/1 musicalbum matching/)).toBeTruthy()
);
});
it("ignores a search-event whose requestId is stale", async () => {
const search = vi.fn(async () => ({ items: [], totalRecordCount: 0 }));
const getItems = vi.fn().mockResolvedValue({ items: [], totalRecordCount: 0 });
vi.mocked((await import("$lib/stores/auth")).auth.getRepository).mockReturnValue({
getItems,
search,
} as any);
const { container } = render(GenericMediaListPage, { props: { config: ALBUM_CONFIG } });
await waitFor(() => expect(getItems).toHaveBeenCalled());
const input = container.querySelector("input") as HTMLInputElement;
fireEvent.input(input, { target: { value: "Rumours" } });
await waitFor(() => expect(search).toHaveBeenCalled());
await waitFor(() => expect(searchEventHandler).not.toBeNull());
// A superseded query's late result (wrong requestId) must not render.
searchEventHandler!({
payload: {
requestId: -999,
result: {
items: [{ id: "stale", name: "Stale Album", type: "MusicAlbum" }],
totalRecordCount: 1,
},
},
});
await new Promise((r) => setTimeout(r, 0));
expect(screen.queryByText("Stale Album")).toBeNull();
});
});