Files
UHD_Media_Tool/packages/domain/test/path.test.ts
T
rootandClaude Fable 5.1 304feea141 chore(deno): migrate toolchain from Node 24 + pnpm to Deno 2.9.7 + TypeScript 7.0.2 (W1.5)
- 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]>
2026-09-27 18:53:06 +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/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: 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([]);
});
});