Files
siftlode/frontend/src/lib/usePlexCollectionTitles.dom.test.tsx
T
peter e5b155ada9 refactor(ui): reuse the store, media-query and deleted-tag rules the review found rebuilt, and name a loading pick
E32 review round 1 (F3–F6).

- Channels' open-row store and the chat dock use lib/store's createStore instead of a hand-rolled
  listener set (the chat dock gets a test of its own).
- The Plex backdrop reads prefers-reduced-motion through useMediaQuery; the query string is one
  constant (REDUCED_MOTION) for the hook and the carousel's one-off read.
- A link-restored collection filter showed its raw rating_key in the picker while the titles
  loaded: usePlexCollectionTitles now says it is pending, the picker draws a placeholder
  (labelsPending, as the channel picker does) and so does the chip row.
- The "deleted tag" rule was written twice with two i18n keys and had drifted: the channel manager
  showed NO chip for a pick while the tag list loaded, or ever after a failed load, while the pick
  still narrowed the table. One rule now (lib/tagPicks), one key (common.deletedTag); the manager's
  row shows every pick, as a placeholder while loading and as its id after a failure.
2026-10-04 02:29:03 +02:00

112 lines
4.9 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.titles["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.titles["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.titles["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.titles["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.titles["17045"]).toBeDefined());
const calls = plexCollections.mock.calls.length;
const b = renderHook(() => usePlexCollectionTitles(["17045", "68231"], undefined), {
wrapper: w,
});
await waitFor(() => expect(b.result.current.titles["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 — and it is
// told the names are on their way, so it draws a placeholder rather than the raw id.
expect(result.current.titles["17045"]).toBeUndefined();
expect(result.current.pending).toBe(true);
});
it("is not pending when nothing is missing, nor once the lookup answered", async () => {
const known = renderHook(() => usePlexCollectionTitles(["17045"], { "17045": "Known" }), {
wrapper: wrap(),
});
expect(known.result.current.pending).toBe(false);
const cold = renderHook(() => usePlexCollectionTitles(["17045"], undefined), {
wrapper: wrap(),
});
await waitFor(() => expect(cold.result.current.pending).toBe(false));
expect(cold.result.current.titles["17045"]).toBe("Pirates of the Caribbean Collection");
});
});