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 = { "budget.maxDownloadGiBPerDay": "budget.maxDownloadBytesPerDay", "organize.minSizeMiB": "organize.minSizeBytes", "metadata.tmdbApiKey": "metadata.tmdbApiKeySecretRef", }; /** 不走表单卡片、由专门组件渲染的字段 → 所在节(只核对节在页面上) */ const CUSTOM_FIELDS: Record = { "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"); expect(first("library")).toBe("roots"); 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(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"); }); });