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
+136
View File
@@ -0,0 +1,136 @@
/**
* Persisted search result group order.
*
* TRACES: UR-050 | DR-066 | UT-*
*/
import { describe, it, expect, beforeEach, beforeAll, afterAll, vi } from "vitest";
import { get } from "svelte/store";
import { DEFAULT_GROUP_ORDER } from "$lib/utils/searchScope";
const STORAGE_KEY = "jellytau-search-group-order";
// This jsdom setup doesn't expose localStorage, so stand in a minimal
// implementation — the store only uses getItem/setItem.
const store = new Map<string, string>();
const localStorage = {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, value),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
};
beforeAll(() => {
vi.stubGlobal("localStorage", localStorage);
});
afterAll(() => {
vi.unstubAllGlobals();
});
describe("searchGroupOrder", () => {
beforeEach(() => {
vi.resetModules();
localStorage.clear();
});
it("starts at the shipped default with nothing stored", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("loads a stored order", async () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(["tvShows", "movies", "songs", "albums", "artists"])
);
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"movies",
"songs",
"albums",
"artists",
]);
});
it("appends groups a partial stored order does not mention", async () => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(["movies"]));
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
it("falls back to the default on corrupt stored JSON", async () => {
localStorage.setItem(STORAGE_KEY, "{not json");
const { searchGroupOrder } = await import("./searchGroupOrder");
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("persists a move so the order survives a restart", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("movies", -1);
expect(get(searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
// Simulate a fresh app start reading the same storage.
vi.resetModules();
const reloaded = await import("./searchGroupOrder");
expect(get(reloaded.searchGroupOrder)).toEqual([
"songs",
"albums",
"movies",
"artists",
"tvShows",
]);
});
it("persists a drag reorder", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.reorder(4, 0);
expect(get(searchGroupOrder)).toEqual([
"tvShows",
"songs",
"albums",
"artists",
"movies",
]);
});
it("resets to the shipped default", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.move("tvShows", -1);
searchGroupOrder.reset();
expect(get(searchGroupOrder)).toEqual([...DEFAULT_GROUP_ORDER]);
});
it("normalizes an explicitly set order", async () => {
const { searchGroupOrder } = await import("./searchGroupOrder");
searchGroupOrder.set(["movies", "podcasts"] as never);
expect(get(searchGroupOrder)).toEqual([
"movies",
"songs",
"albums",
"artists",
"tvShows",
]);
});
});