整理记录状态改为排队中 / 整理中 / 已整理 / 部分整理 / 整理失败(列表摘要、组汇总、队列面板同口径); 下载计划「等你批准」「准备提交」;资源评估「可下载」「信息不全」「已跳过」;任务状态带「中」; 识别等待提示改匹配「TMDB 结果在后台查询」。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
218 lines
9.8 KiB
TypeScript
218 lines
9.8 KiB
TypeScript
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");
|
||
});
|
||
});
|