Replace the eight inconsistent back affordances across user-facing pages with a single absolute-positioned chevron pill, so the control lives in the same screen position regardless of title length or hero content. DetailBreadcrumb keeps its textual hierarchy path but no longer owns the back chevron; PageBack does. DetailHero gains a topNav slot consumed by Movie/Series/Season/Episode/Request detail pages. Non-hero pages drop their bespoke back buttons and add PageBack inside a relative wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
417 lines
11 KiB
TypeScript
417 lines
11 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { MemoryRouter } from "react-router";
|
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
import type { ItemDetail, Season } from "@/api/types";
|
|
import EpisodeContent from "./EpisodeContent";
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
let capturedActionBarProps: Record<string, unknown> | null = null;
|
|
|
|
return {
|
|
capturedActionBarProps: {
|
|
get value() {
|
|
return capturedActionBarProps;
|
|
},
|
|
set value(value: Record<string, unknown> | null) {
|
|
capturedActionBarProps = value;
|
|
},
|
|
},
|
|
useSeasonDetail: vi.fn(),
|
|
useSeasonEpisodes: vi.fn(),
|
|
useAuth: vi.fn(),
|
|
useCurrentProfile: vi.fn(),
|
|
useRedetectEpisodeIntro: vi.fn(),
|
|
useRefreshItemMetadata: vi.fn(),
|
|
useWatchedStateMutation: vi.fn(),
|
|
useRating: vi.fn(),
|
|
useSetRating: vi.fn(),
|
|
useDeleteRating: vi.fn(),
|
|
setRatingMutate: vi.fn(),
|
|
deleteRatingMutate: vi.fn(),
|
|
startPlayback: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock("@/hooks/queries/episodes", () => ({
|
|
useSeasonDetail: mocks.useSeasonDetail,
|
|
useSeasonEpisodes: mocks.useSeasonEpisodes,
|
|
}));
|
|
|
|
vi.mock("@/hooks/useAuth", () => ({
|
|
useAuth: mocks.useAuth,
|
|
}));
|
|
|
|
vi.mock("@/hooks/useCurrentProfile", () => ({
|
|
useCurrentProfile: mocks.useCurrentProfile,
|
|
}));
|
|
|
|
vi.mock("@/playback/watchPlaybackContext", () => ({
|
|
useWatchPlaybackController: () => ({
|
|
startPlayback: mocks.startPlayback,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/hooks/queries/items", () => ({
|
|
useRedetectEpisodeIntro: mocks.useRedetectEpisodeIntro,
|
|
useRefreshItemMetadata: mocks.useRefreshItemMetadata,
|
|
useWatchedStateMutation: mocks.useWatchedStateMutation,
|
|
}));
|
|
|
|
vi.mock("@/hooks/queries/ratings", () => ({
|
|
useRating: mocks.useRating,
|
|
useSetRating: mocks.useSetRating,
|
|
useDeleteRating: mocks.useDeleteRating,
|
|
}));
|
|
|
|
vi.mock("@/components/CastCarousel", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("@/components/EditMetadataDialog", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("@/components/DownloadVersionPicker", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./DetailHero", () => ({
|
|
default: ({ context, actions }: { context?: ReactNode; actions?: ReactNode }) => (
|
|
<div>
|
|
{context}
|
|
{actions}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("./components/MetadataBadges", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./components/QualityBadges", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./components/ScoreRow", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./components/SubtitleSearchDialog", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./components/HeroCrewLine", () => ({
|
|
default: () => <div />,
|
|
}));
|
|
|
|
vi.mock("./components/ActionBar", () => ({
|
|
default: (props: Record<string, unknown>) => {
|
|
mocks.capturedActionBarProps.value = props;
|
|
return <div />;
|
|
},
|
|
}));
|
|
|
|
vi.mock("./components/EpisodeCarousel", () => ({
|
|
default: ({
|
|
episodes,
|
|
currentEpisodeNumber,
|
|
}: {
|
|
episodes: { content_id: string; episode_number: number; title: string }[];
|
|
currentEpisodeNumber: number;
|
|
}) => (
|
|
<div data-testid="episode-carousel">
|
|
{episodes.map((ep) => (
|
|
<div
|
|
key={ep.content_id}
|
|
data-episode={ep.episode_number}
|
|
data-current={ep.episode_number === currentEpisodeNumber ? "true" : undefined}
|
|
>
|
|
{ep.title}
|
|
</div>
|
|
))}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
function makeEpisodeItem(
|
|
overrides: Partial<ItemDetail & { type: "episode" }> = {},
|
|
): ItemDetail & { type: "episode" } {
|
|
return {
|
|
content_id: "episode-1",
|
|
title: "Pilot",
|
|
year: 2024,
|
|
overview: "Episode overview",
|
|
runtime: 42,
|
|
content_rating: "TV-14",
|
|
genres: [],
|
|
rating_imdb: null,
|
|
rating_tmdb: null,
|
|
rating_rt_critic: null,
|
|
rating_rt_audience: null,
|
|
imdb_id: "",
|
|
tmdb_id: "",
|
|
tvdb_id: "",
|
|
cast: [],
|
|
crew: [],
|
|
studios: [],
|
|
networks: [],
|
|
countries: [],
|
|
first_air_date: null,
|
|
last_air_date: null,
|
|
poster_url: "",
|
|
poster_thumbhash: "",
|
|
backdrop_url: "",
|
|
backdrop_thumbhash: "",
|
|
logo_url: "",
|
|
season_count: null,
|
|
series_id: "series-1",
|
|
series_title: "Example Series",
|
|
season_number: 1,
|
|
episode_number: 1,
|
|
air_date: "2024-01-01",
|
|
versions: [{ file_id: 1 } as ItemDetail["versions"][number]],
|
|
subtitles: [],
|
|
intro: null,
|
|
credits: null,
|
|
...overrides,
|
|
release_date: overrides.release_date ?? null,
|
|
type: "episode",
|
|
};
|
|
}
|
|
|
|
function makeSeason(overrides: Partial<Season> = {}): Season {
|
|
return {
|
|
content_id: "season-1",
|
|
season_number: 1,
|
|
is_specials: false,
|
|
title: "Season 1",
|
|
overview: "",
|
|
air_date: null,
|
|
episode_count: 8,
|
|
poster_url: "",
|
|
poster_thumbhash: "",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function countOccurrences(markup: string, fragment: string): number {
|
|
return markup.split(fragment).length - 1;
|
|
}
|
|
|
|
describe("EpisodeContent", () => {
|
|
beforeEach(() => {
|
|
mocks.capturedActionBarProps.value = null;
|
|
mocks.useAuth.mockReturnValue({ user: null });
|
|
mocks.useCurrentProfile.mockReturnValue({ profile: null });
|
|
mocks.useRefreshItemMetadata.mockReturnValue({
|
|
mutate: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
mocks.useRedetectEpisodeIntro.mockReturnValue({
|
|
mutate: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
mocks.useWatchedStateMutation.mockReturnValue({
|
|
mutate: vi.fn(),
|
|
isPending: false,
|
|
});
|
|
mocks.useSeasonEpisodes.mockReturnValue({
|
|
data: { episodes: [] },
|
|
});
|
|
mocks.useSeasonDetail.mockReturnValue({
|
|
data: makeSeason(),
|
|
});
|
|
mocks.useRating.mockReturnValue({ data: { rating: 3, rated_at: "2026-03-22T00:00:00Z" } });
|
|
});
|
|
|
|
it("links the season breadcrumb segment to the resolved season page", () => {
|
|
const markup = renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem()} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(countOccurrences(markup, 'href="/item/series-1"')).toBe(1);
|
|
expect(countOccurrences(markup, 'href="/item/season-1"')).toBe(1);
|
|
expect(markup).toContain(">Season 1<");
|
|
});
|
|
|
|
it("uses season navigation state before season detail finishes loading", () => {
|
|
mocks.useSeasonDetail.mockReturnValue({
|
|
data: undefined,
|
|
});
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<MemoryRouter
|
|
initialEntries={[
|
|
{
|
|
pathname: "/item/episode-1",
|
|
state: {
|
|
parentSeasonHref: "/item/season-99",
|
|
parentSeasonLabel: "Season 99",
|
|
},
|
|
},
|
|
]}
|
|
>
|
|
<EpisodeContent item={makeEpisodeItem()} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(countOccurrences(markup, 'href="/item/season-99"')).toBe(1);
|
|
expect(markup).toContain(">Season 99<");
|
|
});
|
|
|
|
it("shows all season episodes in the carousel, not just nearby ones", () => {
|
|
const allEpisodes = Array.from({ length: 10 }, (_, i) => ({
|
|
content_id: `ep-${i + 1}`,
|
|
season_number: 1,
|
|
episode_number: i + 1,
|
|
title: `Episode ${i + 1} Title`,
|
|
overview: "",
|
|
air_date: null,
|
|
runtime: 42,
|
|
still_url: "",
|
|
still_thumbhash: "",
|
|
files: [],
|
|
}));
|
|
|
|
mocks.useSeasonEpisodes.mockReturnValue({
|
|
data: { episodes: allEpisodes },
|
|
});
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem({ episode_number: 5 })} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
// All 10 episodes should be rendered
|
|
for (let i = 1; i <= 10; i++) {
|
|
expect(markup).toContain(`Episode ${i} Title`);
|
|
}
|
|
|
|
// Current episode (5) should be marked
|
|
expect(markup).toContain('data-current="true"');
|
|
expect(countOccurrences(markup, 'data-current="true"')).toBe(1);
|
|
});
|
|
|
|
it("hides the carousel when only one episode exists", () => {
|
|
mocks.useSeasonEpisodes.mockReturnValue({
|
|
data: {
|
|
episodes: [
|
|
{
|
|
content_id: "ep-1",
|
|
season_number: 1,
|
|
episode_number: 1,
|
|
title: "Only Episode",
|
|
overview: "",
|
|
air_date: null,
|
|
runtime: 42,
|
|
still_url: "",
|
|
still_thumbhash: "",
|
|
files: [],
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const markup = renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem({ episode_number: 1 })} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(markup).not.toContain("More Episodes");
|
|
expect(markup).not.toContain("episode-carousel");
|
|
});
|
|
|
|
it("passes restartHref when the episode is partially watched", () => {
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent
|
|
item={makeEpisodeItem({
|
|
user_data: {
|
|
played: false,
|
|
is_in_progress: true,
|
|
position_seconds: 300,
|
|
duration_seconds: 1800,
|
|
},
|
|
})}
|
|
/>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(mocks.capturedActionBarProps.value).toMatchObject({
|
|
playLabel: "Resume",
|
|
restartHref: "/watch/episode-1?restart=1",
|
|
});
|
|
});
|
|
|
|
it("does not pass restartHref when the episode is completed", () => {
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent
|
|
item={makeEpisodeItem({
|
|
user_data: {
|
|
played: true,
|
|
position_seconds: 1800,
|
|
duration_seconds: 1800,
|
|
},
|
|
})}
|
|
/>
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(mocks.capturedActionBarProps.value).toMatchObject({
|
|
playLabel: "Play Episode",
|
|
});
|
|
expect(mocks.capturedActionBarProps.value).toMatchObject({
|
|
restartHref: undefined,
|
|
});
|
|
});
|
|
|
|
it("does not pass rating props to ActionBar", () => {
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem()} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(mocks.capturedActionBarProps.value).not.toHaveProperty("rating");
|
|
expect(mocks.capturedActionBarProps.value).not.toHaveProperty("onRatingChange");
|
|
});
|
|
|
|
it("passes intro re-detection action only for admins", () => {
|
|
const redetect = vi.fn();
|
|
mocks.useAuth.mockReturnValue({ user: { role: "admin" } });
|
|
mocks.useRedetectEpisodeIntro.mockReturnValue({
|
|
mutate: redetect,
|
|
isPending: false,
|
|
});
|
|
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem()} />
|
|
</MemoryRouter>,
|
|
);
|
|
|
|
expect(mocks.capturedActionBarProps.value).toMatchObject({
|
|
isAdmin: true,
|
|
isRedetectingIntro: false,
|
|
});
|
|
const onRedetectIntro = mocks.capturedActionBarProps.value?.onRedetectIntro;
|
|
expect(typeof onRedetectIntro).toBe("function");
|
|
(onRedetectIntro as () => void)();
|
|
expect(redetect).toHaveBeenCalledWith("episode-1");
|
|
|
|
mocks.useAuth.mockReturnValue({ user: null });
|
|
renderToStaticMarkup(
|
|
<MemoryRouter initialEntries={["/item/episode-1"]}>
|
|
<EpisodeContent item={makeEpisodeItem()} />
|
|
</MemoryRouter>,
|
|
);
|
|
expect(mocks.capturedActionBarProps.value?.onRedetectIntro).toBeUndefined();
|
|
});
|
|
});
|