- root deno.json workspace (nodeModulesDir auto), npm versions registered once in imports, deno.lock; remove pnpm-lock/pnpm-workspace/root package.json; members keep minimal package.json (name/type/exports) - tasks: dev, typecheck (tsc 7.0.2 per member + deno check), lint/fmt (biome, useImportExtensions), test (vitest via deno), check:tokens, build (web + deno compile), ci, db:migrate/reset, gen:types - tsconfig base moved to config/ (no root tsconfig.json); deno types -> types/deno.d.ts; web drops baseUrl - server: Deno.serve entry with graceful shutdown, --role migrate|preflight|probe, WEB_ROOT static SPA hosting; drop @hono/node-server, tsx, esbuild build.mjs - logger: redact child() bindings and serialize err by message/stack; sync stdout destination; tests - relative imports get explicit extensions (sites/mp-sync paths left for follow-up) - crash-recovery tests spawn `deno run -A`; tasks tests use unique per-file schemas - single-container Dockerfile + entrypoint (migrate -> preflight -> api+worker watchdog), docker-compose.yml - docs: README, runbook, ADR-001 superseded, plan §1 validation row 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/index.ts";
|
|
|
|
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");
|
|
});
|
|
});
|