import { createMockTmdb, HEART_SIGNAL_FIXTURE, type MockTmdb, TMDB_FIXTURES } from "@umt/testkit"; import { afterEach, describe, expect, it } from "vitest"; import { createTmdbClient, fetchRichDetail, fetchRichSeason, isPlaceholderEpisodeName, overviewLanguages, parseAltTitles, parseTranslations, pickChineseTitle, pickText, pickTitle, } from "./index.ts"; let mock: MockTmdb | null = null; afterEach(async () => { await mock?.close(); mock = null; }); async function setup() { mock = await createMockTmdb({ fixtures: [...TMDB_FIXTURES, HEART_SIGNAL_FIXTURE] }); const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); return { mock, tmdb }; } const CHAIN = ["zh-CN", "zh-SG", "zh-TW", "zh-HK", "en-US"]; describe("语言回退链(纯函数)", () => { const translations = parseTranslations( { translations: [ { iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "简中简介" } }, { iso_639_1: "zh", iso_3166_1: "TW", data: { name: "人生切割術", overview: "繁中簡介" } }, { iso_639_1: "en", iso_3166_1: "US", data: { name: "", overview: "English" } }, ], }, "name", ); const altTitles = parseAltTitles({ results: [ { iso_3166_1: "CN", title: "Severance CN", type: "" }, { iso_3166_1: "CN", title: "人生切割术", type: "" }, { iso_3166_1: "HK", title: "生活割離術", type: "" }, ], }); const src = { translations, altTitles, originalTitle: "Severance", originalLanguage: "en", primary: "Severance" }; it("zh-CN 译名为空时取 CN 地区的中文别名(对照 MP __update_tmdbinfo_cn_title)", () => { expect(pickTitle(CHAIN, src)).toEqual({ value: "人生切割术", language: "zh-CN", via: "alternative_title" }); expect(pickChineseTitle(CHAIN, src)?.value).toBe("人生切割术"); }); it("没有 CN 别名时按链落到 zh-TW 译名;链只有英文时英文原名", () => { const noCn = { ...src, altTitles: altTitles.filter((a) => a.region !== "CN") }; expect(pickTitle(CHAIN, noCn)).toMatchObject({ value: "人生切割術", language: "zh-TW", via: "translation" }); expect(pickTitle(["en-US"], noCn)).toMatchObject({ value: "Severance", language: "en-US", via: "original" }); expect(pickTitle(["ja-JP"], { ...noCn, originalLanguage: "ko" })).toMatchObject({ via: "primary" }); }); it("简介按链取第一个非空值", () => { expect(pickText(CHAIN, translations, "overview")).toMatchObject({ value: "简中简介", language: "zh-CN" }); expect(pickText(["zh-HK", "en-US"], translations, "overview")).toMatchObject({ value: "English" }); expect(pickText(["ja-JP"], translations, "overview", { value: null, language: null })).toBeNull(); }); it("MD-2 简介链:缺省只到中文变体;排在中文前面的语言保留;链里没有中文或允许回退时原链", () => { expect(overviewLanguages(CHAIN)).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]); expect(overviewLanguages(CHAIN, true)).toEqual(CHAIN); expect(overviewLanguages(["en-US", "zh-CN", "ja-JP"])).toEqual(["en-US", "zh-CN"]); expect(overviewLanguages(["ja-JP", "en-US"])).toEqual(["ja-JP", "en-US"]); expect(overviewLanguages(["zh", "en-US"])).toEqual(["zh"]); }); it("MD-2 简介只有英文:按中文链取不到就为空;标题仍回退到英文", () => { const enOnly = parseTranslations( { translations: [ { iso_639_1: "zh", iso_3166_1: "CN", data: { name: "", overview: "" } }, { iso_639_1: "en", iso_3166_1: "US", data: { name: "Heart Signal", overview: "English only" } }, ], }, "name", ); expect(pickText(overviewLanguages(CHAIN), enOnly, "overview")).toBeNull(); expect(pickText(CHAIN, enOnly, "overview")).toMatchObject({ value: "English only", language: "en-US" }); // 请求语言下的原值其实是英文译文(TMDB 回退):不当作 zh-CN expect( pickText(overviewLanguages(CHAIN), enOnly, "overview", { value: "English only", language: "zh-CN" }), ).toBeNull(); expect(pickTitle(CHAIN, { translations: enOnly, primary: null })).toMatchObject({ value: "Heart Signal", language: "en-US", }); }); it("占位集名识别", () => { expect(isPlaceholderEpisodeName("第 3 集", 3)).toBe(true); expect(isPlaceholderEpisodeName("Episode 3", 3)).toBe(true); expect(isPlaceholderEpisodeName("第3話")).toBe(true); expect(isPlaceholderEpisodeName("第 3 集", 4)).toBe(false); expect(isPlaceholderEpisodeName("有关地狱的好消息", 1)).toBe(false); expect(isPlaceholderEpisodeName("")).toBe(true); }); }); describe("fetchRichDetail / fetchRichSeason(mock TMDB)", () => { it("Severance:标题取 CN 别名,中文名 zh-CN,简介 zh-CN,主创与外部 ID", async () => { const { tmdb, mock } = await setup(); const d = await fetchRichDetail(tmdb, "tv", 95396, CHAIN); expect(d.title).toBe("人生切割术"); expect(d.titleLanguage).toBe("zh-CN"); expect(d.titleZh).toBe("人生切割术"); expect(d.overview).toBe("故事发生在一家名为卢蒙的超级大公司内。"); expect(d.overviewLanguage).toBe("zh-CN"); expect(d.genres).toEqual(["剧情", "悬疑", "Sci-Fi & Fantasy"]); expect(d.voteAverage).toBe(8.4); expect(d.status).toBe("Returning Series"); expect(d.networks).toEqual(["Apple TV"]); expect(d.cast[0]).toEqual({ id: 4495, name: "亚当·斯科特", originalName: null, role: "Mark Scout", profilePath: "/adam.jpg", }); expect(d.crew[0]).toMatchObject({ name: "丹·埃里克森", role: "Creator" }); expect(d.externalIds).toEqual({ imdb: "tt11280740", tvdb: "371980", wikidata: null }); expect(d.seasons.map((s) => s.seasonNumber)).toEqual([1, 2]); // 一次请求(credits 等随 append_to_response) expect(mock.calls.filter((r) => r.path === "/3/tv/95396")).toHaveLength(1); }); it("链首 en-US:英文原名、英文简介,中文名仍按中文链取", async () => { const { tmdb } = await setup(); const d = await fetchRichDetail(tmdb, "tv", 95396, ["en-US"]); expect(d.title).toBe("Severance"); expect(d.overview).toBe("Mark leads a team of office workers."); expect(d.titleZh).toBe("人生切割术"); }); it("季集:首选语言缺标题 / 简介的已播集按链补拉;未播出的集不触发补拉", async () => { const { tmdb, mock } = await setup(); const s = await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); expect(s.name).toBe("第 1 季"); expect(s.overview).toContain("卢蒙"); const [e1, e2, e3] = s.episodes; expect(e1).toMatchObject({ name: "有关地狱的好消息", nameLanguage: "zh-CN", overview: "马克升职成为部门主管。", runtime: 59, stillPath: "/sev-101.jpg", voteAverage: 8.2, voteCount: 171, }); expect(e2).toMatchObject({ name: "半循環", nameLanguage: "zh-TW", overview: "馬克帶領新員工參觀。" }); // 链走完仍没有中文名:en-US 标题;简介各语言都空 expect(e3).toMatchObject({ name: "In Perpetuity", nameLanguage: "en-US", overview: null }); expect(s.fetchedLanguages).toEqual(CHAIN); const perLang = mock.calls.filter((r) => r.path === "/3/tv/95396/season/1").map((r) => r.query.language); expect(perLang).toEqual(CHAIN); mock.calls.length = 0; const s2 = await fetchRichSeason(tmdb, 95396, 2, CHAIN, { now: new Date("2026-01-01") }); expect(s2.episodes[0]).toMatchObject({ name: "第 1 集", nameLanguage: null }); expect(s2.fetchedLanguages).toEqual(["zh-CN"]); }); it("MD-2《心动的信号》:作品与每集简介只有英文 → 留空;标题照常回退;允许非中文时恢复英文", async () => { const { tmdb, mock } = await setup(); const d = await fetchRichDetail(tmdb, "tv", 103147, CHAIN); expect(d.title).toBe("心动的信号"); expect(d.overview).toBeNull(); expect(d.overviewLanguage).toBeNull(); const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") }); const [e1, e2, e3] = s.episodes; expect(e1).toMatchObject({ name: "心动不如行动,大胆亮出底牌!", nameLanguage: "zh-CN", overview: null }); expect(e1!.overviewLanguage).toBeNull(); // 标题:zh-CN 是占位名,回退到 en-US;简介仍不回退 expect(e2).toMatchObject({ name: "Who Is the One?", nameLanguage: "en-US", overview: null }); expect(e3).toMatchObject({ name: "约会之夜", overview: "約會之夜。", overviewLanguage: "zh-TW" }); // 补拉 en-US 只为了标题 expect(s.fetchedLanguages).toEqual(CHAIN); const any = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01"), overviewNonChineseFallback: true, }); expect(any.episodes[0]).toMatchObject({ overview: "If Your Heart Skips a Beat, Play Your Cards Openly!", overviewLanguage: "en-US", }); const anyD = await fetchRichDetail(tmdb, "tv", 103147, CHAIN, { overviewNonChineseFallback: true }); expect(anyD).toMatchObject({ overview: "A Chinese dating reality show.", overviewLanguage: "en-US" }); expect(mock.calls.length).toBeGreaterThan(0); }); it("MD-2 标题都齐、只缺简介时,中文变体走完就停,不为简介补拉英文", async () => { const fx = { ...HEART_SIGNAL_FIXTURE, seasons: [ { ...HEART_SIGNAL_FIXTURE.seasons![0]!, overviews: { "zh-CN": "第一季。" }, episodes: [HEART_SIGNAL_FIXTURE.seasons![0]!.episodes[0]!], }, ], }; mock = await createMockTmdb({ fixtures: [fx] }); const tmdb = createTmdbClient({ baseUrl: `${mock.url}/3`, apiKey: "test-tmdb-key", retries: 0, timeoutMs: 1000 }); const s = await fetchRichSeason(tmdb, 103147, 1, CHAIN, { now: new Date("2026-01-01") }); expect(s.episodes[0]!.overview).toBeNull(); expect(s.fetchedLanguages).toEqual(["zh-CN", "zh-SG", "zh-TW", "zh-HK"]); }); it("补拉走请求缓存:同一季第二次不再请求", async () => { const { tmdb, mock } = await setup(); await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); const n = mock.calls.length; await fetchRichSeason(tmdb, 95396, 1, CHAIN, { now: new Date("2026-01-01") }); expect(mock.calls.length).toBe(n); }); });