Neither search backend orders by *where* the query matched, so a mid-word hit could outrank a prefix one — typing "parks" surfaced "Sparks of Love" above "Parks and Recreation". Add `domain/search_rank.rs`, which sorts by match position (prefix → word-start → mid-word substring → no name match), then by media kind so a container outranks its own contents. The sort is stable, so each backend's own relevance still breaks ties it was never overruled on. `repository_search` applies it to both the instant cache result and the merged cache+server union, so the list does not reshuffle when server results land. Ranking lives in Rust because "a better match" is domain vocabulary, not presentation. On the frontend, the combined `tvShows` result group splits into separate Shows and Episodes groups so a show no longer competes with its own episodes for a slot, and a People group is added so searching an actor's name reaches their bio. A stored `tvShows` order expands in place, keeping the position an upgrading user chose for it.
154 lines
4.4 KiB
TypeScript
154 lines
4.4 KiB
TypeScript
/**
|
|
* 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(["episodes", "shows", "movies", "songs", "albums", "artists", "people"])
|
|
);
|
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
|
expect(get(searchGroupOrder)).toEqual([
|
|
"episodes",
|
|
"shows",
|
|
"movies",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("migrates a stored `tvShows` from before the group split", async () => {
|
|
// Upgrading must keep the user's placement of TV, not append the two new
|
|
// groups at the bottom.
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(["tvShows", "movies"]));
|
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
|
expect(get(searchGroupOrder)).toEqual([
|
|
"shows",
|
|
"episodes",
|
|
"movies",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
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",
|
|
"shows",
|
|
"episodes",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
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");
|
|
// Default is shows, episodes, movies, songs, … — move movies up one.
|
|
searchGroupOrder.move("movies", -1);
|
|
|
|
const expected = [
|
|
"shows",
|
|
"movies",
|
|
"episodes",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
];
|
|
expect(get(searchGroupOrder)).toEqual(expected);
|
|
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)).toEqual(expected);
|
|
|
|
// Simulate a fresh app start reading the same storage.
|
|
vi.resetModules();
|
|
const reloaded = await import("./searchGroupOrder");
|
|
expect(get(reloaded.searchGroupOrder)).toEqual(expected);
|
|
});
|
|
|
|
it("persists a drag reorder", async () => {
|
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
|
// Drag "albums" (index 4) to the front.
|
|
searchGroupOrder.reorder(4, 0);
|
|
expect(get(searchGroupOrder)).toEqual([
|
|
"albums",
|
|
"shows",
|
|
"episodes",
|
|
"movies",
|
|
"songs",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("resets to the shipped default", async () => {
|
|
const { searchGroupOrder } = await import("./searchGroupOrder");
|
|
searchGroupOrder.move("movies", -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",
|
|
"shows",
|
|
"episodes",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
});
|