feat(search): context-scoped search with filter chips and group order
Add a search scope (all/music/shows/movies) resolved from the entry route and adjustable via filter chips, threaded through the library store's search() into includeItemTypes. Results group by type in a user-configurable order, editable from settings. TRACES: UR-049 | DR-063, DR-064, DR-065; UR-050 | DR-066, DR-067
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Scoped search through the library store.
|
||||
*
|
||||
* TRACES: UR-049 | DR-065 | UT-*
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const searchMock = vi.fn();
|
||||
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: vi.fn(async () => () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
auth: {
|
||||
getRepository: () => ({ search: searchMock }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { library } from "./library";
|
||||
|
||||
function result(items: { id: string; type: string }[] = []) {
|
||||
return { items, totalRecordCount: items.length };
|
||||
}
|
||||
|
||||
describe("library.search scoping", () => {
|
||||
beforeEach(() => {
|
||||
searchMock.mockReset();
|
||||
searchMock.mockResolvedValue(result());
|
||||
library.clearSearch();
|
||||
});
|
||||
|
||||
it("omits includeItemTypes entirely for the default (all) scope", async () => {
|
||||
await library.search("office");
|
||||
|
||||
const options = searchMock.mock.calls[0][1];
|
||||
expect(options).not.toHaveProperty("includeItemTypes");
|
||||
expect(options.limit).toBe(10000);
|
||||
});
|
||||
|
||||
it("forwards music item types when scoped to music", async () => {
|
||||
await library.search("office", "music");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual([
|
||||
"MusicAlbum",
|
||||
"MusicArtist",
|
||||
"Audio",
|
||||
"Playlist",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards tv item types when scoped to tv", async () => {
|
||||
await library.search("office", "tv");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Series", "Episode"]);
|
||||
});
|
||||
|
||||
it("forwards movie item types when scoped to movies", async () => {
|
||||
await library.search("office", "movies");
|
||||
|
||||
expect(searchMock.mock.calls[0][1].includeItemTypes).toEqual(["Movie"]);
|
||||
});
|
||||
|
||||
it("stores results and the query on success", async () => {
|
||||
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
|
||||
|
||||
await library.search("office", "movies");
|
||||
|
||||
const state = get(library);
|
||||
expect(state.searchQuery).toBe("office");
|
||||
expect(state.searchResults.map((i) => i.id)).toEqual(["1"]);
|
||||
});
|
||||
|
||||
it("clears results for an empty query without hitting the repository", async () => {
|
||||
searchMock.mockResolvedValue(result([{ id: "1", type: "Movie" }]));
|
||||
await library.search("office", "movies");
|
||||
searchMock.mockClear();
|
||||
|
||||
await library.search(" ");
|
||||
|
||||
expect(searchMock).not.toHaveBeenCalled();
|
||||
const state = get(library);
|
||||
expect(state.searchQuery).toBe("");
|
||||
expect(state.searchResults).toEqual([]);
|
||||
});
|
||||
|
||||
it("discards a superseded response (stale requestId guard)", async () => {
|
||||
// First search resolves *after* a newer one has already started; its
|
||||
// results must not clobber the fresher ones.
|
||||
let resolveFirst: (value: unknown) => void = () => {};
|
||||
searchMock.mockImplementationOnce(
|
||||
() => new Promise((resolve) => (resolveFirst = resolve))
|
||||
);
|
||||
searchMock.mockResolvedValueOnce(result([{ id: "new", type: "Movie" }]));
|
||||
|
||||
const first = library.search("old", "all");
|
||||
await library.search("new", "movies");
|
||||
|
||||
resolveFirst(result([{ id: "old", type: "Audio" }]));
|
||||
await first;
|
||||
|
||||
expect(get(library).searchResults.map((i) => i.id)).toEqual(["new"]);
|
||||
});
|
||||
|
||||
it("passes an increasing requestId to the repository", async () => {
|
||||
await library.search("a");
|
||||
await library.search("b");
|
||||
|
||||
const [firstId, secondId] = searchMock.mock.calls.map((c) => c[2]);
|
||||
expect(secondId).toBeGreaterThan(firstId);
|
||||
});
|
||||
|
||||
it("surfaces a repository failure as a store error", async () => {
|
||||
searchMock.mockRejectedValue(new Error("boom"));
|
||||
|
||||
await expect(library.search("office", "tv")).rejects.toThrow("boom");
|
||||
expect(get(library).error).toBe("boom");
|
||||
expect(get(library).loadingCount).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user