// @vitest-environment jsdom // // The up-next end-card's core behaviour: the countdown that auto-advances at ~10s (the whole // point — it REPLACED the old instant jump, so a regression that fires early or never is exactly the // bug to guard), the "Play now"/Cancel controls, and the episode heading's null season/episode // fallback. i18n is key-passthrough; `../lib/api` is mocked so no network runs; the countdown uses // performance.now, which vitest's fake timers advance alongside setInterval. import { describe, it, expect, afterEach, vi } from "vitest"; import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), }; }); vi.mock("../lib/api", () => ({ api: { plexItem: vi.fn(), plexImageUrl: () => "/img" }, })); import { UpNextCard } from "./UpNextCard"; import { api } from "../lib/api"; afterEach(() => { cleanup(); vi.useRealTimers(); vi.clearAllMocks(); }); function renderCard(opts: { onAdvance?: () => void; onCancel?: () => void } = {}) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return render( , ); } describe("UpNextCard", () => { it("auto-advances after the ~10s countdown, and NOT before", () => { vi.useFakeTimers(); // A never-resolving detail query: the heading is irrelevant here and this avoids async state // updates racing the fake clock. (api.plexItem as ReturnType).mockReturnValue(new Promise(() => {})); const onAdvance = vi.fn(); renderCard({ onAdvance }); act(() => { vi.advanceTimersByTime(9000); }); expect(onAdvance).not.toHaveBeenCalled(); // still counting down act(() => { vi.advanceTimersByTime(1200); }); expect(onAdvance).toHaveBeenCalledTimes(1); // crossed zero → advanced exactly once }); it("'Play now' advances immediately, without waiting for the countdown", () => { (api.plexItem as ReturnType).mockReturnValue(new Promise(() => {})); const onAdvance = vi.fn(); renderCard({ onAdvance }); fireEvent.click(screen.getByRole("button", { name: /playNow/ })); expect(onAdvance).toHaveBeenCalledTimes(1); }); it("Cancel dismisses via onCancel and does not advance", () => { (api.plexItem as ReturnType).mockReturnValue(new Promise(() => {})); const onAdvance = vi.fn(); const onCancel = vi.fn(); renderCard({ onAdvance, onCancel }); fireEvent.click(screen.getByText("plex.player.upNext.cancel")); expect(onCancel).toHaveBeenCalledTimes(1); expect(onAdvance).not.toHaveBeenCalled(); }); it("an episode heading falls back to S?·E? when season/episode are null", async () => { (api.plexItem as ReturnType).mockResolvedValue({ id: "e7", kind: "episode", show_title: "Westworld", title: "The Adversary", season_number: null, episode_number: null, }); renderCard(); expect(await screen.findByText("Westworld · S?·E?")).toBeTruthy(); }); });