feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/ download links, breadcrumb, sort, glob search, hidden toggle, filter chips, paging and truncation notice; multi-select (Shift/Ctrl/all), action bar, context menu, drag-to-move with dryRun preview, keyboard navigation, offline (503) and empty/error states, mobile layout with tree drawer. Rename/move/copy/delete dialogs with dryRun previews and reference checks, 4-step organize wizard, trash view (restore/restore-to/purge, expiry). DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -0,0 +1,249 @@
|
||||
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<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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user