import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { EpisodeGroupList, EpisodeStructureSeasons } from "@umt/contracts"; import { createElement, type ReactElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { episodeMappingKeys } from "~/api/episode-mapping.ts"; import { EpisodeStructureSwitcher, SeasonCardGrid, StructureTrack, SubscriptionSeasonPicker, } from "./episode-structure.tsx"; import { hasStructureChoice, inLibraryText, originText, seasonLine, structureOptions } from "./episode-structure-model.ts"; const ENTITY = "6b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; const groups = (current: string | null): EpisodeGroupList => ({ applicable: true, current: current ? { groupId: current, name: "制作季", setAt: null, setBy: null } : null, groups: [ { id: "grp-production", name: "制作季", type: 6, typeLabel: "制作季", description: null, groupCount: 2, episodeCount: 25, network: null, }, { id: "grp-aired", name: "播出顺序", type: 1, typeLabel: "原始播出", description: null, groupCount: 1, episodeCount: 25, network: null, }, ], error: null, }); const structure: EpisodeStructureSeasons = { groupId: "grp-production", name: "制作季", current: true, seasons: [ { seasonNumber: 1, name: "第一部", episodeCount: 12, airDate: "2024-01-07", posterPath: null, inLibrary: 0, seasonUnitId: "1b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", episodes: [], }, { seasonNumber: 2, name: "第二部", episodeCount: 13, airDate: "2024-07-07", posterPath: null, inLibrary: 1, seasonUnitId: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", episodes: [ { episodeNumber: 2, name: "下篇第2话", airDate: "2024-07-14", tmdbSeason: 1, tmdbEpisode: 14, inLibrary: true, }, ], }, ], }; function withCache(el: ReactElement, list: EpisodeGroupList | null, seasons?: EpisodeStructureSeasons) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Number.POSITIVE_INFINITY } } }); if (list) qc.setQueryData(episodeMappingKeys.groups(ENTITY), list); if (seasons) qc.setQueryData(episodeMappingKeys.structure(ENTITY, seasons.groupId), seasons); return renderToStaticMarkup(createElement(QueryClientProvider, { client: qc }, el)); } describe("季集结构:纯函数", () => { it("轨道选项:TMDB 默认季在前,剧集组带类型 · 季数 · 集数,当前选择打标", () => { const opts = structureOptions(groups("grp-production")); expect(opts.map((o) => [o.groupId, o.label, o.meta, o.current])).toEqual([ [null, "TMDB 默认季", null, false], ["grp-production", "制作季", "制作季 · 2 季 · 25 集", true], ["grp-aired", "播出顺序", "原始播出 · 1 季 · 25 集", false], ]); // 当前剧集组已从 TMDB 删除:仍列出 const gone = structureOptions({ ...groups("old"), groups: [] }); expect(gone.at(-1)).toMatchObject({ groupId: "old", current: true, meta: "TMDB 上已不存在" }); expect(hasStructureChoice({ ...groups(null), groups: [] })).toBe(false); expect(hasStructureChoice(groups(null))).toBe(true); }); it("季卡片文案与原季集对照", () => { expect(seasonLine({ airDate: "2024-07-07", episodeCount: 13 })).toBe("2024 · 13 集"); expect(inLibraryText({ inLibrary: 0, episodeCount: 13 })).toBeNull(); expect(inLibraryText({ inLibrary: 3, episodeCount: 13 })).toBe("已入库 3/13"); expect(originText(2, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 14 })).toBe("原 S01E14"); expect(originText(1, { episodeNumber: 2, tmdbSeason: 1, tmdbEpisode: 2 })).toBeNull(); }); }); describe("季集结构:组件", () => { it("剧集组轨道:每项一张小卡,选中的 aria-pressed", () => { const html = renderToStaticMarkup( createElement(StructureTrack, { options: structureOptions(groups(null)), value: "grp-aired", onChange: () => {}, }), ); expect(html).toContain("TMDB 默认季"); expect(html).toContain("制作季 · 2 季 · 25 集"); expect(html).toMatch(/aria-pressed="true"[^>]*>.*播出顺序/); expect(html).toContain('aria-label="当前"'); }); it("季卡片:季名、首播年 · 集数、已入库 x/y,选中的高亮", () => { const html = renderToStaticMarkup( createElement(SeasonCardGrid, { seasons: structure.seasons, selected: 2, onPick: () => {} }), ); expect(html).toContain("第一部"); expect(html).toContain("2024 · 12 集"); expect(html).toContain("已入库 1/13"); expect(html).not.toContain("已入库 0/12"); expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected/); }); it("订阅选季:有剧集组时显示轨道 + 全部季 + 季卡片,当前结构下按季单元回显选中", () => { const html = withCache( createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "2b1f2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d", onChange: () => {}, }), groups("grp-production"), structure, ); expect(html).toContain("季集结构"); expect(html).toContain("全部季"); expect(html).toContain("第二部"); expect(html).toMatch(/aria-pressed="true"[^>]*class="[^"]*pill-selected[^"]*"[^>]*>.*?第二部/); expect(html).not.toContain("不是作品当前的季集结构"); }); it("没有剧集组时:订阅选季不出现(表单用下拉),作品页只显示原来的季与集", () => { const empty = { ...groups(null), groups: [] }; expect( withCache(createElement(SubscriptionSeasonPicker, { entityId: ENTITY, value: "", onChange: () => {} }), empty), ).toBe(""); const html = withCache( createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }), empty, ); expect(html).toBe("

原季列表

"); }); it("作品页:有剧集组时轨道在季列表上方,当前结构显示原来的季与集", () => { const html = withCache( createElement(EpisodeStructureSwitcher, { entityId: ENTITY, children: createElement("p", null, "原季列表") }), groups("grp-production"), ); expect(html).toContain("季集结构"); expect(html).toContain("原季列表"); expect(html).not.toContain("改用这套结构"); }); });