diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a604c1f8..d4b311b8 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -848,7 +848,7 @@ export const SettingEntry = z.object({ }); export type SettingEntry = z.infer; -/** 已弃用、不归属任何设置分区的旧字段(见 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; +export const SettingsGroupId = z.enum(["connect", "flow", "system"]); +export type SettingsGroupId = z.infer; +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> = { + 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 }; +} diff --git a/packages/contracts/test/settings-sections.test.ts b/packages/contracts/test/settings-sections.test.ts index e8ccc109..e65e41a2 100644 --- a/packages/contracts/test/settings-sections.test.ts +++ b/packages/contracts/test/settings-sections.test.ts @@ -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(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); + } + }); +});