Files
UHD_Media_Tool/packages/domain/test/path.test.ts
T
rootandClaude Fable 5.1 7ba712ea55 feat(domain): pure business logic for parsing, identity, slots, acquisition, paths, organize, download state (W1-2)
- 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]>
2026-09-27 18:11:29 +08:00

304 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from "vitest";
import {
buildNamingContext,
compileTemplate,
DEFAULT_TEMPLATES,
detectPathConflicts,
type PathBinding,
parseRelease,
renderMediaFileName,
renderTemplate,
resolveTargetDirectory,
sanitizeFileName,
sanitizeSegment,
TemplateError,
toDownloaderPath,
toHostPath,
utf8Length,
validatePathMappings,
validateTemplateSet,
} from "../src";
const root = { id: "lib-tv", hostPath: "/media/tv" };
const binding = (over: Partial<PathBinding>): PathBinding => ({
id: "b1",
entityId: "e1",
scope: "entity",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)",
templateVersion: 1,
...over,
});
describe("路径绑定", () => {
it("已有作品/季绑定优先且不随译名变化", () => {
const r = resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "最后的生还者", year: 2023 },
season: 1,
bindings: [binding({}), binding({ id: "b2", scope: "season", season: 1, relativePath: "最后生还者 (2023)/S1" })],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(r).toEqual({
kind: "existing",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)/S1",
bindingIds: ["b2"],
});
});
it("作品已绑定、新季:只按模板渲染季目录,作品目录复用", () => {
const r = resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "新译名", year: 2023 },
season: 2,
bindings: [binding({})],
libraryRoot: { id: "other", hostPath: "/x" },
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({
kind: "new",
libraryRootId: "lib-tv",
relativePath: "最后生还者 (2023)/Season 2",
reusedBindingIds: ["b1"],
newBindings: [{ scope: "season", season: 2, templateVersion: 1 }],
});
});
it("首次:作品 + 季两级绑定,非法字符转全角", () => {
const r = resolveTargetDirectory({
entity: { id: "e9", kind: "tv" },
naming: { title: "Show: Part/2" },
season: 1,
bindings: [binding({})],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(r).toMatchObject({ kind: "new", relativePath: "Show: Part/2/Season 1" });
if (r.kind === "new") expect(r.newBindings).toHaveLength(2);
});
it("电影:已有 / 首次 / 模板缺变量", () => {
const mv = { entity: { id: "m1", kind: "movie" as const }, libraryRoot: root, templates: DEFAULT_TEMPLATES };
expect(
resolveTargetDirectory({
...mv,
naming: { title: "X" },
bindings: [binding({ entityId: "m1", relativePath: "沙丘2 (2024)" })],
}),
).toMatchObject({ kind: "existing", relativePath: "沙丘2 (2024)" });
expect(
resolveTargetDirectory({ ...mv, naming: { title: "沙丘:第二部", year: 2024 }, bindings: [] }),
).toMatchObject({
kind: "new",
relativePath: "沙丘:第二部 (2024)",
});
expect(resolveTargetDirectory({ ...mv, naming: {}, bindings: [] })).toEqual({
kind: "error",
reason: "template_missing_vars",
missing: ["title"],
});
});
it("两个历史目录 → conflict,不自动合并;停用绑定忽略", () => {
const two = [binding({}), binding({ id: "b3", relativePath: "The Last of Us (2023)" })];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: two,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}),
).toMatchObject({ kind: "conflict", reason: "multiple_entity_bindings" });
const inactive = [binding({}), binding({ id: "b3", relativePath: "Old", active: false })];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: inactive,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}).kind,
).toBe("new");
const seasons = [
binding({ id: "s1", scope: "season", season: 1, relativePath: "A/S1" }),
binding({ id: "s2", scope: "season", season: 1, relativePath: "A/Season 1" }),
];
expect(
resolveTargetDirectory({
entity: { id: "e1", kind: "tv" },
naming: { title: "x" },
season: 1,
bindings: seasons,
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
}),
).toMatchObject({ kind: "conflict", reason: "multiple_season_bindings" });
});
it("剧集缺季号 / 模板缺变量", () => {
const tv = { entity: { id: "e5", kind: "tv" as const }, bindings: [], libraryRoot: root };
expect(resolveTargetDirectory({ ...tv, naming: { title: "x" }, templates: DEFAULT_TEMPLATES })).toEqual({
kind: "error",
reason: "season_required",
});
expect(resolveTargetDirectory({ ...tv, naming: {}, season: 1, templates: DEFAULT_TEMPLATES })).toMatchObject({
kind: "error",
missing: ["title"],
});
expect(
resolveTargetDirectory({
...tv,
naming: { title: "x" },
season: 1,
templates: { ...DEFAULT_TEMPLATES, seasonDir: "S{episode}" },
}),
).toMatchObject({ kind: "error", missing: ["episode"] });
});
});
describe("命名模板", () => {
it("电影 / 剧集 / 多集区间 / 综艺日期", () => {
const movie = buildNamingContext(
{ title: "沙丘2", year: 2024 },
parseRelease("Dune.Part.Two.2024.2160p.UHD.BluRay.x265-CHD"),
);
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, movie, "MKV")).toEqual({
ok: true,
fileName: "沙丘2 (2024) - 2160p.mkv",
});
const cut = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay"));
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, cut, "mkv")).toMatchObject({
fileName: "指环王 (2001) - Extended - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(ep.seasonEpisode).toBe("S02E01-E03");
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03.mkv",
});
const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p"));
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, daily, "mp4")).toMatchObject({
fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4",
});
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, { title: "x" }, "mp4")).toEqual({
ok: false,
missing: ["episode_or_air_date"],
});
expect(renderMediaFileName("movie", DEFAULT_TEMPLATES, {}, "mp4")).toEqual({ ok: false, missing: ["title"] });
});
it("上下文完整字段", () => {
const ctx = buildNamingContext(
{ title: "T", originalTitle: "O", year: 2020 },
parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"),
);
expect(ctx).toMatchObject({
originalTitle: "O",
episode: 5,
part: "CD2",
source: "WEB-DL",
videoCodec: "H.265",
hdr: "DV",
releaseGroup: "GRP",
});
expect(buildNamingContext({ title: "T" })).toEqual({ title: "T" });
});
it("格式化与可选片段", () => {
expect(renderTemplate("S{season:02}E{episode:03}", { season: 1, episode: 5 })).toEqual({
ok: true,
value: "S01E005",
});
expect(renderTemplate("{airDate:dot}", { airDate: "20240101" })).toEqual({ ok: true, value: "2024.01.01" });
expect(renderTemplate("{title:zz}", { title: "a" })).toEqual({ ok: true, value: "a" });
expect(renderTemplate("A[[ - {part}]] B", { part: "" })).toEqual({ ok: true, value: "A B" });
expect(renderTemplate("{title}/{year}", { title: "a/b" })).toEqual({ ok: false, missing: ["year"] });
expect(renderTemplate(compileTemplate("{title}"), { title: "a/b" })).toEqual({ ok: true, value: "a/b" });
expect(compileTemplate("{title}[[ ({year})]]").vars).toEqual(["title", "year"]);
});
it("模板校验:白名单、括号", () => {
for (const bad of ["{evil}", "{title", "a]]", "[[a", "[[ [[x]] ]]", "x".repeat(600)])
expect(() => compileTemplate(bad), bad).toThrow(TemplateError);
expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow();
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, dailyFile: "{constructor}" })).toThrow(TemplateError);
});
});
describe("非法字符处理", () => {
it("转全角、去控制字符、首尾点空白、保留名、空串", () => {
expect(sanitizeSegment('A:B*C?"D<E>F|G\\H/I')).toBe("A:B*C?"D<E>F|G\H/I");
expect(sanitizeSegment(" ..hidden. ")).toBe("hidden");
expect(sanitizeSegment("a\u0000b\u001f\u007fc")).toBe("abc");
expect(sanitizeSegment("CON")).toBe("CON_");
expect(sanitizeSegment("...")).toBe("_");
expect(sanitizeSegment("a b")).toBe("a b");
});
it("UTF-8 字节截断,扩展名保留", () => {
const long = "长".repeat(200);
const s = sanitizeSegment(long);
expect(utf8Length(s)).toBeLessThanOrEqual(255);
const f = sanitizeFileName(long, ".MKV");
expect(f.endsWith(".mkv")).toBe(true);
expect(utf8Length(f)).toBeLessThanOrEqual(255);
expect(sanitizeFileName("a", "")).toBe("a");
});
});
describe("冲突检测", () => {
it("计划内重复 / 已存在 / 大小写碰撞", () => {
const out = detectPathConflicts(
[
{ src: "1", dst: "/m/A.mkv" },
{ src: "2", dst: "/m/A.mkv" },
{ src: "3", dst: "/m/b.mkv" },
{ src: "4", dst: "/m/C.mkv" },
{ src: "5", dst: "/m/c.mkv" },
],
[{ path: "/m/A.mkv", size: 1 }, { path: "/m/B.mkv" }],
{ caseInsensitive: true },
);
expect(out.map((c) => `${c.kind}:${c.dst}`)).toEqual([
"duplicate_in_plan:/m/A.mkv",
"exists:/m/A.mkv",
"case_collision:/m/b.mkv",
"case_collision:/m/c.mkv",
]);
expect(detectPathConflicts([{ src: "3", dst: "/m/b.mkv" }], [{ path: "/m/B.mkv" }])).toEqual([]);
});
});
describe("下载器 ↔ 宿主路径映射", () => {
const maps = [
{ instanceId: "qb1", downloaderPath: "/downloads", hostPath: "/mnt/cache/qb1" },
{ instanceId: "qb1", downloaderPath: "/downloads/tv", hostPath: "/mnt/array/tv-dl" },
{ instanceId: "qb2", downloaderPath: "D:\\Downloads\\", hostPath: "/mnt/win" },
{ instanceId: "qb3", downloaderPath: "/", hostPath: "/srv/qb3" },
];
it("最长前缀、按段边界、多实例", () => {
expect(toHostPath("/downloads/movie/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/mnt/cache/qb1/movie/a.mkv",
});
expect(toHostPath("/downloads/tv/s/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/mnt/array/tv-dl/s/a.mkv",
});
expect(toHostPath("/downloads2/a.mkv", "qb1", maps)).toEqual({ ok: false, reason: "no_mapping" });
expect(toHostPath("/downloads", "qb1", maps)).toMatchObject({ ok: true, path: "/mnt/cache/qb1" });
expect(toHostPath("D:\\Downloads\\x\\a.mkv", "qb2", maps)).toMatchObject({ ok: true, path: "/mnt/win/x/a.mkv" });
expect(toHostPath("/any/a.mkv", "qb3", maps)).toMatchObject({ ok: true, path: "/srv/qb3/any/a.mkv" });
expect(toHostPath("/downloads/a", "nope", maps)).toEqual({ ok: false, reason: "no_mapping" });
expect(toDownloaderPath("/mnt/cache/qb1/movie/a.mkv", "qb1", maps)).toMatchObject({
ok: true,
path: "/downloads/movie/a.mkv",
});
expect(toDownloaderPath("/srv/qb3/a", "qb3", maps)).toMatchObject({ ok: true, path: "/a" });
});
it("同前缀多目标 → ambiguous / 配置校验", () => {
const bad = [
{ instanceId: "q", downloaderPath: "/d", hostPath: "/a" },
{ instanceId: "q", downloaderPath: "/d/", hostPath: "/b" },
];
expect(toHostPath("/d/x", "q", bad)).toEqual({ ok: false, reason: "ambiguous" });
expect(validatePathMappings(bad)).toHaveLength(1);
expect(validatePathMappings(maps)).toEqual([]);
});
});