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
🏗️ 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:
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* 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();
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user