Files
UHD_Media_Tool/packages/domain/test/slot.test.ts
T
rootandClaude Opus 5.5 2b8fe667a5 fix(organize,identity,sites,pi): W10-pre backend fixes from v0.2 visual QA
- slot: new manual_verify event (attention -> verifying, with reason); a
  successful organize of a download whose slot is in attention goes back
  through verifying and lands by verify_passed on the actually imported
  assets; the download's organize_blocked decision is resolved with
  resolution {key: manual_organize | organize_retry, organizationRecordId} (U1)
- acquisition: plan purpose is upgrade only when the slot already has a
  current asset; a movie slot gap is a fill (U7)
- identity: entities created from TMDB details get metadata fields projected
  in the same transaction; TMDB aliases deduped by normalized text, site-title
  aliases skipped when the text already exists (U9)
- pi: auth-mode test of a custom Base URL no longer marks the upstream ok, and
  says to use the minimal completion to probe the endpoint (U2)
- sites: PUT /sites/{id}/credentials replaces same-kind secrets in place and
  names new secrets with site id + random suffix (secret.name is unique; was a
  500 on update); M-Team user data uses config/definition apiBaseUrl instead
  of deriving api.<domain> from the site URL (Invalid URL on IP/local bases)
- copy: setup error says 初始化口令; backup job points at umt backup
- tests: schema expected tables add site_user_data; migration round-trip
  timeout 60s; fake secrets honor label as name; vertical/resolve/pi/slot/
  site-center tests for the above
- docs: 09-visual-qa W10-pre status table (U3 root cause: outbox events are
  never mirrored to SSE)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:29:29 +08:00

300 lines
14 KiB
TypeScript

