Files
siftlode/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx
T
peter 889d2acf52 feat(plex): the collection page sorts, and a linked collection has a name
Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:

The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.

Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.

The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.

Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.

12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
2026-09-10 22:05:39 +02:00

98 lines
4.2 KiB
TypeScript

// @vitest-environment jsdom
//
// A shared Plex link carries the collection filter as bare rating_keys — `?pf=collections%3D17045`
// says nothing about what 17045 is called. Opened cold, the sidebar token rendered that raw id and
// the applied-filter chip could only say "Collection". This hook resolves the missing ones.
//
// Two things it must NOT do: ask the server for a title the filter state already carries (the common
// case — the user picked the collection in the sidebar), and pull the whole collection list to find
// one name (431 collections / ~200 KB on the owner's library).
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createElement, type ReactNode } from "react";
const plexCollections = vi.fn();
vi.mock("./api", () => ({ api: { plexCollections: (...a: unknown[]) => plexCollections(...a) } }));
import { usePlexCollectionTitles } from "./usePlexCollectionTitles";
const wrap = () => {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return ({ children }: { children: ReactNode }) =>
createElement(QueryClientProvider, { client: qc }, children);
};
const CATALOG: Record<string, string> = {
"17045": "Pirates of the Caribbean Collection",
"68231": "IMDb Top 250",
};
beforeEach(() => {
plexCollections.mockReset();
// Answer like the endpoint does: only the requested keys come back.
plexCollections.mockImplementation((_lib: unknown, _q: unknown, keys: string[]) =>
Promise.resolve({
collections: (keys ?? []).map((id) => ({ id, title: CATALOG[id] })),
}),
);
});
describe("usePlexCollectionTitles", () => {
it("resolves the ids the filter state has no title for", async () => {
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
wrapper: wrap(),
});
await waitFor(() =>
expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection"),
);
// Only the missing ids are asked for — nothing else.
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["17045"]);
});
it("asks for nothing when every id already has a title", async () => {
const known = { "17045": "Pirates of the Caribbean Collection" };
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], known), {
wrapper: wrap(),
});
expect(result.current["17045"]).toBe("Pirates of the Caribbean Collection");
// Give a would-be fetch a chance to happen before asserting it did not.
await new Promise((r) => setTimeout(r, 20));
expect(plexCollections).not.toHaveBeenCalled();
});
it("asks only for the missing half of a mixed selection", async () => {
const { result } = renderHook(
() => usePlexCollectionTitles(["17045", "68231"], { "17045": "Already Known" }),
{ wrapper: wrap() },
);
await waitFor(() => expect(result.current["68231"]).toBe("IMDb Top 250"));
// The known id is not in the request — and its stored title survives the merge.
expect(plexCollections).toHaveBeenCalledWith(undefined, undefined, ["68231"]);
expect(result.current["17045"]).toBe("Already Known");
});
it("treats the same ids in a different order as one query", async () => {
const w = wrap();
const a = renderHook(() => usePlexCollectionTitles(["68231", "17045"], undefined), {
wrapper: w,
});
await waitFor(() => expect(a.result.current["17045"]).toBeDefined());
const calls = plexCollections.mock.calls.length;
const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), {
wrapper: w,
});
await waitFor(() => expect(b.result.current["17045"]).toBeDefined());
expect(plexCollections.mock.calls.length).toBe(calls);
});
it("returns nothing extra while the lookup is still in flight", () => {
plexCollections.mockReturnValue(new Promise(() => {}));
const { result } = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
wrapper: wrap(),
});
// The caller falls back to its own label; it must not be handed a half-built map.
expect(result.current["17045"]).toBeUndefined();
});
});