Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
448 lines
15 KiB
TypeScript
448 lines
15 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
composeSearchGroups,
|
|
DEFAULT_GROUP_ORDER,
|
|
groupsForScope,
|
|
isSearchRoute,
|
|
moveGroup,
|
|
normalizeGroupOrder,
|
|
parseSearchScope,
|
|
reorderGroups,
|
|
resolveSearchScope,
|
|
searchRouteUrl,
|
|
seedFromSearchUrl,
|
|
shouldNavigateToSearch,
|
|
type SearchGroupId,
|
|
} from "./searchScope";
|
|
|
|
describe("resolveSearchScope", () => {
|
|
it("scopes music routes to music", () => {
|
|
expect(resolveSearchScope("/library/music")).toBe("music");
|
|
expect(resolveSearchScope("/library/music/albums")).toBe("music");
|
|
expect(resolveSearchScope("/library/music/artists")).toBe("music");
|
|
expect(resolveSearchScope("/library/music/genres")).toBe("music");
|
|
expect(resolveSearchScope("/library/music/playlists")).toBe("music");
|
|
expect(resolveSearchScope("/library/music/tracks")).toBe("music");
|
|
});
|
|
|
|
it("scopes movie routes to movies", () => {
|
|
expect(resolveSearchScope("/library/movies")).toBe("movies");
|
|
expect(resolveSearchScope("/library/movies/all")).toBe("movies");
|
|
expect(resolveSearchScope("/library/movies/genres")).toBe("movies");
|
|
});
|
|
|
|
it("scopes tv routes to tv", () => {
|
|
expect(resolveSearchScope("/library/tv")).toBe("tv");
|
|
expect(resolveSearchScope("/library/tv/shows")).toBe("tv");
|
|
});
|
|
|
|
it("treats /library/shows as tv", () => {
|
|
// The TV genre page lives under `shows`, not `tv`.
|
|
expect(resolveSearchScope("/library/shows/genres")).toBe("tv");
|
|
expect(resolveSearchScope("/library/shows")).toBe("tv");
|
|
});
|
|
|
|
it("falls back to all for home, library root, search and unknown routes", () => {
|
|
expect(resolveSearchScope("/")).toBe("all");
|
|
expect(resolveSearchScope("/library")).toBe("all");
|
|
expect(resolveSearchScope("/search")).toBe("all");
|
|
expect(resolveSearchScope("/settings")).toBe("all");
|
|
expect(resolveSearchScope("/downloads")).toBe("all");
|
|
expect(resolveSearchScope("/library/abc123")).toBe("all");
|
|
expect(resolveSearchScope("/nonsense/route")).toBe("all");
|
|
});
|
|
|
|
it("tolerates trailing slashes, query strings and hashes", () => {
|
|
expect(resolveSearchScope("/library/music/")).toBe("music");
|
|
expect(resolveSearchScope("/library/tv?foo=1")).toBe("tv");
|
|
expect(resolveSearchScope("/library/movies#top")).toBe("movies");
|
|
expect(resolveSearchScope("")).toBe("all");
|
|
});
|
|
|
|
it("does not match a prefix that is only a partial segment", () => {
|
|
expect(resolveSearchScope("/library/musicvideos")).toBe("all");
|
|
});
|
|
});
|
|
|
|
// NOTE: the former `scopeItemTypes` suite moved to Rust — see
|
|
// `search_scope_tests` in src-tauri/src/repository/types.rs. The scope →
|
|
// item-type expansion is domain vocabulary and is no longer reachable from the
|
|
// frontend, so testing it here would mean re-introducing the leak to test it.
|
|
// The "fresh array" test is gone because `item_types()` returns an owned Vec,
|
|
// making the aliasing bug it guarded structurally impossible.
|
|
|
|
describe("normalizeGroupOrder", () => {
|
|
it("returns the default for missing or non-array input", () => {
|
|
expect(normalizeGroupOrder(null)).toEqual([...DEFAULT_GROUP_ORDER]);
|
|
expect(normalizeGroupOrder(undefined)).toEqual([...DEFAULT_GROUP_ORDER]);
|
|
expect(normalizeGroupOrder("nonsense")).toEqual([...DEFAULT_GROUP_ORDER]);
|
|
expect(normalizeGroupOrder({})).toEqual([...DEFAULT_GROUP_ORDER]);
|
|
});
|
|
|
|
it("drops ids that no longer exist", () => {
|
|
expect(normalizeGroupOrder(["movies", "podcasts", "songs"])).toEqual([
|
|
"movies",
|
|
"songs",
|
|
"shows",
|
|
"episodes",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("appends groups a stored order does not mention", () => {
|
|
// A user upgrading from a build with fewer groups must not lose the new ones.
|
|
expect(normalizeGroupOrder(["movies", "songs"])).toEqual([
|
|
"movies",
|
|
"songs",
|
|
"shows",
|
|
"episodes",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("de-duplicates repeated ids", () => {
|
|
expect(normalizeGroupOrder(["songs", "songs", "movies"])).toEqual([
|
|
"songs",
|
|
"movies",
|
|
"shows",
|
|
"episodes",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("preserves a complete valid order unchanged", () => {
|
|
const order: SearchGroupId[] = [
|
|
"episodes",
|
|
"shows",
|
|
"movies",
|
|
"artists",
|
|
"albums",
|
|
"songs",
|
|
"people",
|
|
];
|
|
expect(normalizeGroupOrder(order)).toEqual(order);
|
|
});
|
|
|
|
it("expands a stored `tvShows` into shows + episodes in place", () => {
|
|
// Migration: the old combined group split, and a user who put TV first
|
|
// must still get TV first rather than appended at the bottom.
|
|
expect(normalizeGroupOrder(["tvShows", "movies"])).toEqual([
|
|
"shows",
|
|
"episodes",
|
|
"movies",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("groupsForScope", () => {
|
|
it("returns every group in saved order for the all scope", () => {
|
|
expect(
|
|
groupsForScope("all", [
|
|
"movies",
|
|
"songs",
|
|
"shows",
|
|
"episodes",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]),
|
|
).toEqual(["movies", "songs", "shows", "episodes", "albums", "artists", "people"]);
|
|
});
|
|
|
|
it("keeps only in-scope groups, in saved order", () => {
|
|
const order: SearchGroupId[] = [
|
|
"artists",
|
|
"movies",
|
|
"albums",
|
|
"episodes",
|
|
"shows",
|
|
"songs",
|
|
"people",
|
|
];
|
|
expect(groupsForScope("music", order)).toEqual(["artists", "albums", "songs"]);
|
|
expect(groupsForScope("movies", order)).toEqual(["movies"]);
|
|
expect(groupsForScope("tv", order)).toEqual(["episodes", "shows"]);
|
|
});
|
|
|
|
it("surfaces people only under the all scope", () => {
|
|
// Cast/crew cut across music, film and TV, so no narrow scope claims them.
|
|
expect(groupsForScope("all", DEFAULT_GROUP_ORDER)).toContain("people");
|
|
expect(groupsForScope("music", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
|
expect(groupsForScope("tv", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
|
expect(groupsForScope("movies", DEFAULT_GROUP_ORDER)).not.toContain("people");
|
|
});
|
|
});
|
|
|
|
describe("composeSearchGroups", () => {
|
|
const results = [
|
|
{ id: "1", type: "Audio" },
|
|
{ id: "2", type: "MusicAlbum" },
|
|
{ id: "3", type: "Movie" },
|
|
{ id: "4", type: "Series" },
|
|
{ id: "5", type: "Episode" },
|
|
{ id: "6", type: "Person" },
|
|
];
|
|
|
|
it("renders groups in the configured order", () => {
|
|
const groups = composeSearchGroups(results, "all", [
|
|
"shows",
|
|
"episodes",
|
|
"movies",
|
|
"songs",
|
|
"albums",
|
|
"artists",
|
|
"people",
|
|
]);
|
|
expect(groups.map((g) => g.id)).toEqual([
|
|
"shows",
|
|
"episodes",
|
|
"movies",
|
|
"songs",
|
|
"albums",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("puts shows ahead of episodes by default", () => {
|
|
// Searching a show's name should surface the show itself first, not an
|
|
// arbitrary episode of it.
|
|
const ids = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id);
|
|
expect(ids).toEqual(["shows", "episodes"]);
|
|
});
|
|
|
|
it("omits empty groups", () => {
|
|
// No artists in the fixture, so the artists group never renders.
|
|
const groups = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
|
expect(groups.map((g) => g.id)).not.toContain("artists");
|
|
});
|
|
|
|
it("drops out-of-scope groups", () => {
|
|
expect(composeSearchGroups(results, "music", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
|
"songs",
|
|
"albums",
|
|
]);
|
|
expect(composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER).map((g) => g.id)).toEqual([
|
|
"shows",
|
|
"episodes",
|
|
]);
|
|
});
|
|
|
|
it("separates series and episodes into their own groups", () => {
|
|
const groups = composeSearchGroups(results, "tv", DEFAULT_GROUP_ORDER);
|
|
expect(groups.find((g) => g.id === "shows")?.items.map((i) => i.id)).toEqual(["4"]);
|
|
expect(groups.find((g) => g.id === "episodes")?.items.map((i) => i.id)).toEqual(["5"]);
|
|
});
|
|
|
|
it("surfaces people so an actor search reaches their bio", () => {
|
|
// Person items were previously returned by the backend and silently dropped.
|
|
const all = composeSearchGroups(results, "all", DEFAULT_GROUP_ORDER);
|
|
expect(all.find((g) => g.id === "people")?.items.map((i) => i.id)).toEqual(["6"]);
|
|
});
|
|
|
|
it("ignores item types that belong to no group", () => {
|
|
const withFolder = [...results, { id: "7", type: "CollectionFolder" }];
|
|
const all = composeSearchGroups(withFolder, "all", DEFAULT_GROUP_ORDER);
|
|
expect(all.flatMap((g) => g.items).map((i) => i.id)).not.toContain("7");
|
|
});
|
|
|
|
it("narrowing then widening restores the full arrangement", () => {
|
|
// Scope is a filter over the saved order, never a rewrite of it.
|
|
const order: SearchGroupId[] = [
|
|
"shows",
|
|
"songs",
|
|
"movies",
|
|
"albums",
|
|
"artists",
|
|
"episodes",
|
|
"people",
|
|
];
|
|
const wide = composeSearchGroups(results, "all", order).map((g) => g.id);
|
|
composeSearchGroups(results, "music", order);
|
|
expect(composeSearchGroups(results, "all", order).map((g) => g.id)).toEqual(wide);
|
|
expect(wide).toEqual(["shows", "songs", "movies", "albums", "episodes", "people"]);
|
|
});
|
|
|
|
it("survives a stored order containing an unknown id", () => {
|
|
const groups = composeSearchGroups(results, "all", [
|
|
"podcasts",
|
|
"movies",
|
|
] as unknown as SearchGroupId[]);
|
|
expect(groups.map((g) => g.id)).toEqual([
|
|
"movies",
|
|
"shows",
|
|
"episodes",
|
|
"songs",
|
|
"albums",
|
|
"people",
|
|
]);
|
|
});
|
|
|
|
it("handles items with a missing type", () => {
|
|
const groups = composeSearchGroups(
|
|
[{ id: "x", type: null }, { id: "y" }] as { id: string; type?: string | null }[],
|
|
"all",
|
|
DEFAULT_GROUP_ORDER,
|
|
);
|
|
expect(groups).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("moveGroup", () => {
|
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
|
|
|
it("moves a group up", () => {
|
|
expect(moveGroup(order, "artists", -1)).toEqual([
|
|
"songs",
|
|
"artists",
|
|
"albums",
|
|
"movies",
|
|
"shows",
|
|
]);
|
|
});
|
|
|
|
it("moves a group down", () => {
|
|
expect(moveGroup(order, "songs", 1)).toEqual(["albums", "songs", "artists", "movies", "shows"]);
|
|
});
|
|
|
|
it("is a no-op at the boundaries", () => {
|
|
expect(moveGroup(order, "songs", -1)).toEqual(order);
|
|
expect(moveGroup(order, "shows", 1)).toEqual(order);
|
|
});
|
|
|
|
it("is a no-op for an unknown id", () => {
|
|
expect(moveGroup(order, "podcasts" as SearchGroupId, 1)).toEqual(order);
|
|
});
|
|
|
|
it("does not mutate the input", () => {
|
|
const input = [...order];
|
|
moveGroup(input, "songs", 1);
|
|
expect(input).toEqual(order);
|
|
});
|
|
});
|
|
|
|
describe("reorderGroups", () => {
|
|
const order: SearchGroupId[] = ["songs", "albums", "artists", "movies", "shows"];
|
|
|
|
it("moves an item from one index to another", () => {
|
|
expect(reorderGroups(order, 0, 4)).toEqual(["albums", "artists", "movies", "shows", "songs"]);
|
|
expect(reorderGroups(order, 4, 0)).toEqual(["shows", "songs", "albums", "artists", "movies"]);
|
|
});
|
|
|
|
it("is a no-op for equal or out-of-range indices", () => {
|
|
expect(reorderGroups(order, 2, 2)).toEqual(order);
|
|
expect(reorderGroups(order, -1, 2)).toEqual(order);
|
|
expect(reorderGroups(order, 0, 9)).toEqual(order);
|
|
});
|
|
});
|
|
|
|
describe("searchRouteUrl", () => {
|
|
it("encodes the query and the scope", () => {
|
|
expect(searchRouteUrl("miles davis", "music")).toBe("/search?q=miles%20davis&scope=music");
|
|
});
|
|
|
|
it("omits the scope key for the default `all` scope", () => {
|
|
expect(searchRouteUrl("dune", "all")).toBe("/search?q=dune");
|
|
});
|
|
|
|
it("targets bare /search for an empty query so the page shows its empty state", () => {
|
|
expect(searchRouteUrl("", "all")).toBe("/search");
|
|
expect(searchRouteUrl(" ", "music")).toBe("/search");
|
|
});
|
|
});
|
|
|
|
describe("shouldNavigateToSearch", () => {
|
|
it("navigates from any library page, which cannot render results itself", () => {
|
|
// The bug: the header search bar shows on every /library/** route but only
|
|
// /library rendered $library.searchResults, so typing did nothing on
|
|
// /library/music, /library/tv, /library/movies and detail pages.
|
|
expect(shouldNavigateToSearch("/library", "jazz")).toBe(true);
|
|
expect(shouldNavigateToSearch("/library/music", "jazz")).toBe(true);
|
|
expect(shouldNavigateToSearch("/library/tv", "jazz")).toBe(true);
|
|
expect(shouldNavigateToSearch("/library/movies", "jazz")).toBe(true);
|
|
expect(shouldNavigateToSearch("/library/abc123", "jazz")).toBe(true);
|
|
});
|
|
|
|
it("stays put when already on /search, so typing does not re-push history", () => {
|
|
expect(shouldNavigateToSearch("/search", "jazz")).toBe(false);
|
|
expect(shouldNavigateToSearch("/search?q=old", "jazz")).toBe(false);
|
|
});
|
|
|
|
it("does not navigate on an empty query", () => {
|
|
expect(shouldNavigateToSearch("/library/music", "")).toBe(false);
|
|
expect(shouldNavigateToSearch("/library/music", " ")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isSearchRoute", () => {
|
|
it("recognises the search surface through query strings and trailing slashes", () => {
|
|
expect(isSearchRoute("/search")).toBe(true);
|
|
expect(isSearchRoute("/search/")).toBe(true);
|
|
expect(isSearchRoute("/search?q=jazz&scope=music")).toBe(true);
|
|
expect(isSearchRoute("/library/music")).toBe(false);
|
|
expect(isSearchRoute("/")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseSearchScope", () => {
|
|
it("accepts the known scopes", () => {
|
|
expect(parseSearchScope("music")).toBe("music");
|
|
expect(parseSearchScope("tv")).toBe("tv");
|
|
});
|
|
|
|
it("falls back on absent or unrecognised values", () => {
|
|
expect(parseSearchScope(null)).toBe("all");
|
|
expect(parseSearchScope("")).toBe("all");
|
|
expect(parseSearchScope("books")).toBe("all");
|
|
expect(parseSearchScope(null, "music")).toBe("music");
|
|
});
|
|
});
|
|
|
|
describe("seedFromSearchUrl", () => {
|
|
const params = (search: string) => new URL(`http://x/search${search}`).searchParams;
|
|
|
|
it("seeds a fresh page from the URL", () => {
|
|
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), null)).toEqual({
|
|
query: "jazz",
|
|
scope: "music",
|
|
});
|
|
});
|
|
|
|
it("seeds an empty query so an emptied URL clears the page", () => {
|
|
expect(seedFromSearchUrl(params(""), null)).toEqual({ query: "", scope: "all" });
|
|
expect(seedFromSearchUrl(params(""), { query: "jazz", scope: "all" })).toEqual({
|
|
query: "",
|
|
scope: "all",
|
|
});
|
|
});
|
|
|
|
it("asks for nothing once that same URL has been applied", () => {
|
|
// The regression this exists for: the page must consume the URL once, not
|
|
// keep re-asserting it. While the seed is unchanged the user's typing and
|
|
// chip picks own the input — re-applying snapped it back a keystroke later.
|
|
const applied = { query: "jazz", scope: "music" } as const;
|
|
expect(seedFromSearchUrl(params("?q=jazz&scope=music"), applied)).toBeNull();
|
|
});
|
|
|
|
it("re-seeds when the query or the scope actually changes", () => {
|
|
const applied = { query: "jazz", scope: "music" } as const;
|
|
expect(seedFromSearchUrl(params("?q=blues&scope=music"), applied)).toEqual({
|
|
query: "blues",
|
|
scope: "music",
|
|
});
|
|
expect(seedFromSearchUrl(params("?q=jazz"), applied)).toEqual({
|
|
query: "jazz",
|
|
scope: "all",
|
|
});
|
|
});
|
|
});
|