The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh reloads a module that also exports hooks or helpers. - Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts), following navigationInit/playlistsInit. - Helpers that sat in component files have their own modules: welcomeMode, downloadTitle, plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent, ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns and the registry's page wrappers are components files of their own (channelCells, modulePages). - usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original. npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
206 lines
8.6 KiB
TypeScript
206 lines
8.6 KiB
TypeScript
// @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;
|
|
// …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")>();
|
|
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<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={() => {}}
|
|
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");
|
|
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) => (
|
|
<QueryClientProvider client={qc}>
|
|
<PlexCollectionView
|
|
collectionId={id}
|
|
onBack={() => {}}
|
|
onPlay={() => {}}
|
|
onOpenCard={() => {}}
|
|
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();
|
|
});
|
|
});
|