Files
jellytau/src/lib/stores/searchGroupOrder.test.ts
T
dtourolle 5927299c0f feat(search): rank results by match quality and split TV/People groups
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.
2026-07-25 15:13:32 +02:00

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",
]);
});
});