Files
siftlode/frontend/src/components/PlexCollectionView.dom.test.tsx
T
peter 4aaaaa7860 chore(comments): drop epic, sprint and review-round ids from the code
The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").

Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.

Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).

One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
2026-10-05 00:19:39 +02:00

254 lines
10 KiB
TypeScript

// @vitest-environment jsdom
//
// Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
// behaviour a review flagged as untested. A leaf member queues as itself; a show member is
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
import { setLanguage } from "../i18n";
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
beforeAll(() => {
// Poster grids / carousels wire a ResizeObserver; jsdom ships none.
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
// …and the Sort control's popover watches its anchor with an IntersectionObserver.
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// the real instance: these tests switch the language with setLanguage
const real = (await import("i18next")).default;
return {
...actual,
useTranslation: () => ({ i18n: real, t: (key: string) => key }),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
// Spy notify without dropping the module's other exports (the render subtree uses them).
const h = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
notify: (...a: unknown[]) => h.notify(...a),
}));
import PlexCollectionView from "./PlexCollectionView";
import {
api,
HttpError,
type PlexCard,
type PlexCollectionDetail,
type PlexShowDetail,
} from "../lib/api";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
h.notify.mockClear();
});
const card = (id: string, type: "movie" | "show"): PlexCard =>
({
id,
type,
title: id,
year: 2000,
duration_seconds: 3600,
thumb: `/api/plex/image/${id}`,
playable: "direct",
status: "new",
position_seconds: 0,
}) as unknown as PlexCard;
const detail = (items: PlexCard[]): PlexCollectionDetail =>
({
id: "col1",
title: "My Collection",
thumb: "/api/plex/image/col1",
year_min: null,
year_max: null,
content_rating: null,
child_count: items.length,
total_runtime_s: 0,
items,
can_edit: false,
editable: false,
}) as unknown as PlexCollectionDetail;
const showDetail = (episodeIds: string[][]): PlexShowDetail =>
({
seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })),
}) as unknown as PlexShowDetail;
function mount(items: PlexCard[], onPlay = vi.fn()) {
vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexCollectionView
collectionId="col1"
onBack={() => {}}
onPlay={onPlay}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
/>
</QueryClientProvider>,
);
return onPlay;
}
describe("PlexCollectionView — Play-all", () => {
it("queues a leaf member as itself", async () => {
const onPlay = mount([card("movie1", "movie")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]);
expect(h.notify).not.toHaveBeenCalled();
});
it("expands a show member into its episodes in season→episode order", async () => {
const spyShow = vi
.spyOn(api, "plexShow")
.mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]]));
const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(spyShow).toHaveBeenCalledWith("show1", "en");
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
});
it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => {
vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue
const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
),
);
expect(onPlay).not.toHaveBeenCalled();
});
});
// Bug hunt: the Sort control put the sort into the detail query's key, and two things
// on this page did not follow it.
describe("PlexCollectionView — after the sort joined the query key", () => {
it("flips a poster's watched state on the page it was clicked on", async () => {
// The optimistic write kept the key's OLD spelling — an entry nothing reads — and the resync
// only touches the library root, so the click did nothing visible until a reload.
// The request never answers, so the resync never runs: only the optimistic write can flip it.
vi.spyOn(api, "plexSetState").mockReturnValue(new Promise(() => {}));
mount([card("movie1", "movie")]);
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
expect(await screen.findByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
});
it("puts a toggle that failed back the way it was", async () => {
// With the flip landing on the entry the page reads, the resync is what corrects it — and it only
// touched the library root, so a failed toggle stayed "watched" beside its own error toast.
vi.spyOn(api, "plexSetState").mockRejectedValue(new HttpError(422, "nope"));
mount([card("movie1", "movie")]);
fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
expect(await screen.findByRole("button", { name: "plex.markWatched" })).toBeTruthy();
expect(screen.queryByRole("button", { name: "plex.markUnwatched" })).toBeNull();
});
it("keeps the page, and the open Sort panel, on screen while the new order loads", async () => {
// Every sort change was a key with no data yet: the whole page — the control and its open panel
// included — was swapped for a spinner, so key and direction could not be set in one visit.
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
vi.spyOn(api, "plexCollection")
.mockResolvedValueOnce(detail([card("movie1", "movie"), card("movie2", "movie")]))
.mockReturnValueOnce(new Promise(() => {})); // the re-sorted page is still on its way
mount([card("movie1", "movie"), card("movie2", "movie")]);
fireEvent.click(await screen.findByRole("button", { name: /^plex\.filter\.sort:/ }));
// Popover rows are portalled and only exist while it is open, so `hidden: true`.
const other = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
(o) => o.getAttribute("aria-checked") !== "true",
);
fireEvent.click(other!);
await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
expect(screen.queryByText("plex.loading")).toBeNull();
expect(screen.getAllByRole("menuitemradio", { hidden: true }).length).toBeGreaterThan(1);
expect(screen.getByText("plex.collection.playAll")).toBeTruthy();
});
it("does not show the previous collection while stepping to another one", async () => {
// Keeping the page on screen is for a re-sort of THIS collection only.
vi.spyOn(api, "plexCollection")
.mockResolvedValueOnce(detail([card("movie1", "movie")]))
.mockReturnValueOnce(new Promise(() => {}));
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const view = (id: string) => (
<QueryClientProvider client={qc}>
<PlexCollectionView
collectionId={id}
onBack={() => {}}
onPlay={() => {}}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
/>
</QueryClientProvider>
);
const { rerender } = render(view("col1"));
await screen.findByText("plex.collection.playAll");
rerender(view("col2"));
expect(await screen.findByText("plex.loading")).toBeTruthy();
expect(screen.queryByText("plex.collection.playAll")).toBeNull();
});
});
describe("PlexCollectionView — the reader's language", () => {
it("asks for the members, and a show it expands, in Hungarian", async () => {
setLanguage("hu");
try {
const spyShow = vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([["s1e1"]]));
const onPlay = mount([card("show1", "show")]);
fireEvent.click(await screen.findByText("plex.collection.playAll"));
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
expect(vi.mocked(api.plexCollection).mock.calls[0]?.[2]).toBe("hu");
expect(spyShow).toHaveBeenCalledWith("show1", "hu");
} finally {
setLanguage("en");
}
});
});
describe("PlexCollectionView — the summary", () => {
it("offers no More under a summary that fits", async () => {
vi.spyOn(api, "plexCollection").mockResolvedValue({
...detail([card("m1", "movie")]),
summary: "One short line.",
} as PlexCollectionDetail);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexCollectionView
collectionId="col1"
onBack={() => {}}
onPlay={vi.fn()}
onOpenCard={() => {}}
onRestartCard={() => {}}
onInfoCard={() => {}}
/>
</QueryClientProvider>,
);
await screen.findByText("One short line.");
expect(screen.queryByText("plex.textMore")).toBeNull(); // jsdom: nothing is cut
});
});