Files
UHD_Media_Tool/packages/domain/test/episode-resolution.test.ts
T

319 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number, { episode: number; airDate: string | null }[]>;
} = {},
): 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>): ResolveParsedInput => ({ contentType: "unknown", ...p });
const ep = (start: number, end = start) => ({ start, end });
const run = (p: Partial<ResolveParsedInput>, t: SeasonTable, o: Partial<ResolveEpisodesInput> = {}) =>
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<ResolveParsedInput>, 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<EpisodeMappingRule> & { 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", ruleFailed: "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("规则结果越界 → unresolved + ruleFailed,不回退到确定性换算", () => {
// 原号 S02E13 本可按季内绝对集号换算成 S02E01,但有规则时只认规则
const r = run({ season: 2, episodes: ep(13) }, table([12, 12]), {
rules: [rule({ id: "bad", expression: "EP+20" })],
});
expect(r.method).toBe("unresolved");
expect(r.ruleFailed).toBe("bad");
expect(r.ruleId).toBeUndefined();
expect(r.canonical).toBeNull();
});
it("表达式非法 → ruleFailed", () => {
const r = run({ season: 1, episodes: ep(1) }, table([12]), { rules: [rule({ id: "x", expression: "EP-$" })] });
expect(r).toMatchObject({ method: "unresolved", ruleFailed: "x" });
});
});
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");
});
});