feat(settings): 设置分区按接入 / 流程 / 系统三组重排

SETTINGS_SECTIONS 改为 12 个分区(下载器、媒体服务器、站点与网络、元数据、获取、媒体库与目录、
整理与刮削、智能体、系统、缓存、账户与安全、界面),加分组、节别名(搜索用)、旧 hash 别名
SETTINGS_HASH_ALIASES 与 resolveSettingsHash;retention.logRetentionDays 无人读取,登记为弃用。
不改存储结构与缺省值。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 18:01:19 +08:00
1 parent b7460d5d0a
commit 93a6c8a910
2 files changed
+339 -116

No files matched your search

+256 -112
View File
@@ -848,7 +848,7 @@ export const SettingEntry = z.object({
});
export type SettingEntry = z.infer<typeof SettingEntry>;
/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释) */
/** 已弃用、不归属任何设置分区的旧字段(见 PiSettings 注释);设置页不显示 */
export const DEPRECATED_SETTINGS_FIELDS = [
"pi.provider",
"pi.baseUrl",
@@ -859,6 +859,8 @@ export const DEPRECATED_SETTINGS_FIELDS = [
"cache.tmdbTtlHours",
"cache.emptyResultTtlSec",
"identity.poolArchiveDays",
// CACHE-2 起流水数据按 storage.retentionDays 分类清理,这个总的日志保留天数没有任何地方读取
"retention.logRetentionDays",
// 2026-10-06:智能体不设硬性预算上限,只保留单次工具调用次数与时长两个防跑飞限制
"budget.agentMaxRunsPerDay",
"budget.agentMaxCostUsdPerDay",
@@ -866,32 +868,52 @@ export const DEPRECATED_SETTINGS_FIELDS = [
"agent.maxCostUsdPerRun",
] as const;
// ── 设置页分区(docs/design/console-ia.md §3.4) ──
// 只是"展示分区 → 字段"的映射,不改存储 key;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、Pi 上游、账户)用 resources 标注,不进 fields。
// ── 设置页分区(docs/design/console-ia.md §3.4,2026-10-06 重排) ──
// 只是"展示分区 → 字段"的映射,不改存储 key 与缺省值;除 DEPRECATED_SETTINGS_FIELDS 外每个字段恰好归属一个分区(测试断言)。
// 非 settings 表的对象(实例、站点、API Key、模型服务、账户)用 resources 标注,不进 fields。
// 分区按三组排:接入对象(外部服务与数据源)→ 业务环节(获取 → 落盘 → 整理 → 智能体)→ 系统与账户。
export const SettingsSectionId = z.enum([
"automation",
"sites",
"downloaders",
"library",
"media-servers",
"sites",
"metadata",
"acquire",
"library",
"organize",
"agent",
"api-keys",
"interface",
"system",
"cache",
"account",
"interface",
]);
export type SettingsSectionId = z.infer<typeof SettingsSectionId>;
export const SettingsGroupId = z.enum(["connect", "flow", "system"]);
export type SettingsGroupId = z.infer<typeof SettingsGroupId>;
export const SETTINGS_GROUPS: readonly { id: SettingsGroupId; label: string }[] = [
{ id: "connect", label: "接入" },
{ id: "flow", label: "流程" },
{ id: "system", label: "系统" },
];
export type SettingsAnchorDef = {
id: string;
label: string;
/** 搜索用的别名:不在表单字段里的设置(实例抽屉的字段、登录方式等) */
keywords?: string[];
};
export type SettingsSectionDef = {
id: SettingsSectionId;
group: SettingsGroupId;
label: string;
/** 一句说明:这里管什么 */
description: string;
/** 旧 `?tab=` 值 → 本分区(可带分区内锚点) */
legacyTabs: { tab: string; anchor?: string }[];
/** 分区内锚点(节),顺序即页面顺序 */
anchors: { id: string; label: string }[];
anchors: SettingsAnchorDef[];
/** 归属本分区的 settings 字段(`key.field`,compaction 这类嵌套对象按整体算) */
fields: string[];
/** 本分区管理的非 settings 对象 */
@@ -900,44 +922,58 @@ export type SettingsSectionDef = {
export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "automation",
label: "自动化",
description: "总开关、下载盘、每日预算、保留天数",
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
id: "downloaders",
group: "connect",
label: "下载器",
description: "qBittorrent 实例与路径映射;下载盘水位和保种天数的缺省值,实例里可以单独改",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }, { tab: "storage", anchor: "cache" }],
anchors: [
{ id: "switches", label: "开关" },
{ id: "cache", label: "下载盘" },
{ id: "budget", label: "每日预算" },
{ id: "retention", label: "保留" },
{ id: "advanced", label: "高级" },
{
id: "instances",
label: "实例",
keywords: [
"qBittorrent",
"WebUI",
"管理标签",
"缓存卷标识",
"默认下载器",
"路径映射",
"H&R 未知也可清理",
"认证方式",
],
},
{ id: "cache", label: "下载盘", keywords: ["水位", "缓存盘"] },
{ id: "seeding", label: "保种", keywords: ["做种"] },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"automation.workerLanes",
"cache.hardThresholdPercent",
"cache.resumePercent",
"cache.checkIntervalSec",
"cache.cleanupTargetPercent",
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"retention.seedRetentionDays",
"retention.logRetentionDays",
"storage.budgets",
"storage.retentionDays",
"storage.cacheTtl",
],
resources: [],
resources: ["downloader-instances", "path-mappings"],
},
{
id: "media-servers",
group: "connect",
label: "媒体服务器",
description: "Emby / Jellyfin 实例与路径映射,只用来查作品有没有",
legacyTabs: [{ tab: "media-servers" }],
anchors: [{ id: "instances", label: "实例", keywords: ["Emby", "Jellyfin", "用户 ID"] }],
fields: [],
resources: ["media-server-instances", "path-mappings"],
},
{
id: "sites",
label: "站点默认",
description: "新站点的限流、快照周期、UA 和代理,以及原站视图用的浏览器服务;站点和凭证在站点页管理",
group: "connect",
label: "站点与网络",
description: "站点缺省值、User-Agent 与代理、浏览器服务;站点、凭证和 Cloudflare 在站点页",
legacyTabs: [{ tab: "site-credentials" }],
anchors: [
{ id: "defaults", label: "站点默认" },
{ id: "browser", label: "浏览器服务" },
{ id: "defaults", label: "站点缺省值", keywords: ["限流", "抓取周期", "健康检测"] },
{ id: "network", label: "UA 与代理", keywords: ["User-Agent", "代理", "出口"] },
{ id: "browser", label: "浏览器服务", keywords: ["原站视图", "Cloudflare"] },
],
fields: [
"sites.defaultRateLimit",
@@ -948,43 +984,70 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
"sites.defaultSnapshotMaxPages",
"sites.defaultHealthCheckIntervalSec",
"sites.defaultDetailCacheSec",
"search.defaultSiteMode",
"search.siteIds",
],
resources: ["browser-providers"],
},
{
id: "metadata",
group: "connect",
label: "元数据",
description: "TMDB 接入、语言回退与特别篇;缓存在「缓存」里管理",
legacyTabs: [],
anchors: [
{ id: "tmdb", label: "TMDB", keywords: ["API Key", "Base URL"] },
{ id: "language", label: "语言与季集", keywords: ["季 0", "特别篇", "简介"] },
],
fields: [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"metadata.overviewNonChineseFallback",
"metadata.includeSpecials",
],
resources: [],
},
{
id: "downloaders",
label: "下载器",
description: "qBittorrent 实例、连通测试、路径映射",
legacyTabs: [{ tab: "downloaders" }, { tab: "path-mappings" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["downloader-instances", "path-mappings"],
id: "acquire",
group: "flow",
label: "获取",
description: "每日下载预算、搜索站点、资源识别;订阅和规则在各自页面",
legacyTabs: [],
anchors: [
{ id: "budget", label: "每日预算", keywords: ["并发下载"] },
{ id: "search", label: "搜索站点", keywords: ["默认参与搜索"] },
{ id: "identity", label: "资源识别", keywords: ["元数据补全", "TMDB"] },
],
fields: [
"budget.maxNewTitlesPerDay",
"budget.maxDownloadBytesPerDay",
"budget.maxConcurrentDownloads",
"search.defaultSiteMode",
"search.siteIds",
"identity.eagerTmdbLookup",
"identity.autoEnrich",
"identity.enrichDailyBudget",
"identity.enrichDomains",
"identity.enrichSnippetAllSites",
"identity.detailIdsPerSiteHour",
],
resources: [],
},
{
id: "library",
group: "flow",
label: "媒体库与目录",
description: "媒体库目录、默认整理方式、刮削、命名模板",
description: "媒体库目录、整理来源、分类规则、命名模板",
legacyTabs: [{ tab: "library-roots", anchor: "roots" }],
anchors: [
{ id: "roots", label: "媒体库目录" },
{ id: "classification", label: "分类规则" },
{ id: "roots", label: "媒体库目录", keywords: ["标记文件", "检查目录"] },
{ id: "sources", label: "整理来源" },
{ id: "organize", label: "整理方式" },
{ id: "scrape", label: "刮削" },
{ id: "naming", label: "命名模板" },
{ id: "classification", label: "分类规则", keywords: ["二级分类"] },
{ id: "naming", label: "命名模板", keywords: ["重命名"] },
{ id: "naming-roots", label: "目录命名覆盖" },
],
fields: [
"organize.classificationYaml",
"organize.sources",
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
"organize.scrapeAfterImport",
"organize.scrapeMode",
"organize.scrapeImages",
"organize.scrapeEpisodeThumbs",
"naming.movie",
"naming.tv",
"naming.variety",
@@ -993,57 +1056,41 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
resources: ["library-roots"],
},
{
id: "media-servers",
label: "媒体服务器",
description: "Emby / Jellyfin 实例、连通测试、路径映射",
legacyTabs: [{ tab: "media-servers" }],
anchors: [{ id: "instances", label: "实例" }],
fields: [],
resources: ["media-server-instances", "path-mappings"],
},
{
id: "metadata",
label: "元数据",
description: "TMDB API Key 与地址、TMDB 缓存、资源识别、图片代理",
id: "organize",
group: "flow",
label: "整理与刮削",
description: "整理方式、最小视频体积、整理后自动刮削和已有文件的覆盖方式",
legacyTabs: [],
anchors: [
{ id: "tmdb", label: "TMDB" },
{ id: "tmdb-cache", label: "TMDB 缓存" },
{ id: "identity", label: "资源识别" },
{ id: "images", label: "图片代理与缓存" },
{ id: "mode", label: "整理方式", keywords: ["复制", "移动", "跨卷"] },
{ id: "scrape", label: "刮削", keywords: ["NFO", "海报", "剧照", "更新", "只补缺失", "全部覆盖"] },
],
fields: [
"metadata.tmdbApiKeySecretRef",
"metadata.tmdbBaseUrl",
"metadata.languages",
"metadata.overviewNonChineseFallback",
"metadata.includeSpecials",
"cache.tmdbMaxSize",
"identity.eagerTmdbLookup",
"identity.autoEnrich",
"identity.enrichDailyBudget",
"identity.enrichDomains",
"identity.enrichSnippetAllSites",
"identity.detailIdsPerSiteHour",
"cache.globalImageCache",
"cache.globalImageCacheDays",
"organize.defaultMode",
"organize.allowCrossVolumeMove",
"organize.minSizeBytes",
"organize.scrapeAfterImport",
"organize.scrapeMode",
"organize.scrapeImages",
"organize.scrapeEpisodeThumbs",
],
resources: [],
},
{
id: "agent",
group: "flow",
label: "智能体",
description: "模型服务、用量与限制、自动调查、网页搜索",
description: "模型服务、用量与防跑飞限制、自动处理、网页搜索、MCP",
legacyTabs: [{ tab: "pi" }],
anchors: [
{ id: "upstream", label: "模型服务" },
{ id: "runtime", label: "上下文与子会话" },
{ id: "budget", label: "用量与限制" },
{ id: "investigate", label: "自动调查" },
{ id: "websearch", label: "网页搜索" },
{ id: "advanced", label: "高级" },
{ id: "upstream", label: "模型服务", keywords: ["默认模型服务", "订阅登录"] },
{ id: "budget", label: "用量与限制", keywords: ["花费", "预算"] },
{ id: "investigate", label: "自动处理", keywords: ["自动调查", "识别失败"] },
{ id: "runtime", label: "上下文与子会话", keywords: ["压缩"] },
{ id: "websearch", label: "网页搜索", keywords: ["Brave"] },
{ id: "mcp", label: "MCP 服务端" },
{ id: "capabilities", label: "能力清单" },
{ id: "advanced", label: "高级", keywords: ["方案", "审批"] },
],
fields: [
"pi.defaultUpstreamId",
@@ -1071,37 +1118,74 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
resources: ["pi-upstreams"],
},
{
id: "api-keys",
label: "API Key",
description: "签发给外部程序的 Key",
legacyTabs: [{ tab: "api-keys" }],
anchors: [{ id: "keys", label: "API Key" }],
fields: [],
resources: ["api-keys"],
id: "system",
group: "system",
label: "系统",
description: "自动化总开关、只读对账、后台任务并发、存储预算、数据保留",
legacyTabs: [{ tab: "automation" }],
anchors: [
{ id: "switches", label: "自动化", keywords: ["总开关", "暂停"] },
{ id: "readonly", label: "只读对账" },
{ id: "worker-lanes", label: "后台任务并发" },
{ id: "storage", label: "存储预算" },
{ id: "storage-retention", label: "数据保留", keywords: ["日志保留"] },
],
fields: [
"automation.enabled",
"automation.readOnlyReconcile",
"automation.workerLanes",
"storage.budgets",
"storage.retentionDays",
],
resources: [],
},
{
id: "interface",
label: "界面",
description: "只影响当前浏览器",
id: "cache",
group: "system",
label: "缓存",
description: "各类缓存的条数、占用和有效期,可以按区清空",
legacyTabs: [],
anchors: [{ id: "display", label: "显示" }],
fields: [],
resources: ["ui-preferences"],
anchors: [
{
id: "regions",
label: "缓存区",
keywords: ["清空缓存", "豆瓣", "Bangumi", "站点详情", "发现", "网页搜索"],
},
{ id: "tmdb-cache", label: "TMDB 缓存", keywords: ["条数上限"] },
{ id: "images", label: "图片缓存", keywords: ["海报"] },
{ id: "ttl", label: "缓存有效期" },
],
fields: ["cache.tmdbMaxSize", "cache.globalImageCache", "cache.globalImageCacheDays", "storage.cacheTtl"],
resources: ["kv-cache", "image-cache"],
},
{
id: "account",
group: "system",
label: "账户与安全",
description: "密码、二次验证、会话、快捷键",
legacyTabs: [],
description: "密码、二次验证、通行密钥、会话与本机 Token、API Key",
legacyTabs: [{ tab: "api-keys", anchor: "api-keys" }],
anchors: [
{ id: "password", label: "密码" },
{ id: "totp", label: "二次验证" },
{ id: "passkeys", label: "通行密钥" },
{ id: "session", label: "会话" },
{ id: "totp", label: "二次验证", keywords: ["TOTP", "恢复码"] },
{ id: "passkeys", label: "通行密钥", keywords: ["Passkey"] },
{ id: "session", label: "会话", keywords: ["本机 Token", "退出登录"] },
{ id: "api-keys", label: "API Key", keywords: ["外部程序"] },
],
fields: [],
resources: ["auth", "api-keys"],
},
{
id: "interface",
group: "system",
label: "界面",
description: "只影响当前浏览器",
legacyTabs: [],
anchors: [
{ id: "display", label: "显示", keywords: ["技术信息"] },
{ id: "shortcuts", label: "快捷键" },
],
fields: [],
resources: ["auth"],
resources: ["ui-preferences"],
},
];
@@ -1113,3 +1197,63 @@ export function settingsSectionForLegacyTab(tab: string): { section: SettingsSec
}
return null;
}
/**
* 旧 hash(2026-10-06 重排前的 `#分区[/节]`)→ 新位置。先查整条,再查只有分区的那一条;
* 新旧同名的(`#downloaders`、`#sites/browser`、`#agent/budget` 等)不用登记。
*/
export const SETTINGS_HASH_ALIASES: Readonly<Record<string, string>> = {
automation: "system",
"automation/switches": "system/switches",
"automation/cache": "downloaders/cache",
"automation/budget": "acquire/budget",
"automation/retention": "downloaders/seeding",
"automation/advanced": "system/readonly",
"automation/readonly": "system/readonly",
"automation/worker-lanes": "system/worker-lanes",
"automation/storage": "system/storage",
"automation/storage-retention": "system/storage-retention",
"automation/storage-ttl": "cache/ttl",
"system/storage-ttl": "cache/ttl",
"metadata/tmdb-cache": "cache/tmdb-cache",
"metadata/images": "cache/images",
"library/organize": "organize/mode",
"library/scrape": "organize/scrape",
"metadata/identity": "acquire/identity",
"api-keys": "account/api-keys",
"api-keys/keys": "account/api-keys",
"account/shortcuts": "interface/shortcuts",
"sites/accounts": "sites",
};
/**
* 解析设置页 hash:`#分区[/节]`,旧 hash 按 SETTINGS_HASH_ALIASES 换成新位置(moved = true 时调用方应 replace 地址栏);
* 不认识的分区返回 null 分区(调用方落到第一个分区),不认识的节丢掉。
*/
export function resolveSettingsHash(hash: string): {
section: SettingsSectionId | null;
anchor: string | null;
moved: boolean;
} {
let raw = hash.replace(/^#/, "");
try {
raw = decodeURIComponent(raw);
} catch {
// 保持原样
}
const [head = "", tail] = raw.split("/");
const full = tail ? `${head}/${tail}` : head;
const alias = SETTINGS_HASH_ALIASES[full];
let target = alias ?? full;
let moved = alias !== undefined;
if (!alias && SETTINGS_HASH_ALIASES[head] && !SETTINGS_SECTIONS.some((s) => s.id === head)) {
// 旧分区里没登记的节:落到新分区顶部
target = SETTINGS_HASH_ALIASES[head] as string;
moved = true;
}
const [sid, anchor] = target.split("/");
const section = SETTINGS_SECTIONS.find((s) => s.id === sid);
if (!section) return { section: null, anchor: null, moved: false };
const known = anchor && section.anchors.some((a) => a.id === anchor) ? anchor : null;
return { section: section.id, anchor: known, moved };
}
@@ -2,17 +2,28 @@ import { describe, expect, it } from "vitest";
import {
DEFAULT_SETTINGS,
DEPRECATED_SETTINGS_FIELDS,
resolveSettingsHash,
SETTINGS_GROUPS,
SETTINGS_HASH_ALIASES,
SETTINGS_SECTIONS,
SettingsSectionId,
settingsSectionForLegacyTab,
} from "../src/settings.ts";
describe("SETTINGS_SECTIONS", () => {
it("10 个分区,顺序与 SettingsSectionId 一致", () => {
it("分区顺序与 SettingsSectionId 一致,按接入 → 流程 → 系统三组排", () => {
expect(SETTINGS_SECTIONS.map((s) => s.id)).toEqual(SettingsSectionId.options);
const groups = SETTINGS_SECTIONS.map((s) => s.group);
const order = SETTINGS_GROUPS.map((g) => g.id);
expect([...groups].sort((a, b) => order.indexOf(a) - order.indexOf(b))).toEqual(groups);
for (const s of SETTINGS_SECTIONS) {
expect(s.description, s.id).not.toBe("");
expect(s.anchors.length, s.id).toBeGreaterThan(0);
expect(new Set(s.anchors.map((a) => a.id)).size, s.id).toBe(s.anchors.length);
}
});
it("8 个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
it("每个存储 key 的每个字段(弃用字段除外)恰好归属一个分区", () => {
const deprecated = new Set<string>(DEPRECATED_SETTINGS_FIELDS);
const all = Object.entries(DEFAULT_SETTINGS)
.flatMap(([key, v]) => Object.keys(v).map((f) => `${key}.${f}`))
@@ -22,6 +33,23 @@ describe("SETTINGS_SECTIONS", () => {
expect([...owned].sort()).toEqual([...all].sort());
});
it("近期新增的设置归到用户找得到的分区", () => {
const owner = (f: string) => SETTINGS_SECTIONS.find((s) => s.fields.includes(f))?.id;
expect(owner("organize.scrapeMode")).toBe("organize");
expect(owner("organize.scrapeAfterImport")).toBe("organize");
expect(owner("organize.minSizeBytes")).toBe("organize");
expect(owner("agent.autoIdentifyOrganize")).toBe("agent");
expect(owner("agent.maxToolCalls")).toBe("agent");
expect(owner("metadata.includeSpecials")).toBe("metadata");
expect(owner("cache.hardThresholdPercent")).toBe("downloaders");
expect(owner("retention.seedRetentionDays")).toBe("downloaders");
expect(owner("budget.maxConcurrentDownloads")).toBe("acquire");
expect(owner("sites.proxyUrl")).toBe("sites");
expect(owner("storage.cacheTtl")).toBe("cache");
expect(owner("cache.tmdbMaxSize")).toBe("cache");
expect(DEPRECATED_SETTINGS_FIELDS).toContain("retention.logRetentionDays");
});
it("旧 ?tab= 值都能落到分区", () => {
for (const tab of [
"automation",
@@ -36,8 +64,59 @@ describe("SETTINGS_SECTIONS", () => {
]) {
expect(settingsSectionForLegacyTab(tab), tab).not.toBeNull();
}
expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "automation", anchor: "cache" });
expect(settingsSectionForLegacyTab("path-mappings")).toEqual({ section: "downloaders", anchor: undefined });
expect(settingsSectionForLegacyTab("storage")).toEqual({ section: "downloaders", anchor: "cache" });
expect(settingsSectionForLegacyTab("api-keys")).toEqual({ section: "account", anchor: "api-keys" });
expect(settingsSectionForLegacyTab("nope")).toBeNull();
});
});
describe("resolveSettingsHash", () => {
it("新 hash 原样解析", () => {
expect(resolveSettingsHash("#agent/budget")).toEqual({ section: "agent", anchor: "budget", moved: false });
expect(resolveSettingsHash("#downloaders")).toEqual({ section: "downloaders", anchor: null, moved: false });
expect(resolveSettingsHash("")).toEqual({ section: null, anchor: null, moved: false });
expect(resolveSettingsHash("#nope")).toEqual({ section: null, anchor: null, moved: false });
// 不认识的节丢掉
expect(resolveSettingsHash("#agent/zzz")).toEqual({ section: "agent", anchor: null, moved: false });
});
it("旧锚点跳到新位置", () => {
const cases: [string, string, string | null][] = [
["#agent", "agent", null],
["#downloaders", "downloaders", null],
["#automation", "system", null],
["#automation/cache", "downloaders", "cache"],
["#automation/budget", "acquire", "budget"],
["#automation/retention", "downloaders", "seeding"],
["#automation/advanced", "system", "readonly"],
["#automation/storage-ttl", "cache", "ttl"],
["#automation/whatever", "system", null],
["#library/organize", "organize", "mode"],
["#library/scrape", "organize", "scrape"],
["#metadata/identity", "acquire", "identity"],
["#metadata/tmdb-cache", "cache", "tmdb-cache"],
["#metadata/images", "cache", "images"],
["#api-keys", "account", "api-keys"],
["#api-keys/keys", "account", "api-keys"],
["#account/shortcuts", "interface", "shortcuts"],
];
for (const [hash, section, anchor] of cases) {
const r = resolveSettingsHash(hash);
expect({ section: r.section, anchor: r.anchor }, hash).toEqual({ section, anchor });
}
expect(resolveSettingsHash("#automation/cache").moved).toBe(true);
});
it("别名的目标都是存在的分区与节", () => {
for (const [from, to] of Object.entries(SETTINGS_HASH_ALIASES)) {
const [sid, anchor] = to.split("/");
const s = SETTINGS_SECTIONS.find((x) => x.id === sid);
expect(s, from).toBeDefined();
if (anchor)
expect(
s?.anchors.map((a) => a.id),
from,
).toContain(anchor);
}
});
});