import { describe, expect, it } from "vitest";
import {
canTransition,
checkDistinctAssets,
compareRank,
evaluateCandidate,
movieCompletion,
type ParsedRelease,
parseRelease,
qualityTier,
rankForSlot,
type SlotSpec,
type SlotState,
SlotTransitionError,
transition,
unitCompletion,
} from "../src/index.ts";
const GiB = 1024 ** 3;
const rel = (t: string, over: Partial<ParsedRelease> = {}): ParsedRelease => ({ ...parseRelease(t), ...over });
const archival4k: SlotSpec = {
id: "s-4k",
name: "4K 蓝光压制",
hard: { resolutions: ["2160p"], sources: ["BluRay", "WEB-DL"], maxSizeBytes: 40 * GiB },
soft: { hdr: ["DV", "HDR10"], groups: ["CHD", "FraMeSToR"], preferredSizeBytes: { max: 30 * GiB } },
upgrade: { enabled: true },
};
describe("evaluateCandidate 硬条件", () => {
it("满足 → accept 并给软偏好排序键", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265.DV-CHD 20GB"));
expect(r.decision).toBe("accept");
if (r.decision !== "accept") return;
expect(r.softRank).toEqual([2, 2, 0, 0, 1]);
expect(r.tier).toEqual({ source: 3, resolution: 6 });
});
it("已知不满足 → reject,列出全部违规", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.1080p.HDTV.x264-GRP 50GB"));
expect(r.decision).toBe("reject");
if (r.decision !== "reject") return;
expect(r.violations.map((v) => `${v.field}:${v.code}`)).toEqual([
"resolution:not_allowed",
"source:not_allowed",
"size:too_large",
]);
});
it("缺字段 → unknown_field(与不满足区分)", () => {
const r = evaluateCandidate(archival4k, rel("Dune 2021 BluRay"));
expect(r).toMatchObject({ decision: "unknown_field", missing: ["resolution", "size"] });
});
it("unknownPolicy 可按字段配置 reject / accept", () => {
const spec: SlotSpec = { ...archival4k, unknownPolicy: { size: "accept", resolution: "reject" } };
const r = evaluateCandidate(spec, rel("Dune 2021 BluRay"));
expect(r.decision).toBe("reject");
const ok = evaluateCandidate({ ...archival4k, unknownPolicy: "accept" }, rel("Dune 2021 BluRay"));
expect(ok.decision).toBe("accept");
if (ok.decision === "accept") expect(ok.reasons).toContain("unknown_size_accepted_by_policy");
});
it("必需字幕:未知 hold,缺语言 reject,zh 可被 zh-Hans 满足", () => {
const spec: SlotSpec = { name: "zh", hard: { requiredSubtitles: ["zh"] } };
expect(evaluateCandidate(spec, rel("Movie 2020 1080p")).decision).toBe("unknown_field");
expect(evaluateCandidate(spec, rel("Movie 2020 1080p 简体字幕")).decision).toBe("accept");
const r = evaluateCandidate({ name: "en", hard: { requiredSubtitles: ["en"] } }, rel("Movie 2020 简体字幕"));
expect(r.decision).toBe("reject");
});
it("内容版本 / 仅二压 / 排除组 / 内容类型 / 最小体积", () => {
const cutSpec: SlotSpec = { name: "c", hard: { cuts: ["Extended"] } };
expect(evaluateCandidate(cutSpec, rel("LOTR 2001 Extended 1080p")).decision).toBe("accept");
expect(evaluateCandidate(cutSpec, rel("LOTR 2001 1080p")).decision).toBe("reject");
const enc: SlotSpec = { name: "e", hard: { requireEncode: true } };
expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay REMUX")).decision).toBe("reject");
expect(evaluateCandidate(enc, rel("Movie 2020 1080p BluRay x264")).decision).toBe("accept");
expect(evaluateCandidate(enc, rel("Movie 2020 1080p")).decision).toBe("unknown_field");
const ex: SlotSpec = { name: "x", hard: { excludeGroups: ["badgrp"] } };
expect(evaluateCandidate(ex, rel("Movie.2020.1080p.WEB-DL-BADGRP")).decision).toBe("reject");
const ct: SlotSpec = { name: "m", hard: { contentTypes: ["main"] } };
expect(evaluateCandidate(ct, rel("某综艺 第1期 加更")).decision).toBe("reject");
expect(evaluateCandidate(ct, rel("某综艺 第1期")).decision).toBe("unknown_field");
expect(evaluateCandidate(ct, rel("某综艺 第1期 正片")).decision).toBe("accept");
const min: SlotSpec = { name: "min", hard: { minSizeBytes: 2 * GiB } };
expect(evaluateCandidate(min, rel("Movie 2020 1080p 1GB")).decision).toBe("reject");
});
});
describe("升级规则", () => {
const cur4kWeb = { resolution: "2160p" as const, source: "WEB-DL" as const };
it("WEB-DL → 蓝光二压:upgrade", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb);
expect(r.decision).toBe("upgrade");
if (r.decision === "upgrade") expect(r.fromTier.source).toBe(2);
});
it("同等级不反复更换", () => {
const r = evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"), cur4kWeb);
expect(r).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] });
const remuxToEncode = evaluateCandidate(
{ ...archival4k, hard: { resolutions: ["2160p"] } },
rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"),
{ resolution: "2160p", source: "REMUX" },
);
expect(remuxToEncode).toMatchObject({ decision: "reject", violations: [{ code: "same_tier" }] });
});
it("更低等级 / 未开启升级 / 当前资产字段未知 / 达到上限", () => {
const hi = { resolution: "2160p" as const, source: "BluRay" as const };
expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.WEB-DL 20GB"), hi)).toMatchObject({
violations: [{ code: "lower_tier" }],
});
expect(
evaluateCandidate({ ...archival4k, upgrade: { enabled: false } }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb),
).toMatchObject({ violations: [{ code: "upgrade_disabled" }] });
expect(
evaluateCandidate({ ...archival4k, upgrade: undefined }, rel("Dune.2021.2160p.BluRay 2GB"), cur4kWeb),
).toMatchObject({ decision: "reject" });
expect(evaluateCandidate(archival4k, rel("Dune.2021.2160p.BluRay 2GB"), {})).toMatchObject({
decision: "unknown_field",
missing: ["current_asset"],
});
expect(
evaluateCandidate(
{ ...archival4k, upgrade: { enabled: true, ceilingTier: 2 } },
rel("Dune.2021.2160p.BluRay 2GB"),
cur4kWeb,
),
).toMatchObject({ violations: [{ code: "at_ceiling" }] });
});
it("最小改进阈值 2 级:WEBRip → WEB-DL 不够", () => {
const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, minTierSteps: 2 } };
const r = evaluateCandidate(spec, rel("Movie 2020 1080p WEB-DL"), { resolution: "1080p", source: "WEBRip" });
expect(r).toMatchObject({ violations: [{ code: "below_min_improvement" }] });
});
it("分辨率升级需显式允许", () => {
const spec: SlotSpec = { name: "x", hard: {}, upgrade: { enabled: true, allowResolutionUpgrade: true } };
const cur = { resolution: "1080p" as const, source: "WEB-DL" as const };
expect(evaluateCandidate(spec, rel("M 2020 2160p WEB-DL"), cur).decision).toBe("upgrade");
expect(evaluateCandidate({ ...spec, upgrade: { enabled: true } }, rel("M 2020 2160p WEB-DL"), cur).decision).toBe(
"reject",
);
});
it("合格压制组名单:名单外压制不自动升级", () => {
const spec: SlotSpec = { ...archival4k, upgrade: { enabled: true, encodeGroupAllowlist: ["CHD"] } };
expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-CHD 20GB"), cur4kWeb).decision).toBe("upgrade");
expect(evaluateCandidate(spec, rel("Dune.2021.2160p.BluRay.x265-LOWQ 20GB"), cur4kWeb)).toMatchObject({
violations: [{ code: "encode_not_qualified" }],
});
});
it("自定义来源等级表", () => {
expect(
qualityTier(
{ source: "REMUX", resolution: "1080p" },
{ name: "x", hard: {}, upgrade: { enabled: true, sourceTiers: { REMUX: 5 } } },
),
).toEqual({
source: 5,
resolution: 4,
});
expect(qualityTier({})).toEqual({ source: -1, resolution: -1 });
});
});
describe("rankForSlot 排序", () => {
it("可用在前;按等级再按软偏好", () => {
const cands = [
rel("Dune.2021.2160p.WEB-DL.DV-FLUX 20GB"),
rel("Dune.2021.1080p.BluRay-CHD 10GB"),
rel("Dune.2021.2160p.BluRay.x265.HDR-SPARKS 35GB"),
rel("Dune.2021.2160p.BluRay.x265.DV-CHD 25GB"),
];
const ranked = rankForSlot(archival4k, cands);
expect(ranked.map((r) => r.candidate.releaseGroup?.name)).toEqual(["CHD", "SPARKS", "FLUX", "CHD"]);
expect(ranked[3]!.evaluation.decision).toBe("reject");
});
it("compareRank 字典序", () => {
expect(compareRank([1, 0], [0, 5])).toBeGreaterThan(0);
expect(compareRank([1], [1, 0])).toBe(0);
});
});
describe("Slot 状态机", () => {
const s = (status: SlotState["status"], hasAsset = false): SlotState => ({ status, hasAsset });
it("主路径 empty→reserved→downloading→verifying→satisfied", () => {
let st = s("empty");
st = transition(st, { type: "reserve" });
st = transition(st, { type: "download_started" });
st = transition(st, { type: "download_completed" });
st = transition(st, { type: "verify_passed", complete: true });
expect(st).toEqual({ status: "satisfied", hasAsset: true });
});
it("升级路径保留旧资产,取消回到 satisfied", () => {
let st = transition(s("satisfied", true), { type: "upgrade_found" });
expect(st.status).toBe("upgrade_pending");
expect(transition(st, { type: "upgrade_cancelled" }).status).toBe("satisfied");
st = transition(st, { type: "reserve" });
expect(transition(st, { type: "release" })).toEqual({ status: "satisfied", hasAsset: true });
expect(transition(s("reserved"), { type: "release" }).status).toBe("empty");
});
it("失败/校验失败进 attention,解决后按是否有资产回退", () => {
expect(transition(s("downloading"), { type: "download_failed" }).status).toBe("attention");
expect(transition(s("reserved"), { type: "download_failed" }).status).toBe("attention");
expect(transition(s("verifying"), { type: "verify_failed" }).status).toBe("attention");
expect(transition(s("attention"), { type: "attention_resolved", hasAsset: true }).status).toBe("satisfied");
expect(transition(s("attention"), { type: "attention_resolved", hasAsset: false }).status).toBe("empty");
expect(transition(s("attention"), { type: "reserve" }).status).toBe("reserved");
expect(transition(s("satisfied", true), { type: "asset_missing_confirmed" })).toEqual({
status: "attention",
hasAsset: false,
});
});
it("需处理经人工整理回到核验,再按实际资产落终态(manual_verify)", () => {
const st = transition(s("attention"), { type: "manual_verify", reason: "manual_organize" });
expect(st).toEqual({ status: "verifying", hasAsset: false });
expect(transition(st, { type: "verify_passed", complete: true })).toEqual({ status: "satisfied", hasAsset: true });
for (const from of ["empty", "reserved", "downloading", "verifying", "satisfied", "upgrade_pending"] as const)
expect(canTransition(s(from), { type: "manual_verify", reason: "x" }), from).toBe(false);
});
it("部分覆盖回到 empty 但记住已有资产", () => {
expect(transition(s("verifying"), { type: "verify_passed", complete: false })).toEqual({
status: "empty",
hasAsset: true,
});
});
it("非法迁移抛错", () => {
const illegal: [SlotState["status"], Parameters<typeof transition>[1]][] = [
["empty", { type: "download_started" }],
["satisfied", { type: "reserve" }],
["empty", { type: "release" }],
["reserved", { type: "download_completed" }],
["empty", { type: "verify_passed", complete: true }],
["downloading", { type: "verify_failed" }],
["empty", { type: "upgrade_found" }],
["satisfied", { type: "upgrade_cancelled" }],
["empty", { type: "attention_resolved", hasAsset: false }],
["empty", { type: "asset_missing_confirmed" }],
["satisfied", { type: "download_failed" }],
];
for (const [from, ev] of illegal) {
expect(() => transition(s(from), ev), `${from} ${ev.type}`).toThrow(SlotTransitionError);
expect(canTransition(s(from), ev)).toBe(false);
}
expect(canTransition(s("empty"), { type: "reserve" })).toBe(true);
});
});
describe("完成条件", () => {
it("电影按内容版本", () => {
expect(movieCompletion(undefined, [{ assetId: "a", verified: true }]).complete).toBe(true);
expect(movieCompletion(undefined, [{ assetId: "a", verified: false }]).complete).toBe(false);
const r = movieCompletion(["default", "Extended"], [{ assetId: "a", verified: true }]);
expect(r).toEqual({ complete: false, missingCuts: ["Extended"], satisfiedBy: { default: "a" } });
});
it("剧集按 content_unit;season pack 需实际文件清单覆盖", () => {
const req = ["e1", "e2", "e3"];
const pack = { assetId: "p", verified: true, fileUnits: ["e1", "e2"], claimedUnits: ["e1", "e2", "e3"] };
const r = unitCompletion(req, [pack], { totalKnown: true });
expect(r).toEqual({
complete: false,
covered: ["e1", "e2"],
missing: ["e3"],
unverifiedClaims: ["e3"],
keepTracking: true,
});
const full = unitCompletion(req, [pack, { assetId: "x", verified: true, fileUnits: ["e3"] }], { totalKnown: true });
expect(full.complete).toBe(true);
expect(full.keepTracking).toBe(false);
expect(
unitCompletion(req, [{ assetId: "x", verified: false, fileUnits: req }], { totalKnown: true }).complete,
).toBe(false);
expect(
unitCompletion(["e1"], [{ assetId: "x", verified: true, fileUnits: ["e1"] }], { totalKnown: false }),
).toMatchObject({
complete: true,
keepTracking: true,
});
expect(unitCompletion([], [], { totalKnown: false }).complete).toBe(false);
});
it("require_distinct_asset 校验", () => {
const g = { slots: [], requireDistinctAsset: true };
expect(
checkDistinctAssets(g, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "1" },
]),
).toEqual({
ok: false,
sharedAssets: ["1"],
});
expect(
checkDistinctAssets(g, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "2" },
]).ok,
).toBe(true);
expect(
checkDistinctAssets({ slots: [] }, [
{ slotId: "a", assetId: "1" },
{ slotId: "b", assetId: "1" },
]).ok,
).toBe(true);
});
});