import { describe, expect, it } from "vitest"; import { resolveFilesMock } from "~/api/files.ts"; import { clickSelect, collapseCrumbs, deleteBlockText, emptySelection, entryStatusView, formatEpisodes, moveFocus, nameProblem, opDisabledReason, parseEpisodes, pruneSelection, selectAll, shiftEpisodes, splitCommon, stemRange, suggestName, } from "./model.ts"; import { rowProblem, rowsFromIdentify, setRowEntity, type WizardRow } from "./organize-wizard.tsx"; const order = ["a", "b", "c", "d", "e"]; describe("选择模型", () => { it("单击单选,Ctrl 切换,Shift 区间", () => { let s = clickSelect(emptySelection(), order, "b", {}); expect([...s.keys]).toEqual(["b"]); s = clickSelect(s, order, "d", { shift: true }); expect([...s.keys]).toEqual(["b", "c", "d"]); s = clickSelect(s, order, "a", { shift: true }); expect([...s.keys]).toEqual(["a", "b"]); s = clickSelect(s, order, "e", { ctrl: true }); expect([...s.keys].sort()).toEqual(["a", "b", "e"]); s = clickSelect(s, order, "e", { ctrl: true }); expect([...s.keys].sort()).toEqual(["a", "b"]); }); it("Shift+Ctrl 区间并入已有选择", () => { let s = clickSelect(emptySelection(), order, "a", {}); s = clickSelect(s, order, "c", { ctrl: true }); s = clickSelect(s, order, "e", { shift: true, ctrl: true }); expect([...s.keys].sort()).toEqual(["a", "c", "d", "e"]); }); it("方向键移动焦点,Shift 扩选,边界夹住", () => { let s = moveFocus(emptySelection(), order, 1, false); expect(s.focus).toBe("a"); s = moveFocus({ ...s, anchor: "a", keys: new Set(["a"]) }, order, 2, true); expect([...s.keys]).toEqual(["a", "b", "c"]); s = moveFocus(s, order, 99, false); expect(s.focus).toBe("e"); s = moveFocus(s, order, -99, false); expect(s.focus).toBe("a"); }); it("全选与刷新后剪枝", () => { const s = selectAll(order, emptySelection()); expect(s.keys.size).toBe(5); const p = pruneSelection({ ...s, focus: "e", anchor: "z" }, ["a", "e"]); expect([...p.keys]).toEqual(["a", "e"]); expect(p.anchor).toBeNull(); expect(p.focus).toBe("e"); }); }); const base = { name: "x.mkv", path: "x.mkv", type: "file" as const, size: 1, mtime: null, ext: "mkv", fileKind: "video" as const, status: "plain" as const, escape: false, asset: null, download: null, media: null, dirSummary: null, }; describe("状态与禁用原因", () => { it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => { expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已登记"); expect( entryStatusView({ ...base, status: "seeding", download: { taskId: "t", status: "completed", progress: 1, hnr: "unsatisfied" }, }), ).toMatchObject({ tone: "warning" }); expect( entryStatusView({ ...base, status: "downloading", download: { taskId: "t", status: "downloading", progress: 0.425, hnr: "unknown" }, })?.label, ).toBe("下载中 42%"); expect(entryStatusView({ ...base, escape: true })?.tone).toBe("destructive"); expect(entryStatusView(base)).toBeNull(); }); it("做种中不能改名/删除;只读登记根不能移动;未登记不能刮削", () => { const lib = { kind: "library" as const, writable: true }; const seeding = { ...base, status: "seeding" as const, download: { taskId: "t", status: "completed" as const, progress: 1, hnr: "unsatisfied" as const }, }; expect(opDisabledReason("rename", seeding, lib)).toMatch(/做种/); expect(opDisabledReason("delete", seeding, lib)).toMatch(/下载任务/); expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/); expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/); expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull(); expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/); expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull(); expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/登记目录之外/); }); it("删除阻断分软硬", () => { expect(deleteBlockText("needs_retire")?.soft).toBe(true); expect(deleteBlockText("download_active")?.soft).toBe(false); expect(deleteBlockText(null)).toBeNull(); }); }); describe("路径与名称", () => { it("面包屑折叠保留根与末两段", () => { expect(collapseCrumbs([1, 2, 3, 4, 5], 3)).toEqual([1, null, 4, 5]); expect(collapseCrumbs([1, 2], 3)).toEqual([1, 2]); }); it("名称校验与改名默认选中主名", () => { expect(nameProblem("a/b")).toBeTruthy(); expect(nameProblem("..")).toBeTruthy(); expect(nameProblem(" ok.mkv ")).toBeNull(); expect(stemRange("Movie (2024).mkv", false)).toEqual([0, 12]); expect(stemRange(".hidden", false)).toEqual([0, 7]); expect(stemRange("dir.name", true)).toEqual([0, 8]); }); it("前后路径按段取公共前缀", () => { expect(splitCommon("/a/b/c.mkv", "/a/b/d.mkv")).toEqual({ common: "/a/b/", a: "c.mkv", b: "d.mkv" }); expect(splitCommon("/x/1.mkv", "/y/1.mkv").common).toBe("/"); }); it("集号解析/格式化/偏移", () => { expect(parseEpisodes("3-5")).toEqual([3, 4, 5]); expect(parseEpisodes("3,7")).toEqual([3, 7]); expect(parseEpisodes("x")).toBeNull(); expect(parseEpisodes("")).toEqual([]); expect(formatEpisodes([5, 3, 4])).toBe("3-5"); expect(formatEpisodes([1, 3])).toBe("1,3"); expect(shiftEpisodes([1, 2], -1)).toEqual([1, 1]); expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]); }); it("建议名:电影与剧集", () => { expect( suggestName({ title: "沙丘2", year: 2024, kind: "movie" }, { season: null, episodes: [] }, "a.mkv", false), ).toBe("沙丘2 (2024).mkv"); expect( suggestName({ title: "人生切割术", year: 2022, kind: "series" }, { season: 2, episodes: [1] }, "a.mkv", false), ).toBe("人生切割术 (2022) - S02E01.mkv"); expect(suggestName({ title: "A/B", year: null, kind: "movie" }, { season: null, episodes: [] }, "d", true)).toBe( "A B", ); }); }); const item = (path: string, over: Partial<{ id: string | null; level: "high" | "low" }> = {}) => ({ root: "lib:x", path, parsed: { title: "T", year: null, season: 1, episodes: [2], resolution: null, source: null, releaseGroup: null, mediaKind: "series" as const, }, candidates: over.id === null ? [] : [ { mediaEntityId: over.id ?? "e1", provider: "local", externalId: null, title: "T", year: 2020, kind: "series" as const, level: over.level ?? ("high" as const), score: 1, reasons: [], }, ], suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null }, existing: { assetId: null }, warnings: [], }); describe("整理向导门禁", () => { it("高置信自动选中;低置信/无候选为未决,排除后放行", () => { const rows = rowsFromIdentify( [item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })], new Set(), ); expect(rows[0]?.entity?.id).toBe("e1"); expect(rows[0]?.episodes).toBe("2"); expect(rowProblem(rows[0] as WizardRow)).toBeNull(); expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/); expect(rowProblem({ ...(rows[2] as WizardRow), excluded: true })).toBeNull(); }); it("改作品时同目录未手动改过的行跟随,别的目录不动", () => { let rows = rowsFromIdentify( [item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })], new Set(), ); const e = { id: "e9", title: "X", year: 2001, kind: "series" }; rows = setRowEntity(rows, rows[0]?.key as string, e); expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]); rows = setRowEntity(rows, rows[1]?.key as string, { ...e, id: "e8" }); rows = setRowEntity(rows, rows[0]?.key as string, { ...e, id: "e7" }); expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]); }); it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => { const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"])); expect(rows[0]?.hnrBlocked).toBe(true); expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/); expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/); }); }); describe("演示开关", () => { it("只在开发构建生效,?mock=1 记住,?mock=0 清掉", () => { const store = new Map(); const storage = { getItem: (k: string) => store.get(k) ?? null, setItem: (k: string, v: string) => void store.set(k, v), removeItem: (k: string) => void store.delete(k), }; expect(resolveFilesMock({ dev: false, search: "?mock=1", storage })).toBe(false); expect(resolveFilesMock({ dev: true, search: "?mock=1", storage })).toBe(true); expect(resolveFilesMock({ dev: true, search: "", storage })).toBe(true); expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false); }); });