- 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]>
227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
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",
|
||
);
|
||
});
|
||
});
|