- release-parser: title/file/dir parsing with MP parity + deviations (airDate/issue, cut, release group suffix fallback, dir+file merge, safe episode offset) - identity: title normalization (NFKC, trad->simp map, season suffix) and evidence-level candidate ranking (year tolerance ±1, no probabilities) - slot: hard/soft/upgrade evaluation (missing vs unmet fields), state machine, movie/unit completion (season pack must be covered by actual file list) - acquisition: new_title/catch_up/upgrade modes, priority order, budget, cache volume hard threshold with resume watermark (no double counting) - path: immutable path bindings, restricted naming templates, sanitize, conflict detection, downloader<->host path mapping - organize: planOrganize with sidecar pairing, sample exclusion, existing target policy, crossDevice marking, H&R -> conflict for move - download-state: qB state mapping, completion, no-progress evidence 230 tests, ~97% statement coverage. Co-Authored-By: Claude Fable 5.1 <[email protected]>
159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
type IdentityCandidate,
|
|
normalizeTitle,
|
|
parseRelease,
|
|
queryTitles,
|
|
rankCandidates,
|
|
resolveIdentity,
|
|
scoreCandidate,
|
|
stripSeasonSuffix,
|
|
TRAD_TO_SIMP,
|
|
toSimplified,
|
|
} from "../src";
|
|
|
|
const movie = (over: Partial<IdentityCandidate> = {}): IdentityCandidate => ({
|
|
provider: "tmdb",
|
|
entityType: "movie",
|
|
externalId: "872585",
|
|
titles: ["Oppenheimer", "奥本海默"],
|
|
year: 2023,
|
|
aliases: [],
|
|
...over,
|
|
});
|
|
|
|
describe("标题归一化", () => {
|
|
it("繁简 / 全半角 / 标点 / 大小写", () => {
|
|
expect(toSimplified("葬送的芙莉蓮")).toBe("葬送的芙莉莲");
|
|
expect(normalizeTitle("葬送的芙莉蓮")).toBe(normalizeTitle("葬送的芙莉莲"));
|
|
expect(normalizeTitle("THE LAST OF US")).toBe("thelastofus");
|
|
expect(normalizeTitle("Spider-Man: No Way Home")).toBe("spidermannowayhome");
|
|
expect(normalizeTitle("Tom & Jerry")).toBe("tomandjerry");
|
|
expect(normalizeTitle("黑暗榮耀")).toBe("黑暗荣耀");
|
|
});
|
|
it("去季号/年份后缀", () => {
|
|
expect(stripSeasonSuffix("庆余年 第二季")).toBe("庆余年");
|
|
expect(stripSeasonSuffix("The Boys Season 4")).toBe("The Boys");
|
|
expect(stripSeasonSuffix("Show S02")).toBe("Show");
|
|
expect(stripSeasonSuffix("Title (2019)")).toBe("Title");
|
|
expect(stripSeasonSuffix("第二季")).toBe("第二季");
|
|
expect(normalizeTitle("庆余年 第二季")).toBe(normalizeTitle("慶餘年"));
|
|
});
|
|
it("映射表是一一对应的字符对", () => {
|
|
expect(TRAD_TO_SIMP.size).toBeGreaterThan(500);
|
|
for (const [k, v] of TRAD_TO_SIMP) {
|
|
expect([...k].length).toBe(1);
|
|
expect([...v].length).toBe(1);
|
|
}
|
|
});
|
|
it("queryTitles 去重", () => {
|
|
const p = parseRelease("奥本海默 Oppenheimer 2023 1080p");
|
|
expect(queryTitles(p)).toEqual(["奥本海默", "oppenheimer"]);
|
|
});
|
|
});
|
|
|
|
describe("候选打分(证据等级,无概率)", () => {
|
|
const p = parseRelease("Oppenheimer.2023.2160p.UHD.BluRay.REMUX-FraMeSToR");
|
|
it("标题精确 + 年份一致 → rule_confirmed", () => {
|
|
const r = scoreCandidate(p, movie());
|
|
expect(r.level).toBe("rule_confirmed");
|
|
expect(r.titleMatch).toBe("exact");
|
|
expect(r.yearMatch).toBe("exact");
|
|
expect(Object.values(r).some((v) => typeof v === "number")).toBe(false);
|
|
});
|
|
it("年份容差 ±1 → multi_evidence;±2 → rejected", () => {
|
|
expect(scoreCandidate(p, movie({ year: 2024 })).level).toBe("multi_evidence");
|
|
expect(scoreCandidate(p, movie({ year: 2022 })).yearMatch).toBe("within_tolerance");
|
|
expect(scoreCandidate(p, movie({ year: 2025 })).level).toBe("rejected");
|
|
expect(scoreCandidate(p, movie({ year: 2025 }), { yearTolerance: 2 }).level).toBe("multi_evidence");
|
|
});
|
|
it("别名命中 → multi_evidence;部分包含 → needs_confirm;无关 → rejected", () => {
|
|
expect(scoreCandidate(p, movie({ titles: ["X"], aliases: ["Oppenheimer"] })).level).toBe("multi_evidence");
|
|
expect(scoreCandidate(p, movie({ titles: ["Oppenheimer Returns"] })).level).toBe("needs_confirm");
|
|
expect(scoreCandidate(p, movie({ titles: ["Barbie"] })).level).toBe("rejected");
|
|
});
|
|
it("年份未知:电影需确认,剧集有季号且类型一致算多证据", () => {
|
|
const noYear = parseRelease("Oppenheimer 1080p WEB-DL");
|
|
expect(scoreCandidate(noYear, movie()).level).toBe("needs_confirm");
|
|
const tv = parseRelease("奔跑吧 第十二季 20240426 第1期 1080p WEB-DL");
|
|
const show: IdentityCandidate = {
|
|
provider: "tmdb",
|
|
entityType: "tv",
|
|
externalId: "1",
|
|
titles: ["奔跑吧"],
|
|
year: 2017,
|
|
aliases: [],
|
|
};
|
|
const r = scoreCandidate(tv, show);
|
|
expect(r.yearMatch).toBe("unknown");
|
|
expect(r.typeMatch).toBe("match");
|
|
expect(r.level).toBe("multi_evidence");
|
|
expect(scoreCandidate(tv, { ...show, entityType: "movie" }).level).toBe("needs_confirm");
|
|
});
|
|
it("剧集季年份:seasonYears 命中算 season_year;后续季无季年份信息不据此拒绝", () => {
|
|
const s2 = parseRelease("The.Last.of.Us.S02.2025.1080p.WEB-DL");
|
|
const show: IdentityCandidate = {
|
|
provider: "tmdb",
|
|
entityType: "tv",
|
|
externalId: "100088",
|
|
titles: ["The Last of Us"],
|
|
year: 2023,
|
|
aliases: [],
|
|
};
|
|
expect(scoreCandidate(s2, show).yearMatch).toBe("unknown");
|
|
const withYears = scoreCandidate(s2, { ...show, seasonYears: [2023, 2025] });
|
|
expect(withYears.yearMatch).toBe("season_year");
|
|
expect(withYears.level).toBe("rule_confirmed");
|
|
expect(scoreCandidate(s2, { ...show, seasonYears: [2023] }).level).toBe("rejected");
|
|
});
|
|
it("显式 ID 提升一级;标题不符但显式 ID 命中 → needs_confirm", () => {
|
|
const e = parseRelease("[tmdbid=872585] Oppenheimer 2024 1080p");
|
|
const r = scoreCandidate(e, movie());
|
|
expect(r.evidence).toContain("explicit_id");
|
|
expect(r.level).toBe("rule_confirmed");
|
|
const e2 = parseRelease("[tmdbid=872585] Something Else 2023 1080p");
|
|
expect(scoreCandidate(e2, movie()).level).toBe("needs_confirm");
|
|
const local = movie({
|
|
provider: "local",
|
|
externalId: "uuid-1",
|
|
linkedExternalIds: [{ provider: "tmdb", id: "872585" }],
|
|
});
|
|
expect(scoreCandidate(e2, local).evidence).toContain("explicit_id");
|
|
});
|
|
});
|
|
|
|
describe("排序与判定", () => {
|
|
const p = parseRelease("Oppenheimer.2023.1080p.BluRay.x264-CHD");
|
|
it("本地实体同等级优先,等级 > 标题 > 年份", () => {
|
|
const tmdb = movie();
|
|
const local = movie({ provider: "local", externalId: "uuid-1", localEntityId: "uuid-1" });
|
|
const weak = movie({ externalId: "9", titles: ["Oppenheimer X"] });
|
|
const ranked = rankCandidates(p, [weak, tmdb, local]);
|
|
expect(ranked.map((r) => r.candidate.externalId)).toEqual(["uuid-1", "872585", "9"]);
|
|
});
|
|
it("唯一强候选 → auto", () => {
|
|
const res = resolveIdentity(p, [movie(), movie({ externalId: "2", titles: ["Barbie"] })]);
|
|
expect(res.status).toBe("auto");
|
|
expect(res.best?.candidate.externalId).toBe("872585");
|
|
});
|
|
it("同等级两个不同身份 → ambiguous;同一身份(本地映射)不算冲突", () => {
|
|
const dup = resolveIdentity(p, [movie(), movie({ externalId: "999" })]);
|
|
expect(dup.status).toBe("ambiguous");
|
|
expect(dup.reason).toBe("multiple_candidates_same_level");
|
|
const local = movie({
|
|
provider: "local",
|
|
externalId: "uuid-1",
|
|
localEntityId: "uuid-1",
|
|
linkedExternalIds: [{ provider: "tmdb", id: "872585" }],
|
|
});
|
|
expect(resolveIdentity(p, [movie(), local]).status).toBe("auto");
|
|
expect(resolveIdentity(p, [local, movie()]).status).toBe("auto");
|
|
expect(resolveIdentity(p, [local, { ...local, externalId: "x" }]).status).toBe("auto");
|
|
});
|
|
it("弱证据 → ambiguous;无候选 → no_match", () => {
|
|
expect(resolveIdentity(p, [movie({ titles: ["Oppenheimer Returns"] })]).status).toBe("ambiguous");
|
|
expect(resolveIdentity(p, []).status).toBe("no_match");
|
|
expect(resolveIdentity(p, [movie({ titles: ["Barbie"] })]).status).toBe("no_match");
|
|
});
|
|
});
|