Files
UHD_Media_Tool/packages/mapping/test/convert.test.ts
T
rootandClaude Opus 5.5 70935f4c17 整理:被排除的待确认视频不算成功
季集待确认 / 没有季目录 / 命名失败而被排除的视频(excluded:episode_unresolved / no_target_dir /
naming_failed)让整理记录从 succeeded 降为 partial;样例、过小、已在库等预期跳过不受影响。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-03 20:59:06 +08:00

280 lines
11 KiB
TypeScript

import * as C from "@umt/contracts";
import {
DEFAULT_TEMPLATES,
evaluateCandidate,
type IdentityCandidate,
PARSER_VERSION,
parseRelease,
planOrganize,
resolveIdentity,
resolveTargetDirectory,
type SlotSpec,
} from "@umt/domain";
import { describe, expect, it } from "vitest";
import * as M from "../src/index.ts";
const U = (n: number) => `00000000-0000-4000-8000-${String(n).padStart(12, "0")}`;
const AT = "2026-09-27T00:00:00.000Z";
const GiB = 1024 ** 3;
describe("ParsedRelease ↔ source_release.parsed", () => {
it("往返无损;版本不符或形状不对返回 null(调用方重解析)", () => {
const p = parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1-FGT");
const cols = M.toParsedColumns(p);
expect(C.SourceReleaseCreate.shape.parserVersion.safeParse(cols.parserVersion).success).toBe(true);
expect(M.fromParsedColumns(cols)).toEqual(JSON.parse(JSON.stringify(p)));
expect(M.fromParsedColumns({ ...cols, parserVersion: "0" })).toBeNull();
expect(
M.fromParsedColumns({ parsed: { ...cols.parsed, audio: "x" }, parserVersion: cols.parserVersion }),
).toBeNull();
});
});
describe("身份", () => {
const p = parseRelease("Dune.Part.Two.2024.2160p.WEB-DL.DDP5.1.H.265-FLUX");
const tmdb: IdentityCandidate = {
provider: "tmdb",
entityType: "movie",
externalId: "693134",
titles: ["Dune: Part Two", "Dune Part Two"],
year: 2024,
aliases: [],
};
it("排名候选 → ExternalIdentityCreate(schema 合法);本地候选不生成外部身份", () => {
const res = resolveIdentity(p, [tmdb]);
const create = M.rankedToIdentityCreate(res.ranked[0]!, { mediaEntityId: U(1) }, "accepted");
expect(create).not.toBeNull();
expect(C.ExternalIdentityCreate.safeParse(create).success).toBe(true);
expect(create).toMatchObject({ provider: "tmdb", externalId: "693134", status: "accepted" });
const local = M.localIdentityCandidate(
{ id: U(2), kind: "movie", title: "沙丘2", originalTitle: "Dune: Part Two", year: 2024 },
["Dune Part Two"],
[{ provider: "tmdb", externalId: "693134", status: "accepted" }],
);
expect(local).toMatchObject({
provider: "local",
localEntityId: U(2),
linkedExternalIds: [{ provider: "tmdb", id: "693134" }],
});
const both = resolveIdentity(p, [tmdb, local!]);
// 本地作品与 TMDB 是同一身份(linkedExternalIds),不构成歧义
expect(both.status).toBe("auto");
const localRanked = both.ranked.find((r) => r.candidate.provider === "local")!;
expect(M.rankedToIdentityCreate(localRanked, { mediaEntityId: U(2) })).toBeNull();
expect(
M.localIdentityCandidate({ id: U(3), kind: "collection", title: "x", originalTitle: null, year: null }, []),
).toBeNull();
});
});
describe("PathBinding", () => {
const row = (over: Partial<C.PathBinding>): C.PathBinding => ({
id: U(10),
createdAt: AT,
updatedAt: AT,
mediaEntityId: U(1),
contentUnitId: null,
seasonNumber: null,
bindingKind: "entity",
libraryRootId: U(20),
relativePath: "最后生还者 (2023)",
templateVersion: "1",
status: "active",
boundAt: AT,
boundBy: null,
supersededById: null,
note: null,
...over,
});
it("行 → domain 后参与目标目录决策:active 优先,非 active 忽略", () => {
const bindings = [
row({}),
row({ id: U(11), bindingKind: "season", seasonNumber: 1, relativePath: "最后生还者 (2023)/S1" }),
row({ id: U(12), relativePath: "旧目录", status: "superseded", templateVersion: "legacy" }),
].map(M.toDomainPathBinding);
expect(bindings[2]).toMatchObject({ active: false, templateVersion: 0 });
const r = resolveTargetDirectory({
entity: { id: U(1), kind: "tv" },
naming: { title: "最后的生还者", year: 2023 },
season: 1,
bindings,
libraryRoot: { id: U(20), hostPath: "/media/tv" },
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({ kind: "existing", relativePath: "最后生还者 (2023)/S1", bindingIds: [U(11)] });
});
it("新绑定 → PathBindingCreate(schema 合法,season 绑定带 seasonNumber)", () => {
const r = resolveTargetDirectory({
entity: { id: U(1), kind: "tv" },
naming: { title: "最后生还者", year: 2023 },
season: 2,
bindings: [],
libraryRoot: { id: U(20), hostPath: "/media/tv" },
templates: DEFAULT_TEMPLATES,
});
expect(r.kind).toBe("new");
if (r.kind !== "new") return;
const creates = r.newBindings.map((nb) => M.toPathBindingCreate(nb, { mediaEntityId: U(1) }));
for (const c of creates) expect(C.PathBindingCreate.safeParse(c).success, JSON.stringify(c)).toBe(true);
expect(creates.map((c) => [c.bindingKind, c.seasonNumber ?? null, c.templateVersion])).toEqual([
["entity", null, "2"],
["season", 2, "2"],
]);
});
});
describe("整理计划 → 整理记录", () => {
const ROOT = "/downloads/tv/The.Last.of.Us.S01.2160p.WEB-DL-FLUX";
const plan = planOrganize({
operation: "copy",
mediaType: "tv",
sourceRoot: ROOT,
files: [
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.mkv`, size: 5 * GiB, device: 1 },
{ path: `${ROOT}/The.Last.of.Us.S01E01.2160p.chs.srt`, size: 50_000, device: 1 },
{ path: `${ROOT}/Sample/the.last.of.us.s01e01.sample.mkv`, size: 80 * 1024 ** 2, device: 1 },
],
entity: { title: "最后生还者", year: 2023 },
target: { seasonDirs: { 1: "/media/tv/最后生还者 (2023)/Season 1" }, device: 2 },
existingTargets: [],
});
it("记录与逐文件草稿都通过 contracts schema;排除文件记为 skipped", () => {
const rec = M.organizePlanToRecord(plan, {
trigger: "auto",
actorType: "system",
downloadTaskId: U(5),
sourcePath: ROOT,
planFingerprint: "h1",
});
expect(C.OrganizationRecordCreate.safeParse(rec).success).toBe(true);
expect(rec).toMatchObject({ action: "copy", sourcePathView: "local", downloadTaskId: U(5) });
expect(rec.plan).toMatchObject({ operation: "copy" });
const files = M.organizePlanToFileResults(plan, { targetLibraryRootId: U(20) });
for (const f of files) {
expect(C.OrganizationFileResultCreate.safeParse({ ...f, organizationRecordId: U(9) }).success).toBe(true);
expect(C.FileResultStatus.options).toContain(f.status);
}
expect(files.map((f) => [f.ordinal, f.role, f.status, f.skipReason])).toEqual([
[0, "main", "queued", null],
[1, "subtitle", "queued", null],
[2, "main", "skipped", "excluded:sample"],
]);
});
it("能力输出合法;整理记录状态按文件结果汇总", () => {
const out = M.organizePlanToCapabilityOutput(plan, { planId: U(7), payloadHash: "h1" });
expect(C.OrganizePlanOutput.safeParse(out).success).toBe(true);
expect(out.files).toHaveLength(3);
expect(M.organizationStatusFromFiles(["succeeded", "skipped"])).toBe("succeeded");
expect(M.organizationStatusFromFiles(["succeeded", "failed"])).toBe("partial");
expect(M.organizationStatusFromFiles(["failed", "skipped"])).toBe("failed");
expect(M.organizationStatusFromFiles(["succeeded", "running"])).toBe("running");
expect(M.organizationStatusFromFiles(["cancelled"])).toBe("cancelled");
expect(M.organizationStatusFromFiles([])).toBe("succeeded");
// 被排除的待确认视频(季集 / 季目录 / 命名)让成功降为 partial;预期跳过不影响
expect(M.organizationStatusFromFiles(["succeeded", "skipped"], { unresolved: 1 })).toBe("partial");
expect(M.organizationStatusFromFiles(["succeeded", "failed"], { unresolved: 1 })).toBe("partial");
expect(M.organizationStatusFromFiles(["failed", "skipped"], { unresolved: 1 })).toBe("failed");
expect(M.isUnresolvedExclusion("excluded:episode_unresolved")).toBe(true);
expect(M.isUnresolvedExclusion("excluded:no_target_dir")).toBe(true);
expect(M.isUnresolvedExclusion("excluded:naming_failed")).toBe(true);
expect(M.isUnresolvedExclusion("excluded:too_small")).toBe(false);
expect(M.isUnresolvedExclusion("already_in_library")).toBe(false);
expect(M.isUnresolvedExclusion(null)).toBe(false);
});
});
describe("Slot 规则与评估", () => {
const spec: SlotSpec = {
id: U(30),
name: "archival",
hard: { resolutions: ["2160p"], maxSizeBytes: 40 * GiB },
unknownPolicy: { size: "accept" },
soft: { hdr: ["DV"] },
upgrade: { enabled: true },
};
it("SlotSpec → 规则列 → SlotSpec 往返", () => {
const cols = M.slotSpecToColumns(spec);
expect(C.AcquisitionSlotCreate.safeParse({ ...cols, mediaEntityId: U(1) }).success).toBe(true);
const back = M.slotSpecFromRow({ id: U(30), ...cols, completion: cols.completion ?? {} } as Parameters<
typeof M.slotSpecFromRow
>[0]);
expect(back).toEqual(spec);
});
it("评估结果 → 扁平表示(rejected 与 DownloadPlanOutput.rejected 同形)", () => {
const ok = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.2160p.BluRay.x265.DV-CHD")));
expect(ok).toMatchObject({ decision: "accept", acceptable: true, rejected: [] });
const bad = M.toContractSlotEvaluation(evaluateCandidate(spec, parseRelease("Dune.2021.1080p.HDTV.x264-GRP")));
expect(bad.acceptable).toBe(false);
expect(bad.rejected[0]?.code).toMatch(/^resolution\./);
const shape = C.DownloadPlanOutput.shape.rejected;
expect(shape.safeParse(bad.rejected).success).toBe(true);
});
});
describe("SiteRelease ↔ source_release", () => {
const site: M.SiteReleaseLike = {
siteId: "audiences",
siteReleaseId: "123",
title: "Dune.Part.Two.2024.2160p.WEB-DL-FLUX",
subtitle: "沙丘2",
category: { id: "401", name: "电影" },
sizeBytes: 20 * GiB,
seeders: 10,
leechers: 1,
snatched: 30,
promotion: { type: "free", downloadFactor: 0, uploadFactor: 1, expiresAt: "2026-09-28T00:00:00.000Z" },
hr: "unknown",
uploadedAt: "2026-09-26T00:00:00.000Z",
detailUrl: "https://pt.example/details.php?id=123",
downloadUrl: "https://pt.example/download.php?id=123",
infoHash: "A".repeat(40),
imdbId: "tt15239678",
tags: ["中字"],
raw: { small_descr: "沙丘2" },
observedAt: AT,
};
it("转换结果通过 SourceReleaseCreate;downloadUrl 不入库;siteId 用 site 行 UUID", () => {
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40), parsed: parseRelease(site.title) });
expect(C.SourceReleaseCreate.safeParse(create).success).toBe(true);
expect(create).toMatchObject({ siteId: U(40), infohashV1: "a".repeat(40), infohashV2: null, categoryRaw: "401" });
expect(JSON.stringify(create)).not.toContain("download.php");
expect(create.parserVersion).toBe(String(PARSER_VERSION));
});
it("source_release 行还原为 SiteReleaseLike(下载入口除外)", () => {
const create = M.siteReleaseToSourceRelease(site, { siteId: U(40) });
const row: C.SourceRelease = {
...(create as Required<typeof create>),
id: U(41),
createdAt: AT,
updatedAt: AT,
firstSeenAt: AT,
lastSeenAt: AT,
fileList: null,
detailFetchedAt: null,
parsed: {},
parserVersion: null,
mediaEntityId: null,
contentUnitId: null,
resolutionStatus: "unresolved",
resolutionEvidence: {},
availability: "active",
resolutionLocked: false,
episodeResolution: null,
};
expect(C.SourceRelease.safeParse(row).success).toBe(true);
const { downloadUrl: _d, ...expected } = site;
expect(M.sourceReleaseToSiteRelease(row)).toEqual({ ...expected, infoHash: "a".repeat(40) });
});
});