The grid served Hungarian titles, but every other card surface (Discover rails, the show page's episodes, search, collections, the person page, playlists, Home's resume rail, hero and comfort shelf) served English. The card serializers now take a required keyword-only lang (a forgotten call site fails instead of going English), and each of those reads takes ?lang=hu, with the language in its query key. A Hungarian search finds an episode by its Hungarian title too. An episode on a poster rail showed its 16:9 still cropped into a poster, titled only with the episode name; it now shows the show's poster and name, with S·E · title under it (option A). Plex's generic season names (Season 1, Specials) are rebuilt from the number in the reader's language; a season with a name of its own keeps it. Home's comfort-shelf move wrote its optimistic order to the key without the language, which nothing read any more; it writes the query's own key.
225 lines
9.4 KiB
TypeScript
225 lines
9.4 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 { 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")>();
|
|
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={() => {}}
|
|
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 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={() => {}}
|
|
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 (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");
|
|
}
|
|
});
|
|
});
|