Files
UHD_Media_Tool/packages/domain/test/naming.test.ts
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

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

392 lines
17 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,
buildNamingVars,
compileNaming,
DEFAULT_TEMPLATES,
LEGACY_V1_NAMING,
MP_DEFAULT_NAMING,
NAMING_SAMPLES,
NAMING_VARIABLE_NAMES,
NAMING_VARIABLES,
NamingTemplateError,
namingKindOf,
parseRelease,
renderMediaFileName,
renderNamingPath,
renderNamingString,
resolveNamingTemplates,
resolveTargetDirectory,
tryCompileNaming,
upgradeLegacyTemplate,
validateNamingTemplate,
} from "../src/index.ts";
const known = { knownVars: NAMING_VARIABLE_NAMES };
const r = (tpl: string, vars: Record<string, string | number> = {}) => renderNamingString(tpl, vars);
const issue = (tpl: string) => {
const c = tryCompileNaming(tpl, known);
return c.ok ? undefined : c.issues[0];
};
describe("语法", () => {
it("输出、or、默认值、注释、去空白", () => {
expect(r("{{ title }}", { title: "A" })).toBe("A");
expect(r("{{ en_title or title }}", { title: "A" })).toBe("A");
expect(r("{{ en_title or title or 'x' }}", {})).toBe("x");
expect(r("{{ year | default('未知') }}", {})).toBe("未知");
expect(r("a{# 注释 #}b")).toBe("ab");
expect(r("a {%- if 1 -%} b {%- endif %}")).toBe("ab");
expect(r("{{ missing }}")).toBe("");
});
it("if / elif / else 嵌套与比较", () => {
const t = "{% if n > 10 %}big{% elif n == 1 %}one{% elif not n %}none{% else %}mid{% endif %}";
expect(r(t, { n: 11 })).toBe("big");
expect(r(t, { n: "1" })).toBe("one");
expect(r(t, {})).toBe("none");
expect(r(t, { n: 5 })).toBe("mid");
expect(r("{% if a and (b or c) %}y{% endif %}", { a: 1, c: "x" })).toBe("y");
expect(r("{% if a != 'x' %}y{% endif %}", { a: "z" })).toBe("y");
expect(r("{% if a >= 2 and a <= 3 %}y{% endif %}", { a: "3" })).toBe("y");
expect(r("{% if a > 1 %}y{% endif %}", { a: "abc" })).toBe("");
expect(r("{% if total_episodes %}y{% else %}n{% endif %}", { total_episodes: 0 })).toBe("n");
});
it("过滤器", () => {
expect(r("{{ season | pad(2) }}", { season: "3" })).toBe("03");
expect(r("{{ episode | pad(3) }}", { episode: "1-3" })).toBe("001-003");
expect(r("{{ season | pad2 }}", { season: 7 })).toBe("07");
expect(r("{{ t | upper }}|{{ t | lower }}|{{ ' a ' | trim }}", { t: "Ab" })).toBe("AB|ab|a");
expect(r("{{ t | replace(' ', '.') }}", { t: "a b c" })).toBe("a.b.c");
expect(r("{{ t | truncate(3) }}", { t: "绝命毒师" })).toBe("绝命毒");
expect(r("{{ d | date('YYYY-MM-DD') }}/{{ d | date('YYYY.MM.DD') }}", { d: "20260927" })).toBe(
"2026-09-27/2026.09.27",
);
expect(r("{{ d | date('YYYYMMDD') }}", { d: "2026-09-27" })).toBe("20260927");
expect(renderNamingString("{{ l | join(',') }}", { l: ["a", "b"] })).toBe("a,b");
expect(r("{{ x | default('-') | upper }}", {})).toBe("-");
});
it("编译错误带行列号与原因", () => {
expect(issue("{% for x in y %}{% endfor %}")).toMatchObject({ code: "unsupported", line: 1, col: 1 });
expect(issue("a\n{{ title.__class__ }}")).toMatchObject({ code: "attribute_access", line: 2, col: 9 });
expect(issue("{{ title[0] }}")?.code).toBe("subscript");
expect(issue("{{ lipsum() }}")?.code).toBe("call");
expect(issue("{{ title ~ year }}")?.code).toBe("concat");
expect(issue("{{ '%02d' % season }}")?.message).toContain("pad(2)");
expect(issue("{{ title if year else 'x' }}")?.code).toBe("inline_if");
expect(issue("{% set a = 1 %}")?.code).toBe("unsupported");
expect(issue("{{ title")?.code).toBe("unclosed_tag");
expect(issue("{% if title %}x")?.code).toBe("unclosed_if");
expect(issue("{% endif %}")?.code).toBe("stray_endif");
expect(issue("{% else %}")?.code).toBe("stray_else");
expect(issue("{% if title %}{% else %}{% elif year %}{% endif %}")?.code).toBe("syntax");
expect(issue("{{ }}")?.code).toBe("empty_output");
expect(issue("x }}")?.code).toBe("stray_close");
expect(issue("{{ title | shout }}")?.code).toBe("unknown_filter");
expect(issue("{{ title | uper }}")?.message).toContain("是否指 upper");
expect(issue("{{ season | pad('x') }}")?.code).toBe("filter_arg");
expect(issue("{{ airDate | date('MM/DD') }}")?.code).toBe("filter_arg");
expect(issue("{{ title | replace('a') }}")?.code).toBe("filter_arity");
expect(issue("{{ videoformat }}")?.message).toBe("未知变量 videoformat,是否指 videoFormat");
expect(issue("{{ constructor }}")?.code).toBe("unknown_variable");
expect(issue("{{ title | constructor }}")?.code).toBe("unknown_filter");
expect(tryCompileNaming("{% constructor %}").ok).toBe(false);
expect(renderNamingString("{{ constructor }}{{ __proto__ }}", {})).toBe("");
expect(issue("{{ 1.5 }}")?.code).toBe("unsupported_number");
expect(() => compileNaming("{{ a.b }}")).toThrow(NamingTemplateError);
});
it("限制:长度、嵌套深度、节点数、输出字节", () => {
expect(issue("x".repeat(1025))?.code).toBe("too_long");
const deep = `${"{% if title %}".repeat(5)}x${"{% endif %}".repeat(5)}`;
expect(issue(deep)?.code).toBe("too_deep");
expect(tryCompileNaming("{{a}}".repeat(200)).ok).toBe(true);
expect(tryCompileNaming("{{a}}".repeat(205)).ok).toBe(false);
expect(() => renderNamingString("{{a}}{{a}}", { a: "x".repeat(3000) })).toThrow(/4096/);
});
it("收集引用变量", () => {
expect(compileNaming(MP_DEFAULT_NAMING.movie).vars).toEqual(["title", "year", "part", "videoFormat", "fileExt"]);
});
});
describe("变量表与上下文", () => {
it("变量名唯一,覆盖 MP 命名变量", () => {
expect(new Set(NAMING_VARIABLE_NAMES).size).toBe(NAMING_VARIABLES.length);
const mp =
"title en_title original_title name year title_year season season_fmt season_year episode season_episode total_episodes episode_title episode_date part videoFormat resourceType effect edition resource_term releaseGroup videoCodec videoBit audioCodec webSource fps customization fileExt type category tmdbid imdbid doubanid".split(
" ",
);
for (const v of mp) expect(NAMING_VARIABLE_NAMES, v).toContain(v);
expect(NAMING_VARIABLES.filter((v) => v.ours).map((v) => v.name)).toEqual([
"cut",
"contentType",
"segment",
"airDate",
"issue",
"slotName",
"libraryRoot",
]);
});
it("季集元数据、收录版本、库根、外部 ID", () => {
const v = buildNamingVars(
{
title: "绝命毒师",
year: 2008,
tmdbId: 1396,
imdbId: "tt0903747",
slotName: "4K HDR",
libraryRoot: "电视剧-A",
category: "欧美剧",
seasons: { 1: { year: 2008, episodeTotal: 7 } },
episodes: { "1:1": { title: "Pilot", airDate: "2008-01-20" } },
},
parseRelease("Breaking.Bad.S01E01.2160p.UHD.BluRay.REMUX.DV.HDR10.HEVC.TrueHD.7.1-FGT.mkv"),
{ fileExt: "MKV" },
);
expect(v).toMatchObject({
season: "1",
season_fmt: "S01",
season_year: "2008",
total_episodes: 7,
episode_title: "Pilot",
episode_date: "2008-01-20",
resourceType: "UHD BluRay REMUX",
tmdbid: "1396",
imdbid: "tt0903747",
slotName: "4K HDR",
libraryRoot: "电视剧-A",
category: "欧美剧",
fileExt: ".mkv",
type: "电视剧",
});
const daily = buildNamingVars({ title: "奔跑吧", kind: "variety" }, parseRelease("奔跑吧 20240426 第1期"));
expect(daily).toMatchObject({ type: "综艺", season: "1", airDate: "20240426", issue: "1" });
expect(daily.episode).toBeUndefined();
expect(buildNamingVars({ title: "T" }, parseRelease("T.S01-S02.1080p")).season_fmt).toBe("S01-S02");
});
it("namingKindOf", () => {
expect(["movie", "series", "variety", "collection"].map(namingKindOf)).toEqual(["movie", "tv", "variety", "movie"]);
});
});
describe("路径:段、角色、清理", () => {
it("段角色:分类前缀 / 作品根 / 季 / 子目录 / 文件", () => {
const res = renderNamingPath(
"{{type}}/{{category}}/{{title_year}}/Season {{season}}/{{edition}}/{{title}} - {{season_episode}}{{fileExt}}",
{
type: "电视剧",
category: "",
title: "X",
title_year: "X (2008)",
season: "1",
season_episode: "S01E01",
edition: "WEB-DL",
fileExt: ".mkv",
},
);
expect(res.ok).toBe(true);
expect(res.relativePath).toBe("电视剧/X (2008)/Season 1/WEB-DL/X - S01E01.mkv");
expect(res.segments.map((s) => s.role)).toEqual(["category", "root", "season", "sub", "file"]);
});
it("模板里的 \\ 视为目录分隔,多余的 / 合并", () => {
expect(renderNamingPath("/{{title}}\\\\{{title}}{{fileExt}}/", { title: "A", fileExt: ".mkv" }).relativePath).toBe(
"A/A.mkv",
);
});
it("值里的非法字符与 / 转全角,控制字符删除,合并空白", () => {
const res = renderNamingPath("{{title}}/{{title}} - {{releaseGroup}}{{fileExt}}", {
title: 'A/B:C*D?"E<F>G|H\\I\u0001',
releaseGroup: "x/y",
fileExt: ".mkv",
});
expect(res.relativePath).toBe("A/B:C*D?"E<F>G|H\I/A/B:C*D?"E<F>G|H\I - x/y.mkv");
});
it("保留名、首尾点、长度截断", () => {
expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "nul", fileExt: ".mkv" }).relativePath).toBe(
"nul_/nul_.mkv",
);
expect(renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "..a..", fileExt: ".mkv" }).relativePath).toBe(
"a/a.mkv",
);
const long = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { title: "é".repeat(300), fileExt: ".mkv" });
const [dir, file] = long.relativePath.split("/");
expect(new TextEncoder().encode(dir).length).toBeLessThanOrEqual(255);
expect(new TextEncoder().encode(file).length).toBeLessThanOrEqual(255);
expect(file!.endsWith(".mkv")).toBe(true);
const tooLong = renderNamingPath(`${"{{title}}/".repeat(20)}{{title}}{{fileExt}}`, {
title: "长".repeat(85),
fileExt: ".mkv",
});
expect(tooLong.ok).toBe(false);
expect(tooLong.errors[0]?.code).toMatch(/_too_long$/);
});
it("空段:分类/子目录丢弃,作品根/文件名为空报错,识别不出作品根报错", () => {
const empty = renderNamingPath("{{title}}/{{title}}{{fileExt}}", { fileExt: ".mkv" });
expect(empty.ok).toBe(false);
expect(empty.errors[0]?.code).toBe("empty_segment");
expect(empty.missing).toEqual(["title"]);
expect(renderNamingPath("{{title}}{{fileExt}}", { title: "A", fileExt: ".mkv" }).errors[0]?.code).toBe(
"no_work_root",
);
expect(
renderNamingPath("{{type}}/{{title}}{{fileExt}}", { type: "电影", title: "A", fileExt: ".mkv" }).errors[0]?.code,
).toBe("no_work_root");
});
});
describe("校验(保存前)", () => {
it("默认模板全部通过且角色正确", () => {
for (const k of ["movie", "tv", "variety"] as const) {
const v = validateNamingTemplate(MP_DEFAULT_NAMING[k], k);
expect(v, k).toMatchObject({ ok: true, errors: [] });
expect(v.roles.map((x) => x.role)).toEqual(k === "movie" ? ["root", "file"] : ["root", "season", "file"]);
}
for (const k of ["movie", "tv", "variety"] as const)
expect(validateNamingTemplate(LEGACY_V1_NAMING[k], k).ok, k).toBe(true);
});
it("缺 fileExt、未知变量、作品根;可能为空的提示", () => {
expect(validateNamingTemplate("{{title}}/{{title}}", "movie").errors[0]).toMatchObject({
code: "missing_file_ext",
message: "文件名缺少 {{fileExt}}:模板必须以 {{fileExt}} 结尾",
});
expect(validateNamingTemplate("{{title}}/{{titel}}{{fileExt}}", "movie").errors[0]?.message).toContain(
"是否指 title",
);
expect(validateNamingTemplate("{{title}} ({{year}})/{{title}}{{fileExt}}", "movie").warnings).toEqual([
"year 可能为空,建议写成 {% if year %}…{{year}}…{% endif %}",
]);
expect(validateNamingTemplate(MP_DEFAULT_NAMING.tv, "tv").warnings).toEqual([]);
});
});
describe("v1 旧语法迁移", () => {
it("{var}、{var:02}、{airDate:dash}、[[ ]] → 新语法", () => {
expect(upgradeLegacyTemplate("{title}[[ ({year})]]")).toBe("{{title}}{% if year %} ({{year}}){% endif %}");
expect(upgradeLegacyTemplate("S{season:02}")).toBe("S{{season | pad(2)}}");
expect(upgradeLegacyTemplate("{airDate:dash}[[ - 第{issue}期]]")).toBe(
"{{airDate | date('YYYY-MM-DD')}}{% if issue %} - 第{{issue}}期{% endif %}",
);
expect(upgradeLegacyTemplate("{resolution} {seasonEpisode}")).toBe("{{videoFormat}} {{season_episode}}");
});
it("v1 模板迁移后输出与旧实现一致", () => {
const movie = buildNamingContext({ title: "指环王", year: 2001 }, parseRelease("LOTR.2001.Extended.1080p.BluRay"));
expect(renderMediaFileName("movie", LEGACY_V1_NAMING, movie, "mkv")).toEqual({
ok: true,
fileName: "指环王 (2001) - Extended - 1080p.mkv",
});
const ep = buildNamingContext({ title: "庆余年" }, parseRelease("Show.S02E01-E03.1080p"));
expect(renderMediaFileName("tv", LEGACY_V1_NAMING, ep, "mkv")).toMatchObject({
fileName: "庆余年 - S02E01-E03.mkv",
});
const daily = buildNamingContext({ title: "奔跑吧" }, parseRelease("奔跑吧 第十二季 20240426 第1期 1080p"));
expect(renderMediaFileName("tv", LEGACY_V1_NAMING, daily, "mp4")).toMatchObject({
fileName: "奔跑吧 - 2024-04-26 - 第1期.mp4",
});
});
});
describe("覆盖顺序:作品 > 库根 > 全局(按类型回落)", () => {
it("resolveNamingTemplates", () => {
const res = resolveNamingTemplates({
global: MP_DEFAULT_NAMING,
root: { tv: "R-tv", movie: "" },
entity: { tv: "E-tv", variety: "E-var" },
});
expect(res.templates).toEqual({ movie: MP_DEFAULT_NAMING.movie, tv: "E-tv", variety: "E-var" });
expect(res.sources).toEqual({ movie: "global", tv: "entity", variety: "entity" });
expect(resolveNamingTemplates({ global: MP_DEFAULT_NAMING, root: { tv: "R-tv" }, entity: null }).sources.tv).toBe(
"root",
);
});
});
describe("路径绑定不变", () => {
const root = { id: "L", hostPath: "/media/tv" };
it("已有作品绑定:目录用绑定路径,文件名用新模板(差分 #24)", () => {
const naming = buildNamingContext({ title: "新名", year: 2008 }, parseRelease("Show.S01E02.1080p"));
const dec = resolveTargetDirectory({
entity: { id: "e", kind: "tv" },
naming,
season: 1,
bindings: [
{
id: "b",
entityId: "e",
scope: "entity",
libraryRootId: "L",
relativePath: "旧名 (2008)",
templateVersion: 1,
},
],
libraryRoot: root,
templates: DEFAULT_TEMPLATES,
});
expect(dec).toMatchObject({ kind: "new", relativePath: "旧名 (2008)/Season 1", reusedBindingIds: ["b"] });
expect(renderMediaFileName("tv", DEFAULT_TEMPLATES, naming, "mkv")).toMatchObject({
fileName: "新名 - S01E02 - 第 2 集.mkv",
});
});
it("首次绑定:作品根 + 分类前缀进绑定,子目录段在绑定之后照常渲染", () => {
const templates = {
...DEFAULT_TEMPLATES,
version: 7,
movie: "{{type}}/{{title_year}}/{{edition}}/{{title}}{{fileExt}}",
};
const naming = buildNamingContext({ title: "X", year: 2020 }, parseRelease("X.2020.1080p.BluRay.DV.x265-A"));
const first = resolveTargetDirectory({
entity: { id: "m", kind: "movie" },
naming,
bindings: [],
libraryRoot: root,
templates,
});
expect(first).toMatchObject({
kind: "new",
relativePath: "电影/X (2020)/BluRay DV",
newBindings: [{ scope: "entity", relativePath: "电影/X (2020)", templateVersion: 7 }],
});
const again = resolveTargetDirectory({
entity: { id: "m", kind: "movie" },
naming: { ...naming, title_year: "改名 (2020)" },
bindings: [
{
id: "b",
entityId: "m",
scope: "entity",
libraryRootId: "L",
relativePath: "电影/X (2020)",
templateVersion: 7,
},
],
libraryRoot: root,
templates,
});
expect(again).toMatchObject({ kind: "existing", relativePath: "电影/X (2020)/BluRay DV" });
});
it("没有季目录的模板:季绑定等于作品目录", () => {
const dec = resolveTargetDirectory({
entity: { id: "e", kind: "tv" },
naming: buildNamingContext({ title: "X" }, parseRelease("X.S02E01")),
season: 2,
bindings: [],
libraryRoot: root,
templates: { ...DEFAULT_TEMPLATES, tv: "{{title_year}}/{{title}} {{season_fmt}}{{fileExt}}" },
});
expect(dec).toMatchObject({
kind: "new",
relativePath: "X",
newBindings: [{ relativePath: "X" }, { relativePath: "X" }],
});
});
});
describe("内置样本", () => {
it("六条样本都能用默认模板渲染", () => {
expect(NAMING_SAMPLES).toHaveLength(6);
for (const s of NAMING_SAMPLES) {
const ext = s.fileName.slice(s.fileName.lastIndexOf("."));
const vars = buildNamingVars({ ...s.entity, kind: s.kind }, parseRelease(s.fileName), { fileExt: ext });
const res = renderNamingPath(MP_DEFAULT_NAMING[s.kind], vars);
expect(res.ok, `${s.label} ${JSON.stringify(res.errors)}`).toBe(true);
}
});
});