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

218 lines
9.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DEFAULT_SETTINGS, DEPRECATED_SETTINGS_FIELDS, type GlobalSettings, SETTINGS_SECTIONS } from "@umt/contracts";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { qk } from "~/lib/query-keys.ts";
import { cacheRow } from "./cache.tsx";
import { SETTINGS_CARDS, searchSettings } from "./search-index.ts";
import { parseSettingsHash, SECTION_GROUPS, SECTIONS, settingsHash } from "./sections.tsx";
import { anchorId } from "./settings-block.tsx";
// 设置页重排(2026-10-06):分组导航、旧锚点跳转、搜索定位、各分区渲染不丢字段。
// 渲染用 react-dom/server + 预置的设置数据;列表类接口在服务端渲染里保持加载态,只核对节锚点。
/** 表单字段名 → 它写回的存储字段(换了单位 / 拆了子字段的在这里对上) */
const FIELD_ALIASES: Record<string, string> = {
"budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay",
"organize.minSizeMiB": "organize.minSizeBytes",
"metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef",
};
/** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */
const CUSTOM_FIELDS: Record<string, string> = {
"organize.classificationYaml": "library/classification",
"organize.sources": "library/sources",
"naming.movie": "library/naming",
"naming.tv": "library/naming",
"naming.variety": "library/naming",
"naming.lockFileNameForBound": "library/naming",
"pi.defaultUpstreamId": "agent/upstream",
"search.defaultSiteMode": "acquire/search",
"search.siteIds": "acquire/search",
"automation.enabled": "system/switches",
"automation.readOnlyReconcile": "system/readonly",
};
/** 打开所有"开了才显示后续字段"的开关,让条件字段也渲染出来 */
const ALL_ON: GlobalSettings = {
...DEFAULT_SETTINGS,
pi: {
...DEFAULT_SETTINGS.pi,
subagentsEnabled: true,
subagents: { ...DEFAULT_SETTINGS.pi.subagents, enabled: true },
},
agent: {
...DEFAULT_SETTINGS.agent,
autoInvestigate: true,
autoInvestigateMode: "recover",
webSearch: { ...DEFAULT_SETTINGS.agent.webSearch, provider: "brave" },
},
};
function render(sectionId: string, settings: GlobalSettings = DEFAULT_SETTINGS): string {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false, enabled: false } } });
qc.setQueryData(qk.settings, settings);
const section = SECTIONS.find((s) => s.id === sectionId)!;
return renderToString(
createElement(
QueryClientProvider,
{ client: qc },
createElement(MemoryRouter, { initialEntries: [`/console/settings#${sectionId}`] }, section.render()),
),
);
}
/** 存储字段(`key.field`)是否被某个渲染出来的控件 id(`f-<表单字段名>`)覆盖 */
function covered(field: string, controlIds: string[]): boolean {
return controlIds.some((id) => {
const target = FIELD_ALIASES[id] ?? id;
return target === field || target.startsWith(`${field}.`);
});
}
describe("设置页分组导航", () => {
it("三组按 接入 → 流程 → 系统 排,每个分区都在某一组里,带标题和一句说明", () => {
expect(SECTION_GROUPS.map((g) => g.label)).toEqual(["接入", "流程", "系统"]);
expect(SECTION_GROUPS.flatMap((g) => g.sections.map((s) => s.id))).toEqual(SECTIONS.map((s) => s.id));
for (const s of SECTIONS) {
expect(s.label).not.toBe("");
expect(s.description).not.toBe("");
expect(typeof s.render).toBe("function");
}
});
it("接入对象与参数分开:实例类分区的第一节是对象列表", () => {
const first = (id: string) => SECTIONS.find((s) => s.id === id)?.anchors[0]?.id;
expect(first("downloaders")).toBe("instances");
expect(first("media-servers")).toBe("instances");
// 媒体库与目录:第一节是存储(Alist / OpenList 实例,媒体库目录可以登记在它上面),也是对象列表
expect(first("library")).toBe("storages");
expect(first("agent")).toBe("upstream");
});
});
describe("旧锚点跳转", () => {
it.each([
["#automation/cache", "downloaders", "cache"],
["#automation/budget", "acquire", "budget"],
["#automation/advanced", "system", "readonly"],
["#automation", "system", null],
["#library/scrape", "organize", "scrape"],
["#metadata/identity", "acquire", "identity"],
["#metadata/tmdb-cache", "cache", "tmdb-cache"],
["#api-keys", "account", "api-keys"],
["#account/shortcuts", "interface", "shortcuts"],
])("%s → #%s/%s,并标记需要替换地址", (hash, section, anchor) => {
const r = parseSettingsHash(hash);
expect(r.section.id).toBe(section);
expect(r.anchor).toBe(anchor);
expect(r.moved).toBe(true);
});
it("新锚点不跳;#agent 等同名分区保持;不认识的落到第一个分区", () => {
expect(parseSettingsHash("#agent")).toMatchObject({ anchor: null, moved: false });
expect(parseSettingsHash("#agent").section.id).toBe("agent");
expect(parseSettingsHash("#downloaders/instances").section.id).toBe("downloaders");
expect(parseSettingsHash("#nope").section.id).toBe(SECTIONS[0]!.id);
expect(settingsHash("cache", "regions")).toBe("#cache/regions");
});
});
describe("搜索定位", () => {
it.each([
["H&R", "downloaders", "instances", null],
["刮削", "organize", "scrape", null],
["整理后自动刮削", "organize", "scrape", "organize.scrapeAfterImport"],
["已有文件", "organize", "scrape", "organize.scrapeMode"],
["单次工具调用上限", "agent", "budget", "agent.maxToolCalls"],
["整理识别不到作品", "agent", "investigate", "agent.autoIdentifyOrganize"],
["特别篇", "metadata", "language", "metadata.includeSpecials"],
["代理地址", "sites", "network", "sites.proxyUrl"],
["Cloudflare", "sites", "browser", null],
["保种天数", "downloaders", "seeding", "retention.seedRetentionDays"],
["豆瓣", "cache", "regions", null],
["Passkey", "account", "passkeys", null],
["API Key", "account", "api-keys", null],
])("「%s」能找到 %s/%s", (q, section, anchor, field) => {
const hits = searchSettings(q, 20);
const hit = hits.find((h) => h.section === section && h.anchor === anchor && (field === null || h.field === field));
expect(hit, JSON.stringify(hits.map((h) => h.key))).toBeDefined();
});
it("空查询没有结果;找不到时返回空", () => {
expect(searchSettings(" ")).toEqual([]);
expect(searchSettings("完全不存在的设置名")).toEqual([]);
});
it("搜索登记的卡片锚点都是所在分区的节", () => {
for (const [sid, card] of SETTINGS_CARDS) {
const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
expect(
s?.anchors.map((a) => a.id),
`${sid}/${card.anchor}`,
).toContain(card.anchor);
}
});
});
describe("各分区渲染(不丢字段)", () => {
const html = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id)]));
const htmlAllOn = Object.fromEntries(SECTIONS.map((s) => [s.id, render(s.id, ALL_ON)]));
it.each(SECTIONS.map((s) => [s.id]))("%s:每一节都渲染出来", (id) => {
const s = SECTIONS.find((x) => x.id === id)!;
for (const a of s.anchors) expect(html[id], `${id}/${a.id}`).toContain(`id="${anchorId(id, a.id)}"`);
});
it("每个在用的存储字段都出现在它所属分区的页面上", () => {
const deprecated = new Set<string>(DEPRECATED_SETTINGS_FIELDS);
const missing: string[] = [];
for (const s of SECTIONS) {
const page = `${html[s.id] ?? ""}\n${htmlAllOn[s.id] ?? ""}`;
const controlIds = [...page.matchAll(/id="f-([^"]+)"/g)].map((m) => m[1] as string);
for (const f of s.fields) {
if (deprecated.has(f)) continue;
const custom = CUSTOM_FIELDS[f];
if (custom) {
const [sid, anchor] = custom.split("/") as [string, string];
if (sid !== s.id || !page.includes(`id="${anchorId(sid, anchor)}"`)) missing.push(`${s.id}:${f}`);
continue;
}
if (!covered(f, controlIds)) missing.push(`${s.id}:${f}`);
}
}
expect(missing).toEqual([]);
});
it("近期新增的设置都在页面上:智能体防跑飞限制与用量、识别失败自动派智能体、刮削「更新」、整理后自动刮削、H&R 未知", () => {
expect(html.agent).toContain('id="f-agent.maxToolCalls"');
expect(html.agent).toContain('id="f-agent.maxDurationSec"');
expect(html.agent).toContain('id="f-agent.autoIdentifyOrganize"');
expect(html.organize).toContain('id="f-organize.scrapeAfterImport"');
expect(html.organize).toContain("更新");
// 实例抽屉的字段在打开抽屉后才渲染;这里核对搜索能定位到实例节
expect(searchSettings("H&R 未知也可清理")[0]).toMatchObject({ section: "downloaders", anchor: "instances" });
});
it("废弃字段不再显示", () => {
const all = Object.values(html).join("\n");
for (const f of DEPRECATED_SETTINGS_FIELDS) expect(all).not.toContain(`id="f-${f}"`);
expect(all).not.toContain("日志保留天数");
expect(all).not.toContain("每日费用");
});
});
describe("缓存区名", () => {
it("已知区显示中文名,未知区原样显示", () => {
const base = { entries: 1, expired: 0, bytes: 10, lastWriteAt: null };
expect(cacheRow({ region: "tmdb.request", ...base }).label).toBe("TMDB 请求");
expect(cacheRow({ region: "douban.subject", ...base }).label).toBe("豆瓣作品");
expect(cacheRow({ region: "bangumi.calendar", ...base }).label).toBe("Bangumi(calendar)");
expect(cacheRow({ region: "site.detail", ...base }).label).toBe("站点详情页");
expect(cacheRow({ region: "images", ...base }).label).toBe("图片");
expect(cacheRow({ region: "zzz.custom", ...base }).label).toBe("zzz.custom");
});
});