feat(domain): 集数解析纯函数——规则、原号校验(滞后放行)、绝对集号与播出日期换算、建议规则
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
111cbddb36
commit
7b9939e0a0
13 files changed
+991
-1
No files matched your search
@@ -0,0 +1,282 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user