Files
rootandClaude Opus 5.5 d162d464c4 feat(domain): W5-C subscription gaps, episode totals, multi-inflight slot, hard conditions and priority ladder
- resolveEpisodeTotal: totals only grow; TMDB regression floored at highest covered episode; manual lock
- computeUnitGaps / seasonProgress / unitsOverlap: missing = units ∩ range − covered − in-flight,
  season wildcard reservation, suspected-missing when total unknown
- transitionWithInflight: slot status aggregates several in-flight plans (Slot × unit reservation)
- hard conditions: excludeSources, hdr sets, codec sets, per-unit size, include/exclude regex
  (whitelist-validated), site scope, minSeeders, freeOnly (site facts via EvaluationContext)
- priority ladder (MP rule-group tiers) → pri 0-100 with per-tier trace; priority-basis upgrades,
  targetPri ceiling, upgrade modes movie/per_unit/full_pack, improvableUnits
- all evaluations carry readable trace via describeViolation

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 23:33:46 +08:00

227 lines
11 KiB
TypeScript
Raw Permalink 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 {
computeUnitGaps,
describeViolation,
evaluateCandidate,
improvableUnits,
type ParsedRelease,
parseRelease,
priorityLadder,
rankForSlot,
resolveEpisodeTotal,
type SlotSpec,
seasonProgress,
transitionWithInflight,
unitsOverlap,
validateKeywordPattern,
} from "../src/index.ts";
// W5-C 订阅增强:总集数账本、缺集、Slot×单元在途、硬条件扩展、优先级阶梯、洗版模式(对照 MP 账本 §13 样本)
const GiB = 1024 ** 3;
const MiB = 1024 ** 2;
const rel = (t: string, over: Partial<ParsedRelease> = {}): ParsedRelease => ({ ...parseRelease(t), ...over });
describe("resolveEpisodeTotal 总集数只升、回退不低于已存在最高集", () => {
it("TMDB 未公布(0)→ 未知,不当作已知总数", () => {
expect(resolveEpisodeTotal({ previous: null, fresh: 0 })).toMatchObject({ total: null, known: false });
// 只见过前 3 集:仍未知(已见 ≠ 全集)
expect(resolveEpisodeTotal({ previous: null, fresh: null, coveredMax: 3 })).toMatchObject({ known: false });
});
it("变大采用;注入值只升(样本:插件 12 > TMDB 8 → 12)", () => {
expect(resolveEpisodeTotal({ previous: 8, fresh: 10 })).toMatchObject({ total: 10, source: "tmdb", changed: true });
expect(resolveEpisodeTotal({ previous: null, fresh: 8, injected: 12 })).toMatchObject({
total: 12,
source: "injected",
});
expect(resolveEpisodeTotal({ previous: 12, fresh: 12, injected: 6 })).toMatchObject({ total: 12, changed: false });
});
it("TMDB 回退 12→10,库里已有 1–11 → 11(样本 9);回退但未覆盖更高集 → 接受新值", () => {
expect(resolveEpisodeTotal({ previous: 12, fresh: 10, coveredMax: 11 })).toMatchObject({
total: 11,
source: "covered_floor",
clampedByCovered: true,
});
expect(resolveEpisodeTotal({ previous: 12, fresh: 10, coveredMax: 4 })).toMatchObject({ total: 10 });
});
it("人工锁定后不被元数据改写", () => {
expect(resolveEpisodeTotal({ previous: 13, fresh: 20, locked: true })).toMatchObject({
total: 13,
source: "manual",
changed: false,
});
});
});
describe("computeUnitGaps / seasonProgress(媒体库已有 ∩ 范围 − 在途)", () => {
it("样本 1:12 集,库 1–4,在途 5–6,start=3 → 缺 7–12", () => {
const units = Array.from({ length: 12 }, (_, i) => `s1e${i + 1}`);
const g = computeUnitGaps({
units,
covered: ["s1e1", "s1e2", "s1e3", "s1e4"],
inflight: ["s1e5", "s1e6"],
range: { start: 3 },
});
expect(g.missing).toEqual(["s1e7", "s1e8", "s1e9", "s1e10", "s1e11", "s1e12"]);
expect(g.outOfRange).toEqual(["s1e1", "s1e2"]);
expect(g.reserved).toEqual(["s1e5", "s1e6"]);
});
it("季通配预留与同季任何集相交;不同季不相交", () => {
expect(unitsOverlap(["s1e3"], ["s1*"])).toEqual(["s1e3"]);
expect(unitsOverlap(["s1*"], ["s1e9"])).toEqual(["s1*"]);
expect(unitsOverlap(["s2e1"], ["s1*"])).toEqual([]);
expect(unitsOverlap(["uuid-a", "s1e1"], ["uuid-a"])).toEqual(["uuid-a"]);
});
it("总集数已知:缺集明确,全覆盖才完成;未知:只报疑似缺集并继续追踪", () => {
const known = seasonProgress({ season: 1, total: 4, totalKnown: true, covered: [1, 2, 4], inflight: [] });
expect(known).toMatchObject({ missing: [3], complete: false, keepTracking: true });
expect(seasonProgress({ season: 1, total: 3, totalKnown: true, covered: [1, 2, 3], inflight: [] }).complete).toBe(
true,
);
const unknown = seasonProgress({ season: 1, total: null, totalKnown: false, covered: [1, 2, 5], inflight: [4] });
expect(unknown).toMatchObject({ complete: false, keepTracking: true, missing: [], suspectedMissing: [3] });
});
});
describe("transitionWithInflight(Slot×单元多在途)", () => {
it("两个在途:一个核验通过,Slot 仍按另一个的阶段显示;最后一个通过才落到静止态", () => {
const s1 = transitionWithInflight(
{ status: "verifying", hasAsset: false },
{ type: "verify_passed", complete: false },
["downloading"],
);
expect(s1).toEqual({ status: "downloading", hasAsset: true });
const s2 = transitionWithInflight(s1!, { type: "download_completed" }, []);
expect(s2).toEqual({ status: "verifying", hasAsset: true });
expect(transitionWithInflight(s2!, { type: "verify_passed", complete: false }, [])).toEqual({
status: "empty",
hasAsset: true,
});
});
it("第二个计划开始下载:Slot 已在下载中也不报非法;静止态上的非法事件不改写", () => {
expect(
transitionWithInflight({ status: "downloading", hasAsset: false }, { type: "download_started" }, ["downloading"]),
).toEqual({ status: "downloading", hasAsset: false });
expect(transitionWithInflight({ status: "satisfied", hasAsset: true }, { type: "download_completed" }, [])).toEqual(
{ status: "verifying", hasAsset: true },
);
expect(transitionWithInflight({ status: "satisfied", hasAsset: true }, { type: "verify_failed" }, [])).toBeNull();
});
});
describe("硬条件扩展", () => {
const base: SlotSpec = { name: "x", hard: {} };
it("排除来源、HDR 集合、编码集合", () => {
const spec: SlotSpec = {
...base,
hard: { excludeSources: ["REMUX"], hdr: { noneOf: ["DV"] }, codecs: ["H.265"] },
};
const r = evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.REMUX.DV.HEVC-FGT"));
expect(r.decision).toBe("reject");
if (r.decision !== "reject") return;
expect(r.violations.map((v) => `${v.field}.${v.code}`)).toEqual(["source.excluded", "hdr.excluded"]);
expect(r.trace?.[0]).toBe("来源 REMUX 被排除");
const avc = evaluateCandidate(spec, rel("Dune.2021.1080p.WEB-DL.H264-GRP"));
expect(avc.decision === "reject" && avc.violations.map((v) => v.code)).toEqual(["not_allowed"]);
expect(
evaluateCandidate({ ...base, hard: { hdr: { anyOf: ["DV", "HDR10"] } } }, rel("X.2021.2160p.WEB-DL.HDR.H265-A"))
.decision,
).toBe("accept");
});
it("样本 12:每集体积 500MB–3GB,10 集 20GB 季包 → 每集 2GB 通过;单集 4GB 拒绝", () => {
const spec: SlotSpec = { ...base, hard: { minUnitSizeBytes: 500 * MiB, maxUnitSizeBytes: 3 * GiB } };
const pack = rel("Show.S01.1080p.WEB-DL.x264-GRP", { sizeBytes: 20 * GiB });
expect(evaluateCandidate(spec, pack, null, { unitCount: 10 }).decision).toBe("accept");
// 季包未知集数 → 字段未知(hold),不当成整包体积
expect(evaluateCandidate(spec, pack).decision).toBe("unknown_field");
const ep = evaluateCandidate(spec, rel("Show.S01E02.1080p.WEB-DL.x264-GRP", { sizeBytes: 4 * GiB }));
expect(ep.decision === "reject" && ep.violations[0]?.code).toBe("unit_too_large");
});
it("关键词包含/排除匹配 标题+副标题+标签;不安全正则拒绝", () => {
const spec: SlotSpec = { ...base, hard: { include: ["中字|简繁"], exclude: ["\\bHC\\b"] } };
const r = rel("Movie.2024.1080p.WEB-DL.x264-GRP");
expect(evaluateCandidate(spec, r).decision).toBe("reject");
expect(evaluateCandidate(spec, r, null, { subtitle: "官方中字" }).decision).toBe("accept");
expect(evaluateCandidate(spec, r, null, { subtitle: "中字", labels: ["HC"] }).decision).toBe("reject");
expect(validateKeywordPattern("(a+)+$")).toBe("nested_quantifier");
expect(validateKeywordPattern("(?<=x)y")).toBe("lookbehind");
expect(validateKeywordPattern("(a)\\1")).toBe("backreference");
const bad = evaluateCandidate({ ...base, hard: { include: ["(a+)+"] } }, r);
expect(bad.decision === "reject" && bad.violations[0]?.code).toBe("invalid_pattern");
});
it("站点范围 / 做种数 / 免费:缺上下文 → 字段未知", () => {
const spec: SlotSpec = { ...base, hard: { sites: ["site-a"], minSeeders: 5, freeOnly: true } };
const r = rel("Movie.2024.1080p.WEB-DL.x264-GRP");
expect(evaluateCandidate(spec, r).decision).toBe("unknown_field");
const ev = evaluateCandidate(spec, r, null, { siteId: "site-b", seeders: 3, downloadVolumeFactor: 1 });
expect(ev.decision === "reject" && ev.violations.map((v) => `${v.field}.${v.code}`)).toEqual([
"site.not_in_scope",
"seeders.too_few",
"promotion.not_free",
]);
expect(evaluateCandidate(spec, r, null, { siteId: "site-a", seeders: 9, downloadVolumeFactor: 0 }).decision).toBe(
"accept",
);
});
});
describe("优先级阶梯(MP 规则组 `>` 分层)", () => {
const ladder: SlotSpec = {
name: "阶梯",
hard: {},
soft: {
tiers: [
{ name: "4K REMUX", resolutions: ["2160p"], sources: ["REMUX"] },
{ name: "1080p WEB", resolutions: ["1080p"], sources: ["WEB-DL"] },
{ name: "720p", resolutions: ["720p"] },
],
tiersRequired: true,
},
upgrade: { enabled: true },
};
it("样本 11:A > B > C 命中 B → pri=99;都不命中 → 过滤;trace 逐层说明", () => {
const b = priorityLadder(ladder, rel("Show.S01E01.1080p.WEB-DL.x264-GRP"));
expect(b).toMatchObject({ pri: 99, level: 1, levelName: "1080p WEB" });
expect(b.trace[0]).toContain("分辨率 1080p 不在 [2160p]");
const none = evaluateCandidate(ladder, rel("Show.S01E01.1080p.HDTV.x264-GRP"));
expect(none.decision === "reject" && none.violations[0]?.code).toBe("no_tier_matched");
expect(none.trace?.length).toBe(4);
});
it("排序先按 pri:4K 总在 1080p 前,不看来源等级(单 Slot `4K > 1080P` 语义)", () => {
const spec: SlotSpec = {
name: "r",
hard: {},
soft: { tiers: [{ resolutions: ["2160p"] }, { resolutions: ["1080p"] }] },
};
const ranked = rankForSlot(spec, [rel("M.2024.1080p.BluRay.x264-A"), rel("M.2024.2160p.WEB-DL.H265-B")]);
expect(ranked[0]!.candidate.resolution).toBe("2160p");
expect(ranked[0]!.evaluation.decision === "accept" && ranked[0]!.evaluation.pri).toBe(100);
});
it("洗版(priority 口径):pri 严格提升才升级;达到 targetPri 视为顶档(样本 6)", () => {
const cur = { resolution: "1080p", source: "WEB-DL" } as const;
const same = evaluateCandidate(ladder, rel("Show.S01E01.1080p.WEB-DL.x265-OTHER"), cur);
expect(same.decision === "reject" && same.violations[0]?.code).toBe("not_higher_priority");
const better = evaluateCandidate(ladder, rel("Show.S01E01.2160p.BluRay.REMUX-FGT"), cur);
expect(better).toMatchObject({ decision: "upgrade", pri: 100, fromPri: 99 });
const top = evaluateCandidate(ladder, rel("Show.S01E01.2160p.BluRay.REMUX-FGT"), { ...cur, pri: 100 });
expect(top.decision === "reject" && top.violations[0]?.code).toBe("at_ceiling");
});
it("分集洗版:只取能提升的单元(样本 3);全集洗版只接受整季包", () => {
const spec: SlotSpec = { ...ladder, soft: { ...ladder.soft, tiersRequired: false } };
const r = rel("Show.S01E01-E02.1080p.WEB-DL.x264-GRP");
const out = improvableUnits(spec, r, ["s1e1", "s1e2"], {
s1e1: { resolution: "720p", source: "HDTV" },
s1e2: { resolution: "2160p", source: "REMUX" },
});
expect(out.units).toEqual(["s1e1"]);
const full: SlotSpec = { ...spec, upgrade: { enabled: true, mode: "full_pack" } };
const ep = evaluateCandidate(full, r, { resolution: "720p", source: "HDTV" });
expect(ep.decision === "reject" && ep.violations[0]?.code).toBe("not_full_pack");
});
it("describeViolation 给中文可读说明", () => {
expect(describeViolation({ field: "size", code: "too_large", expected: 2 * GiB, actual: 3 * GiB })).toBe(
"体积 3.00 GiB 超过上限 2.00 GiB",
);
});
});