fix(plex): Restart only where an episode has a resume point; the Surprise draw is re-read, not redrawn

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.
This commit is contained in:
2026-10-09 22:01:28 +02:00
parent 5572499b56
commit 6df257d7df
26 changed files with 433 additions and 80 deletions
@@ -30,12 +30,23 @@ function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const play = vi.fn();
const openShow = vi.fn();
const { result } = renderHook(() => usePlayShow(play, openShow), {
wrapper: ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
),
});
return { qc, play, openShow, playShow: result.current };
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", () => {
@@ -65,6 +76,32 @@ describe("usePlayShow", () => {
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();