// The Episode Focus View is the *only* episode surface (ux-flows §5B.1), so it // has to carry everything the bare Episode page used to: download, breadcrumbs // back to the series/season, cast and similar shows. It shipped with only Play // and Favourite, which is why "open an episode from Continue Watching" lost the // download affordance. // // TRACES: UR-048, UR-058 | DR-062, DR-142 | UT-131, UT-132, UT-133, UT-134, UT-135 import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/svelte"; import type { MediaItem } from "$lib/api/types"; const h = vi.hoisted(() => { function shim(initial: T) { let value = initial; const subs = new Set<(v: T) => void>(); return { set(v: T) { value = v; subs.forEach((fn) => fn(value)); }, subscribe(fn: (v: T) => void) { subs.add(fn); fn(value); return () => subs.delete(fn); }, }; } return { downloadsStore: shim({ downloads: {} as Record }), favoriteOverridesStore: shim(new Map()), getSimilarItems: vi.fn(async () => ({ items: [] as MediaItem[] })), search: vi.fn(async () => ({ items: [] as MediaItem[] })), }; }); vi.mock("$lib/stores/downloads", () => ({ downloads: { subscribe: h.downloadsStore.subscribe, downloadVideo: vi.fn(), pinItem: vi.fn(), unpinItem: vi.fn(), delete: vi.fn(), cancel: vi.fn(), }, })); vi.mock("$lib/stores/favorites", async () => { const actual = await vi.importActual("$lib/stores/favorites"); return { ...actual, favoriteOverrides: { subscribe: h.favoriteOverridesStore.subscribe } }; }); vi.mock("$lib/stores/auth", () => ({ auth: { getRepository: () => ({ getSimilarItems: h.getSimilarItems, search: h.search }), getUserId: () => "user-1", }, user: { subscribe: (fn: (v: unknown) => void) => (fn({ id: "user-1" }), () => {}) }, })); // CachedImage does async repo/image work irrelevant to these tests. vi.mock("$lib/components/common/CachedImage.svelte", async () => ({ default: (await import("./__mocks__/StubImage.svelte")).default, })); import EpisodeFocusView from "./EpisodeFocusView.svelte"; const SERIES: MediaItem = { id: "series-1", name: "The Show", kind: "series", genres: ["Drama"], people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }], } as unknown as MediaItem; function episode(overrides: Partial = {}): MediaItem { return { id: "ep-4", name: "The Fourth One", kind: "episode", seriesId: "series-1", seriesName: "The Show", parentIndexNumber: 2, indexNumber: 4, durationMs: 2_880_000, overview: "Something happens.", genres: ["Drama"], people: [{ id: "p-1", name: "Lead Actor", type: "Actor" }], ...overrides, } as unknown as MediaItem; } function sibling(id: string, number: number): MediaItem { return { id, name: `Episode ${number}`, kind: "episode", seriesId: "series-1", parentIndexNumber: 2, indexNumber: number, } as unknown as MediaItem; } const allEpisodes = [sibling("ep-3", 3), episode(), sibling("ep-5", 5)]; describe("EpisodeFocusView — full episode functionality (DR-142)", () => { beforeEach(() => { vi.clearAllMocks(); h.downloadsStore.set({ downloads: {} }); h.favoriteOverridesStore.set(new Map()); }); it("offers a download control in the hero", () => { render(EpisodeFocusView, { props: { episode: episode(), series: SERIES, allEpisodes }, }); expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy(); }); it("links the series name back to the series page", () => { render(EpisodeFocusView, { props: { episode: episode(), series: SERIES, allEpisodes }, }); const link = screen.getByRole("link", { name: "The Show" }); expect(link.getAttribute("href")).toBe("/library/series-1"); }); it("links the season badge to the season's place in the series list", () => { render(EpisodeFocusView, { props: { episode: episode(), series: SERIES, allEpisodes }, }); const link = screen.getByRole("link", { name: "S2E4" }); expect(link.getAttribute("href")).toBe("/library/series-1#season-2"); }); it("renders cast below the episode strip, never above it (DR-062)", () => { const { container } = render(EpisodeFocusView, { props: { episode: episode(), series: SERIES, allEpisodes }, }); const headings = [...container.querySelectorAll("h2")].map((h2) => h2.textContent?.trim()); const strip = headings.indexOf("More Episodes"); const cast = headings.findIndex((t) => t?.startsWith("Cast")); expect(strip).toBeGreaterThanOrEqual(0); expect(cast).toBeGreaterThan(strip); }); it("hides the episode strip when the episode has no siblings", () => { render(EpisodeFocusView, { props: { episode: episode(), series: SERIES, allEpisodes: [] }, }); expect(screen.queryByText("More Episodes")).toBeNull(); }); it("renders without a series for an episode that carries no seriesId", () => { render(EpisodeFocusView, { props: { episode: episode({ seriesId: null, seriesName: null }), series: null, allEpisodes: [], }, }); // Still a complete surface: title, play and download all present. expect(screen.getByText("The Fourth One")).toBeTruthy(); expect(screen.getByLabelText(/Download for offline playback/i)).toBeTruthy(); expect(screen.queryByRole("link", { name: "The Show" })).toBeNull(); }); });