Show cards carry resumable (ShowState, from the same rollup) so a part-watched show whose next episode is untouched offers Play, not a Continue and a Restart that do the same thing; plexStarted is the one rule for cards and menus. The Surprise rail's key sits under the discover root: whatever refreshes the rails re-reads the draw (keep=) with its watch state now, kept past the 5-minute gc, and only the reshuffle deals. Closing the player refreshes the Discover rails. A show card's play acts on the last click only, and only where it was made.
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
//
|
|
// A show's card plays (the owner, 2026-10-09): the click asks the server which episode, every time —
|
|
// a preview answer from before a watch elsewhere must not decide it.
|
|
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactNode } from "react";
|
|
|
|
const notified = vi.hoisted(() => vi.fn());
|
|
vi.mock("../lib/actionErrors", () => ({ notifyActionError: notified }));
|
|
vi.mock("react-i18next", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("react-i18next")>()),
|
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "hu" } }),
|
|
}));
|
|
|
|
import { api, type PlexCard } from "../lib/api";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { usePlayShow } from "./usePlayShow";
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
notified.mockReset();
|
|
});
|
|
|
|
const show: PlexCard = { id: "sh", type: "show", title: "Show", thumb: "" };
|
|
const ep = (id: string): PlexCard => ({ id, type: "episode", title: id, thumb: "" });
|
|
|
|
function mount() {
|
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
const play = vi.fn();
|
|
const openShow = vi.fn();
|
|
const { result, rerender, unmount } = renderHook(
|
|
({ here }: { here: unknown }) => usePlayShow(play, openShow, here),
|
|
{
|
|
initialProps: { here: "grid" as unknown },
|
|
wrapper: ({ children }: { children: ReactNode }) => (
|
|
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
),
|
|
},
|
|
);
|
|
return { qc, play, openShow, playShow: result.current, rerender, unmount };
|
|
}
|
|
|
|
/** Answers the test hands out one by one, in the order it chooses. */
|
|
function answers() {
|
|
const pending: ((v: { next: PlexCard | null }) => void)[] = [];
|
|
vi.spyOn(api, "plexShowNext").mockImplementation(() => new Promise((r) => void pending.push(r)));
|
|
return pending;
|
|
}
|
|
|
|
describe("usePlayShow", () => {
|
|
it("plays the episode the server names, from the start when asked to", async () => {
|
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e7") });
|
|
const { play, openShow, playShow } = mount();
|
|
await act(() => playShow(show, true));
|
|
expect(ask).toHaveBeenCalledWith("sh", "hu");
|
|
expect(play).toHaveBeenCalledWith("e7", true);
|
|
expect(openShow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("asks again rather than play what the preview cached before a watch", async () => {
|
|
const ask = vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: ep("e8") });
|
|
const { qc, play, playShow } = mount();
|
|
qc.setQueryData(qk.plexShowNext("sh", "hu"), { next: ep("e7") });
|
|
await act(() => playShow(show, false));
|
|
expect(ask).toHaveBeenCalledTimes(1);
|
|
expect(play).toHaveBeenCalledWith("e8", false);
|
|
});
|
|
|
|
it("opens the page of a show with nothing to play", async () => {
|
|
vi.spyOn(api, "plexShowNext").mockResolvedValue({ next: null });
|
|
const { play, openShow, playShow } = mount();
|
|
await act(() => playShow(show, false));
|
|
expect(openShow).toHaveBeenCalledWith("sh");
|
|
expect(play).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("acts on the last click only: an earlier, slower answer is dropped", async () => {
|
|
const pending = answers();
|
|
const { play, playShow } = mount();
|
|
const first = playShow({ ...show, id: "a" }, false);
|
|
const second = playShow({ ...show, id: "b" }, false);
|
|
await act(async () => pending[1]!({ next: ep("b1") }));
|
|
await act(async () => pending[0]!({ next: ep("a1") }));
|
|
await act(() => Promise.all([first, second]));
|
|
expect(play.mock.calls).toEqual([["b1", false]]);
|
|
});
|
|
|
|
it("drops an answer that lands after the viewer has moved on, or left", async () => {
|
|
const pending = answers();
|
|
const { play, openShow, playShow, rerender, unmount } = mount();
|
|
const moved = playShow(show, false);
|
|
rerender({ here: "info" });
|
|
await act(async () => pending[0]!({ next: ep("e1") }));
|
|
await act(() => moved);
|
|
expect(play).not.toHaveBeenCalled();
|
|
const left = playShow(show, false);
|
|
unmount();
|
|
await act(async () => pending[1]!({ next: null }));
|
|
await act(() => left);
|
|
expect(openShow).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("says so when the question fails, and opens nothing", async () => {
|
|
vi.spyOn(api, "plexShowNext").mockRejectedValue(new Error("down"));
|
|
const { play, openShow, playShow } = mount();
|
|
await act(() => playShow(show, false));
|
|
expect(notified).toHaveBeenCalledWith(expect.any(Error), "plex.playShowFailed");
|
|
expect(play).not.toHaveBeenCalled();
|
|
expect(openShow).not.toHaveBeenCalled();
|
|
});
|
|
});
|