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"; const root = { id: "lib-tv", hostPath: "/media/tv" }; const binding = (over: Partial): 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?"DF|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([]); }); });