🏗️ 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
155 lines
5.6 KiB
TypeScript
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();
|
|
});
|
|
});
|