Files
UHD_Media_Tool/apps/web/app/components/settings/settings-page.test.ts
T
rootandClaude Opus 5.5 8071ad33f9 feat(web): 设置页按新分类重排,加搜索、缓存分区与旧锚点跳转
- 左栏三组分区 + 当前分区的节;窄屏为搜索框、分组下拉和节的横排胶囊
- 搜索框按分区、节、节别名和字段名定位,选中字段时滚到该字段并聚焦
- 旧 hash(#automation/cache、#api-keys、#library/scrape 等)replace 到新位置
- 下载器分区放下载盘水位与保种缺省值,名字与实例抽屉统一(暂停线 / 恢复线 / 保种天数)
- 新增获取、整理与刮削、系统、缓存分区;API Key 并入账户与安全,快捷键移到界面
- 补上缺的入口:最小视频体积、子会话并发与层数、搜索站点;日志保留天数(已废弃)不再显示
- 缓存分区按区列出条数、占用、最近写入,单区清空二次确认;TMDB 与图片缓存并入
- 保存提示统一为「已保存「节名」」;按入库口径把相关文案改成「整理」

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 18:01:26 +08:00

217 lines
9.7 KiB
TypeScript
Raw 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");
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<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");
});
});