- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示) - Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话 - 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别 - 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务; 哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS - 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随 Co-Authored-By: Claude Opus 5.5 <[email protected]>
392 lines
17 KiB
TypeScript
392 lines
17 KiB
TypeScript
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);
|
||
}
|
||
});
|
||
});
|