Files
UHD_Media_Tool/packages/domain/test/path.test.ts
T
rootandClaude Opus 5.5 f7c75ad781 feat(domain): naming template engine (Jinja safe subset, MP variables, path layout)
- naming/engine: lexer/parser/AST/render for {{ }} / if-elif-else / whitelisted filters
  (default pad pad2 upper lower trim replace truncate date join); rejects for/set/attribute/
  subscript/call/~/% with line:col errors and 'did you mean' hints
- naming/variables: variable table aligned with MoviePilot names plus ours (cut, airDate,
  issue, slotName, libraryRoot ...); buildNamingVars from parsed release + entity + units
- naming/layout: split by literal '/', segment roles (category/root/season/sub/file),
  value escaping, reserved names, 255-byte segments, validateNamingTemplate
- naming/defaults: MP default movie/tv + our variety template, v1 syntax upgrade,
  entity > root > global override resolution, builtin preview samples
- path/binding: resolveTargetDirectory keeps bound dirs, only unbound segments render;
  renderMediaFileName = last segment of the full template; DEFAULT_TEMPLATES = MP v2
- differential: 34 cases vs MP (21 identical, 13 documented deviations)

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

300 lines
11 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,
DEFAULT_TEMPLATES,
detectPathConflicts,
NamingTemplateError,
type PathBinding,
parseRelease,
renderMediaFileName,
resolveTargetDirectory,
sanitizeFileName,
sanitizeSegment,
toDownloaderPath,
toHostPath,
utf8Length,
validatePathMappings,
validateTemplateSet,
} from "../src/index.ts";
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: 2 }],
});
});
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, tv: "{{title}}/{{evil}}/{{title}}{{fileExt}}" },
}),
).toMatchObject({ kind: "error", reason: "template_error" });
});
});
describe("命名模板(旧入口 buildNamingContext / renderMediaFileName)", () => {
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) - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(ep.season_episode).toBe("S02E01-E03");
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03 - 第 1-3 集.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")).toMatchObject({ ok: false, missing: ["title"] });
});
it("上下文完整字段(MP 变量名)", () => {
const ctx = buildNamingContext(
{ title: "T", originalTitle: "O", year: 2020 },
parseRelease("T.2020.S01E05.CD2.2160p.WEB-DL.DV.HEVC-GRP"),
);
expect(ctx).toMatchObject({
original_title: "O",
season: "1",
episode: "5",
season_episode: "S01E05",
part: "CD2",
resourceType: "WEB-DL",
videoCodec: "H265",
effect: "DV",
edition: "WEB-DL DV",
resource_term: "WEB-DL DV 2160p",
releaseGroup: "GRP",
type: "电视剧",
});
expect(buildNamingContext({ title: "T" })).toEqual({
title: "T",
title_year: "T",
type: "电影",
total_episodes: 0,
});
});
it("模板集校验", () => {
expect(() => validateTemplateSet(DEFAULT_TEMPLATES)).not.toThrow();
expect(() => validateTemplateSet({ ...DEFAULT_TEMPLATES, variety: "{{constructor}}" })).toThrow(
NamingTemplateError,
);
});
});
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([]);
});
});