- 新表 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
332 lines
15 KiB
TypeScript
332 lines
15 KiB
TypeScript
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");
|
||
});
|
||
});
|