Files
UHD_Media_Tool/packages/domain/test/episode-resolution.test.ts
T
rootandClaude Opus 5.5 63e94b308e feat(identify-words): 识别词——全局识别词(MP 语法)+ 作品识别词(季集换算),规则页「识别词」页签
- 新表 identify_words(0094):全局一块 + 每部作品一块原文,带版本与审计;episode_mapping_rule 转成文本行后删表
- 全局识别词移植 MP WordsMatcher(屏蔽 / 替换 / 集数偏移 / 替换+偏移 / {[tmdbid=…]}),所有按标题 / 文件名识别的地方解析前套用,命中的行记在解析结果上;保存后后台按改动行重新识别
- 作品识别词:S02 >> S01 EP+24 / S01E13 >> S00E01 / S01 @组 >> 忽略,解析器 + 打印器 + 编译;发布组繁简视为同一个
- 去掉规则自动停用:算出来对不上季表只待确认,要停用就注释掉那一行
- 集数待确认的采用 / 忽略与智能体 apply_rule 改为往作品识别词追加一行(带来历注释)
- 编辑器可视化 / 文本两种模式改同一份原文,保存前试算;作品页「集数映射」用同一个编辑器

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 17:03:00 +08:00

332 lines
15 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", 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");
});
});