Files
UHD_Media_Tool/packages/domain/test/naming.test.ts
T
rootandClaude Opus 5.5 0ba947dce3 copy(domain): 命名模板、规则判定、收录版本评估文案改全角标点并去掉内部用语
阶梯 pri / 候选 / 资产交付特征等改成界面用词;同步断言。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:52:23 +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);
}
});
});