diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx new file mode 100644 index 00000000..5255e399 --- /dev/null +++ b/frontend/src/components/PlexCollectionView.dom.test.tsx @@ -0,0 +1,128 @@ +// @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 { 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; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useTranslation: () => ({ t: (key: string) => key }) }; +}); +vi.mock("./ConfirmProvider", () => ({ 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, 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={() => {}} + 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"); + 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(); + }); +});