// @vitest-environment jsdom // // S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states // behaviour the deep-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(); return { ...actual, useTranslation: () => ({ 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()), 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( {}} onPlay={onPlay} onOpenCard={() => {}} onRestartCard={() => {}} onInfoCard={() => {}} /> , ); 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 deep-review: 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) => ( {}} onPlay={() => {}} onOpenCard={() => {}} onRestartCard={() => {}} onInfoCard={() => {}} /> ); 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 (E30)", () => { 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"); } }); });