- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px - 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多" - 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB - 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行), TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、 plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录 - 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…" - 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏 Co-Authored-By: Claude Opus 5.5 <[email protected]>
260 lines
9.9 KiB
TypeScript
260 lines
9.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { resolveFilesMock } from "~/api/files.ts";
|
|
import {
|
|
clickSelect,
|
|
collapseCrumbs,
|
|
deleteBlockText,
|
|
emptySelection,
|
|
entryStatusView,
|
|
entrySubline,
|
|
formatEpisodes,
|
|
hoverOps,
|
|
initialLocation,
|
|
menuModel,
|
|
moveFocus,
|
|
nameProblem,
|
|
nameTaken,
|
|
opApplies,
|
|
opDisabledReason,
|
|
parseEpisodes,
|
|
parseHint,
|
|
pruneSelection,
|
|
selectAll,
|
|
shiftEpisodes,
|
|
splitCommon,
|
|
stemRange,
|
|
} 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,
|
|
};
|
|
|
|
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,
|
|
};
|
|
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<typeof menuModel>) => 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 视频 · 0 已登记");
|
|
expect(parseHint("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p");
|
|
expect(parseHint("readme.txt")).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<string, string>();
|
|
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);
|
|
});
|
|
});
|