Files
rootandClaude Opus 5.5 592f40ccf8 copy(web): api 标签与整理状态统一
整理记录状态改为排队中 / 整理中 / 已整理 / 部分整理 / 整理失败(列表摘要、组汇总、队列面板同口径);
下载计划「等你批准」「准备提交」;资源评估「可下载」「信息不全」「已跳过」;任务状态带「中」;
识别等待提示改匹配「TMDB 结果在后台查询」。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 02:10:19 +08:00

393 lines
16 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { resolveFilesMock } from "~/api/files.ts";
import {
applyIdentifyCompleted,
breadcrumbItems,
clickSelect,
collapseCrumbs,
cueTime,
deleteBlockText,
emptySelection,
entryStatusView,
entrySubline,
formatEpisodes,
hoverOpLabel,
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("writeNfo", base, lib)).toBeNull();
expect(opDisabledReason("writeNfo", base, { kind: "library", writable: false })).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<typeof menuModel>) => xs.map((x) => ("separator" in x ? "|" : x.key)).join(" ");
it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,刮削照 MP 都有;顺序固定", () => {
expect(keys(menuModel([video], lib))).toBe(
"open | organize identify rename move copy | writeNfo probe | copyPath | delete",
);
expect(keys(menuModel([registered], lib))).toBe(
"open | identify rename move copy | scrape writeNfo 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 writeNfo | delete");
expect(keys(menuModel([registered, registered], lib))).toContain("scrape writeNfo");
});
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("悬停常用操作按文件 / 目录类型选 2–3 个,都在菜单里能找到", () => {
expect(hoverOps(video)).toEqual(["organize", "identify", "writeNfo"]);
expect(hoverOps({ ...base, type: "dir" as const, fileKind: null })).toEqual(["organize", "identify", "writeNfo"]);
expect(hoverOps(registered)).toEqual(["openMedia", "scrape", "rename"]);
// 未完成的视频不能整理:退为识别 / 重命名 / 移动
expect(hoverOps({ ...video, status: "temp" as const })).toEqual(["identify", "rename", "move"]);
expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]);
expect(hoverOps({ ...base, fileKind: "subtitle" as const })).toEqual(["rename", "move"]);
for (const e of [video, registered, { ...base, fileKind: "nfo" as const }]) {
const ops = hoverOps(e);
expect(ops.length).toBeGreaterThanOrEqual(2);
expect(ops.length).toBeLessThanOrEqual(3);
const menu = menuModel([e], lib).flatMap((x) => ("separator" in x ? [] : [x.key]));
for (const op of ops) expect(menu).toContain(op);
}
});
it("悬停图标的 tooltip / aria-label 文字去掉菜单里的省略号", () => {
expect(hoverOpLabel("organize")).toBe("整理");
expect(hoverOpLabel("writeNfo")).toBe("刮削");
expect(hoverOpLabel("move")).toBe("移动到");
expect(hoverOpLabel("scrape")).toBe("刷新元数据");
expect(hoverOpLabel("openMedia")).toBe("打开作品");
});
it("面包屑:登记目录类型 > 登记目录 > 各级子目录,每段带可跳转的相对路径", () => {
expect(breadcrumbItems({ name: "qBittorrent · 下载", kind: "download" }, "")).toEqual({
group: "下载目录",
crumbs: [{ label: "qBittorrent · 下载", path: "" }],
});
expect(breadcrumbItems({ name: "剧集库", kind: "library" }, "亚洲剧集/繁花 (2023)/Season 1/")).toEqual({
group: "媒体库",
crumbs: [
{ label: "剧集库", path: "" },
{ label: "亚洲剧集", path: "亚洲剧集" },
{ label: "繁花 (2023)", path: "亚洲剧集/繁花 (2023)" },
{ label: "Season 1", path: "亚洲剧集/繁花 (2023)/Season 1" },
],
});
});
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<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);
});
});
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(/目标不能是 pack 或它的子目录/);
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<string, unknown> = {}) => ({
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: ["TMDB 结果在后台查询,查完自动补上"],
...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(["TMDB 查询失败,只显示本地结果:超时"]);
});
});
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}');
});
});