import { describe, expect, it } from "vitest"; import { resolveFilesMock } from "~/api/files.ts"; import { applyIdentifyCompleted, clickSelect, collapseCrumbs, cueTime, deleteBlockText, emptySelection, entryStatusView, entrySubline, formatEpisodes, hoverOps, initialLocation, menuModel, moveFocus, nameProblem, nameTaken, opApplies, opDisabledReason, parseEpisodes, prettyJson, previewKindOf, pruneSelection, selectAll, shiftEpisodes, splitCommon, stemRange, transferTargetProblem, } from "./model.ts"; 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, parsedHint: 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([2, 3], -1)).toEqual([1, 2]); expect(shiftEpisodes([1, 2], -1)).toBeNull(); expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]); }); it("同名即时提示忽略自身", () => { expect(nameTaken("b.mkv", "a.mkv", ["a.mkv", "b.mkv"])).toBe(true); expect(nameTaken("a.mkv", "a.mkv", ["a.mkv"])).toBe(false); expect(nameTaken("c.mkv", "a.mkv", ["a.mkv", "b.mkv"])).toBe(false); }); }); describe("v2 菜单与副行", () => { const lib = { kind: "library" as const, writable: true }; const video = { ...base, name: "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv", fileKind: "video" as const, status: "unidentified" as const, parsedHint: "The Last of Us · S01E02 · 1080p", }; const asset = { assetId: "a", locationId: "l", mediaEntityId: "e", title: "最后生还者", year: 2023, contentUnit: "S01E02", slotId: null, slotStatus: null, isPrimary: true, }; const registered = { ...video, status: "registered" as const, asset }; const keys = (xs: ReturnType) => xs.map((x) => ("separator" in x ? "|" : x.key)).join(" "); it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => { expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete"); expect(keys(menuModel([registered], lib))).toBe( "open | identify rename move copy | scrape probe | copyPath openMedia records | delete", ); expect(opApplies("organize", registered)).toBe(false); }); it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => { expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete"); expect(keys(menuModel([registered, registered], lib))).toContain("scrape"); }); it("被状态阻止的项灰显带原因", () => { const seeding = { ...video, status: "seeding" as const, download: { taskId: "t", status: "completed" as const, progress: 1, hnr: "unsatisfied" as const }, }; const m = menuModel([seeding], lib).find((x) => !("separator" in x) && x.key === "rename"); expect(m && !("separator" in m) ? m.disabled : null).toMatch(/H&R/); }); it("hover 主操作按状态", () => { expect(hoverOps(video)).toEqual(["organize", "identify"]); expect(hoverOps(registered)).toEqual(["rename", "openMedia"]); expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]); }); it("副行:已登记作品、待识别解析、目录计数", () => { expect(entrySubline(registered)?.text).toBe("最后生还者 (2023) · S01E02"); expect(entrySubline(video)).toEqual({ text: "The Last of Us · S01E02 · 1080p", tone: "hint" }); expect( entrySubline({ ...base, type: "dir" as const, dirSummary: { videoCount: 13, registeredCount: 0, partial: false }, })?.text, ).toBe("13 个视频 · 未入库"); // 没有 parsedHint(服务端解析不出标题)时不显示副行,前端不自己解析 expect(entrySubline({ ...video, parsedHint: null })).toBeNull(); }); it("初始位置:上次位置 > 下载目录 > 库根;离线根跳过", () => { const roots = [ { id: "lib:a", kind: "library" as const, health: "healthy" as const }, { id: "dl:b", kind: "download" as const, health: "healthy" as const }, { id: "lib:c", kind: "library" as const, health: "offline" as const }, ]; expect(initialLocation(roots, null)).toEqual({ root: "dl:b", path: "" }); expect(initialLocation(roots, { root: "lib:a", path: "x" })).toEqual({ root: "lib:a", path: "x" }); expect(initialLocation(roots, { root: "lib:c", path: "x" })).toEqual({ root: "dl:b", path: "" }); expect(initialLocation([], null)).toBeNull(); }); }); 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); }); }); describe("transferTargetProblem(U13)", () => { const items = [ { root: "dl", path: "a/b.mkv" }, { root: "dl", path: "pack" }, ]; it("目标就是源所在目录 / 在源目录之内时拦下", () => { expect(transferTargetProblem(items, { root: "dl", path: "a" })).toMatch(/已在这个目录里/); expect(transferTargetProblem(items, { root: "dl", path: "pack/sub" })).toMatch(/目标在源目录之内/); expect(transferTargetProblem([{ root: "dl", path: "x.mkv" }], { root: "dl", path: "" })).toMatch(/已在/); }); it("其他目录、其他根放行", () => { expect(transferTargetProblem(items, { root: "dl", path: "c" })).toBeNull(); expect(transferTargetProblem(items, { root: "lib", path: "a" })).toBeNull(); expect(transferTargetProblem(items, { root: "dl", path: "packs" })).toBeNull(); }); }); describe("识别远端回填(S-7)", () => { const item = (path: string, over: Record = {}) => ({ root: "dl:x:0", path, parsed: { title: "T", year: null, season: null, episodes: [], resolution: null, source: null, releaseGroup: null, mediaKind: null, }, candidates: [], suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null }, existing: { assetId: null }, warnings: ["远端候选在后台查询,查完自动补上"], ...over, }); const base = { items: [item("a.mkv"), item("b.mkv")], truncated: false, remoteTaskId: "run-1", remotePending: 1 }; it("同一 taskId 按 root + path 替换条目并清掉待查标记", () => { const done = item("b.mkv", { warnings: [], candidates: [{ title: "B" }] }); const r = applyIdentifyCompleted( base as never, { taskId: "run-1", status: "succeeded", items: [done], truncated: false, excludedBySize: 0, error: null, } as never, ); expect(r.remoteTaskId).toBeNull(); expect(r.remotePending).toBe(0); expect(r.items[0]).toBe(base.items[0]); expect(r.items[1]).toBe(done); }); it("别的 taskId 原样返回;失败时保留本地结果并写明原因", () => { const other = { taskId: "run-2", status: "succeeded", items: [], truncated: false, excludedBySize: 0, error: null }; expect(applyIdentifyCompleted(base as never, other as never)).toBe(base); const f = applyIdentifyCompleted( base as never, { ...other, taskId: "run-1", status: "failed", error: "超时" } as never, ); expect(f.remoteTaskId).toBeNull(); expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]); }); }); describe("预览(FM-2)", () => { it("按扩展名分预览类型,目录与未知扩展名不预览", () => { expect(previewKindOf({ type: "file", ext: "NFO" })).toBe("text"); expect(previewKindOf({ type: "file", ext: "ass" })).toBe("subtitle"); expect(previewKindOf({ type: "file", ext: "torrent" })).toBe("torrent"); expect(previewKindOf({ type: "file", ext: "avif" })).toBe("image"); expect(previewKindOf({ type: "file", ext: "mkv" })).toBeNull(); expect(previewKindOf({ type: "dir", ext: null })).toBeNull(); }); it("时间轴与 JSON 美化", () => { expect(cueTime(3_723_045)).toBe("01:02:03.045"); expect(prettyJson('{"a":1}', false)).toBe('{\n "a": 1\n}'); expect(prettyJson('{"a":', false)).toBe('{"a":'); expect(prettyJson('{"a":1}', true)).toBe('{"a":1}'); }); });