import { describe, expect, it } from "vitest"; import { type EpisodeMappingRule, type ResolveEpisodesInput, type ResolveParsedInput, resolveEpisodes, type SeasonTable, validateEpisodes, } from "../src/index.ts"; // 集数解析与映射:判定流程(设计见 docs/plan/episode-resolution.md) /** 周更播出日期:从 first 起每 7 天一集 */ function weekly(first: string, count: number) { const t0 = Date.parse(`${first}T00:00:00Z`); return Array.from({ length: count }, (_, i) => ({ episode: i + 1, airDate: new Date(t0 + i * 7 * 86_400_000).toISOString().slice(0, 10), })); } function table( totals: (number | null)[], o: { ended?: boolean; latest?: number | null; air?: Record; } = {}, ): SeasonTable { return { seasons: totals.map((total, i) => ({ season: i + 1, total, ...(o.air?.[i + 1] ? { episodes: o.air[i + 1] } : {}), })), latestSeason: o.latest === undefined ? totals.length : o.latest, ended: o.ended ?? true, }; } const parsed = (p: Partial): ResolveParsedInput => ({ contentType: "unknown", ...p }); const ep = (start: number, end = start) => ({ start, end }); const run = (p: Partial, t: SeasonTable, o: Partial = {}) => resolveEpisodes({ parsed: parsed(p), kind: "series", table: t, rules: [], ...o }); describe("validateEpisodes", () => { it("季内集号通过,超出完结作品失败", () => { expect(validateEpisodes(1, ep(12), table([12]))).toBe("pass"); expect(validateEpisodes(1, ep(13), table([12]))).toBe("fail"); expect(validateEpisodes(1, ep(0), table([12]))).toBe("fail"); }); it("在播最新季超出不多(≤3)放行,超出更多失败;非最新季不放行", () => { const t = table([12, 12], { ended: false }); expect(validateEpisodes(2, ep(15), t)).toBe("unverified"); expect(validateEpisodes(2, ep(16), t)).toBe("fail"); expect(validateEpisodes(1, ep(13), t)).toBe("fail"); }); it("季总集数未知放行;新季尚未入元数据只放行开头几集", () => { expect(validateEpisodes(2, ep(30), table([12, null]))).toBe("unverified"); const t = table([12], { ended: false }); expect(validateEpisodes(2, ep(2), t)).toBe("unverified"); expect(validateEpisodes(2, ep(8), t)).toBe("fail"); expect(validateEpisodes(3, ep(1), t)).toBe("fail"); expect(validateEpisodes(1, ep(5), { seasons: [], latestSeason: null, ended: false })).toBe("unverified"); }); }); describe("resolveEpisodes 原号与跳过", () => { it("原号通过 → raw", () => { const r = run({ season: 1, episodes: ep(5) }, table([12])); expect(r.method).toBe("raw"); expect(r.canonical).toEqual({ season: 1, episodes: ep(5) }); expect(r.source).toEqual({ season: 1, episodes: ep(5) }); }); it("电影 / 非正片 / 季 0 / 无集号也无日期 → skipped", () => { expect( resolveEpisodes({ parsed: parsed({ season: 1, episodes: ep(1) }), kind: "movie", table: table([12]), rules: [] }) .method, ).toBe("skipped"); expect(run({ season: 1, episodes: ep(1), contentType: "extra" }, table([12])).method).toBe("skipped"); expect(run({ season: 0, episodes: ep(1) }, table([12])).method).toBe("skipped"); const pack = run({ season: 1 }, table([12])); expect(pack.method).toBe("skipped"); expect(pack.canonical).toBeNull(); }); }); describe("resolveEpisodes 确定性换算", () => { it("ANi `S02 13`(S1=12、S2=12 已完结)→ 季内绝对集号 S02E01", () => { const r = run({ season: 2, episodes: ep(13) }, table([12, 12])); expect(r.method).toBe("absolute_in_season"); expect(r.canonical).toEqual({ season: 2, episodes: ep(1) }); expect(r.evidence).toContain("absolute_in_season:-12"); }); it("无季号 `- 25`(S1=12、S2=13)→ 跨季绝对集号 S02E13", () => { const r = run({ season: 1, seasonInferred: true, episodes: ep(25) }, table([12, 13])); expect(r.method).toBe("absolute_cross_season"); expect(r.canonical).toEqual({ season: 2, episodes: ep(13) }); }); it("季号可信时不做跨季换算", () => { expect(run({ season: 1, episodes: ep(25) }, table([12, 13])).method).toBe("unresolved"); }); it("双集整体换算:跨季 27-28 → S02E15-E16,季内 S02 13-14 → S02E01-E02", () => { expect(run({ season: 1, seasonInferred: true, episodes: ep(27, 28) }, table([12, 24])).canonical).toEqual({ season: 2, episodes: ep(15, 16), }); expect(run({ season: 2, episodes: ep(13, 14) }, table([12, 12])).canonical).toEqual({ season: 2, episodes: ep(1, 2), }); }); it("区间跨季拆开 → 拒绝(unresolved)", () => { const r = run({ season: 1, seasonInferred: true, episodes: ep(12, 13) }, table([12, 12])); expect(r.method).toBe("unresolved"); expect(r.canonical).toBeNull(); expect(r.evidence).toContain("absolute_cross_season:split"); }); it("多解拒绝:S03E25(S1=12、S2=12、S3=24)从开头编号与接续上一季都成立 → unresolved", () => { const r = run({ season: 3, episodes: ep(25) }, table([12, 12, 24])); expect(r.method).toBe("unresolved"); expect(r.evidence.some((e) => e.startsWith("conversion:ambiguous"))).toBe(true); }); it("换算结果本身校验不通过 → unresolved", () => { expect(run({ season: 2, episodes: ep(30) }, table([12, 12])).method).toBe("unresolved"); }); }); describe("resolveEpisodes 元数据滞后与发布时间", () => { // S2 在播,元数据只列 12 集;S02E01 于 2026-01-08 播出 const airing = table([12, 12], { ended: false, air: { 2: weekly("2026-01-08", 12) } }); it("E13 发布时间远离 S02E01 播出 → 按滞后放行 unverified", () => { const r = run({ season: 2, episodes: ep(13) }, airing, { publishedAt: "2026-04-02T12:00:00Z" }); expect(r.method).toBe("unverified"); expect(r.canonical).toEqual({ season: 2, episodes: ep(13) }); expect(r.evidence).toContain("absolute_in_season:-12:published_far:84d"); }); it("E13 发布时间贴近 S02E01 播出 → absolute_in_season", () => { const r = run({ season: 2, episodes: ep(13) }, airing, { publishedAt: "2026-01-08T16:30:00Z" }); expect(r.method).toBe("absolute_in_season"); expect(r.canonical).toEqual({ season: 2, episodes: ep(1) }); expect(r.evidence).toContain("published_near:0d"); }); it("没有发布时间 → unverified", () => { expect(run({ season: 2, episodes: ep(13) }, airing).method).toBe("unverified"); }); it("季总集数未知 → unverified", () => { const r = run({ season: 2, episodes: ep(5) }, table([12, null], { ended: false })); expect(r.method).toBe("unverified"); expect(r.canonical).toEqual({ season: 2, episodes: ep(5) }); }); }); describe("resolveEpisodes 综艺按播出日期对集", () => { const variety = table([10], { ended: false, air: { 1: weekly("2026-01-03", 10) } }); const runVariety = (p: Partial, t = variety) => resolveEpisodes({ parsed: parsed(p), kind: "variety", table: t, rules: [] }); it("只有日期 → air_date 对到唯一一期", () => { const r = runVariety({ airDate: "20260117" }); expect(r.method).toBe("air_date"); expect(r.canonical).toEqual({ season: 1, episodes: ep(3) }); expect(r.source).toEqual({ airDate: "20260117" }); }); it("在播、日期对不上 → 按滞后放行(照用解析结果)", () => { const r = runVariety({ airDate: "20260118" }); expect(r.method).toBe("unverified"); expect(r.canonical).toBeNull(); }); it("没有每集播出日期 → 按滞后放行", () => { expect(runVariety({ airDate: "20260117" }, table([10], { ended: true })).method).toBe("unverified"); }); it("已完结且已知日期覆盖到这一天、对不上 → unresolved", () => { const ended = table([10], { ended: true, air: { 1: weekly("2026-01-03", 10) } }); const r = runVariety({ airDate: "20260118" }, ended); expect(r.method).toBe("unresolved"); expect(r.canonical).toBeNull(); }); it("同一天两期 → 多解拒绝", () => { const t = table([3], { air: { 1: [ { episode: 1, airDate: "2026-01-03" }, { episode: 2, airDate: "2026-01-03" }, { episode: 3, airDate: "2026-01-10" }, ], }, }); expect(runVariety({ airDate: "20260103" }, t).method).toBe("unresolved"); }); it("集号越界但日期能对上 → air_date", () => { const r = runVariety( { season: 1, episodes: ep(20), airDate: "20260124" }, table([10], { air: { 1: weekly("2026-01-03", 10) } }), ); expect(r.method).toBe("air_date"); expect(r.canonical).toEqual({ season: 1, episodes: ep(4) }); }); }); describe("resolveEpisodes 规则", () => { const rule = (o: Partial & { id: string }): EpisodeMappingRule => ({ season: null, releaseGroup: null, kind: "expression", expression: "EP", ...o, }); const ani = { name: "ANi", source: "bracket" as const }; it("优先级:季 + 发布组 > 仅季 > 仅发布组 > 都空;发布组忽略大小写", () => { const t = table([12, 12]); const rules = [ rule({ id: "none", expression: "EP+1" }), rule({ id: "group", releaseGroup: "ani", expression: "EP+2" }), rule({ id: "season", season: 2, expression: "EP+3" }), rule({ id: "both", season: 2, releaseGroup: "ANI", expression: "EP+4" }), ]; const pick = (rs: EpisodeMappingRule[], season = 2) => run({ season, episodes: ep(1), releaseGroup: ani }, t, { rules: rs }).ruleId; expect(pick(rules)).toBe("both"); expect(pick(rules.filter((r) => r.id !== "both"))).toBe("season"); expect(pick(rules.filter((r) => r.id !== "both" && r.id !== "season"))).toBe("group"); expect(pick([rules[0]!])).toBe("none"); // 季不匹配的规则不适用 expect(pick([rule({ id: "s3", season: 3 })])).toBeUndefined(); // 同级多条按 id 排序取第一条 expect(pick([rule({ id: "b", season: 2 }), rule({ id: "a", season: 2 })])).toBe("a"); }); it("expression `EP-12` + 目标季 2:无季号 13 → S02E01", () => { const r = run({ season: 1, seasonInferred: true, episodes: ep(13), releaseGroup: ani }, table([12, 12]), { rules: [rule({ id: "r1", releaseGroup: "ANi", expression: "EP-12", targetSeason: 2 })], }); expect(r).toMatchObject({ method: "rule", ruleId: "r1", canonical: { season: 2, episodes: ep(1) } }); expect(r.evidence).toContain("validate:pass"); }); it("table 规则 26 → S02E01、27-28 → S02E02、29 → S02E03", () => { const tableRule = rule({ id: "t1", kind: "table", table: [ { from: ep(26), to: { season: 2, episode: 1 } }, { from: ep(27, 28), to: { season: 2, episode: 2 } }, { from: ep(29), to: { season: 2, episode: 3 } }, ], }); const t = table([25, 12]); const map = (e: { start: number; end: number }) => run({ season: 1, seasonInferred: true, episodes: e }, t, { rules: [tableRule] }); expect(map(ep(26)).canonical).toEqual({ season: 2, episodes: ep(1) }); expect(map(ep(27, 28)).canonical).toEqual({ season: 2, episodes: ep(2) }); expect(map(ep(29)).canonical).toEqual({ season: 2, episodes: ep(3) }); expect(map(ep(26, 29)).canonical).toEqual({ season: 2, episodes: ep(1, 3) }); expect(map(ep(30))).toMatchObject({ method: "unresolved", ruleId: "t1" }); }); it("special 规则 → 返回特别篇标记,不校验", () => { const r = run({ season: 1, episodes: ep(99) }, table([12]), { rules: [rule({ id: "sp", kind: "special", special: "extra" })], }); expect(r).toMatchObject({ method: "rule", ruleId: "sp", special: "extra", canonical: null }); }); it("规则结果越界 → 待确认,记命中的规则行;不回退到确定性换算,也没有停用一说", () => { // 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则 const bad = rule({ id: "bad", expression: "EP+20", line: 3, text: "S02 >> EP+20" }); const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), { rules: [bad] }); expect(r).toMatchObject({ method: "unresolved", ruleId: "bad", ruleLine: 3, ruleText: "S02 >> EP+20" }); expect(r.evidence).toContain("rule:validate_fail"); expect(r.canonical).toBeNull(); expect("ruleFailed" in r).toBe(false); // 同一条规则对另一条资源照样生效(不会因为上一条对不上就被停用) const ok = run({ season: 2, episodes: ep(1) }, table([12, 30]), { rules: [bad] }); expect(ok).toMatchObject({ method: "rule", canonical: { season: 2, episodes: ep(21) } }); }); it("表达式非法 → 待确认,记规则", () => { const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] }); expect(r).toMatchObject({ method: "unresolved", ruleId: "x" }); }); it("发布组繁简视为同一个:綠茶字幕組 的规则命中 绿茶字幕组 的资源", () => { const r = run( { season: 3, episodes: ep(2), releaseGroup: { name: "绿茶字幕组", source: "bracket" } }, table([24, 24, 24]), { rules: [rule({ id: "g", season: 3, releaseGroup: "綠茶字幕組", expression: "EP", targetSeason: 1 })], }, ); expect(r).toMatchObject({ method: "rule", canonical: { season: 1, episodes: ep(2) } }); }); }); describe("resolveEpisodes 剧集组:按 TMDB 默认季集换算", () => { // TMDB 默认 S1 共 25 集;剧集组拆成 S1(1–12)、S2(13–25) const grouped: SeasonTable = { ...table([12, 13]), origins: Array.from({ length: 25 }, (_, k) => ({ tmdbSeason: 1, tmdbEpisode: k + 1, season: k < 12 ? 1 : 2, episode: k < 12 ? k + 1 : k - 11, })), }; it("S01E14(按 TMDB 默认季编号)→ 组内 S02E02", () => { const r = run({ season: 1, episodes: ep(14) }, grouped); expect(r).toMatchObject({ method: "tmdb_season", canonical: { season: 2, episodes: ep(2) } }); expect(r.evidence).toContain("tmdb_season:S01E14"); }); it("「- 13」季号推定:跨季绝对集号与默认季对照同解,归第 2 季第 1 集", () => { const r = run({ season: 1, seasonInferred: true, episodes: ep(13) }, grouped); expect(r.canonical).toEqual({ season: 2, episodes: ep(1) }); expect(["absolute_cross_season", "tmdb_season"]).toContain(r.method); }); it("区间跨组不换;组内号本身对得上时原号优先", () => { expect(run({ season: 1, episodes: ep(12, 13) }, grouped).method).toBe("unresolved"); expect(run({ season: 2, episodes: ep(3) }, grouped)).toMatchObject({ method: "raw" }); expect(run({ season: 1, episodes: ep(14, 15) }, grouped).canonical).toEqual({ season: 2, episodes: ep(2, 3) }); }); it("没有 origins(默认季结构)时不出这种换算", () => { const { origins: _o, ...plain } = grouped; expect(run({ season: 1, episodes: ep(14) }, plain).method).toBe("unresolved"); }); });