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:
2026-07-23 20:02:15 +02:00
parent e083b53ee8
commit c175378f38
11 changed files with 1135 additions and 256 deletions
+122
View File
@@ -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);
});
});