// 配置只读事实面(契约 v0.68 §4.10(a))的**纯逻辑**测试:形状 → 文案的映射。 // 这里盯的是三条不变量:值只取后端给的生效值、默认/已改只由 isDefault 决定、 // 后端没给的键如实显示「未报告」而不是编一个 0。 // // 跑法(项目无前端测试运行器,用 vite 自带的 esbuild;见同目录既有测试): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/configFacts.test.ts \ // --bundle --platform=node --format=esm \ // --outfile=/tmp/cyrene-config-facts.test.mjs \ // && node /tmp/cyrene-config-facts.test.mjs // // `tsc --noEmit`(npm run build 的一部分)会类型检查本文件,因此它不 import node:*。 import type { AdminConfigFacts } from "../../lib/types"; import { BACKTHINK_HEARTBEAT_KEYS, FACT_MISSING_VALUE, MEMORY_TIDY_KEYS, QUIET_HOURS_KEYS, factRows, heartbeatPurpose, heartbeatRows, memoryTidyRows, quietHoursPurpose, quietHoursRows, quietHoursSummary, } from "./configFacts"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function same(got: T, want: T, what: string): void { ok( JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`, ); } function has(haystack: string, needle: string): boolean { return haystack.includes(needle); } /** 生效值全为默认的常态响应(后端在默认部署下会这么回)。 */ const DEFAULTS: AdminConfigFacts = { facts: [ { key: "MEMORY_TIDY_ENABLED", value: "true", default: "true", isDefault: true }, { key: "MEMORY_TIDY_INTERVAL_SEC", value: "86400", default: "86400", isDefault: true }, { key: "MEMORY_TIDY_QUIET_START_HOUR", value: "23", default: "23", isDefault: true }, { key: "MEMORY_TIDY_QUIET_END_HOUR", value: "7", default: "7", isDefault: true }, { key: "MEMORY_TIDY_RECALL_THRESHOLD", value: "0.8", default: "0.8", isDefault: true }, { key: "MEMORY_TIDY_SCAN_LIMIT", value: "5000", default: "5000", isDefault: true }, { key: "MEMORY_TIDY_CANDIDATE_LIMIT", value: "20", default: "20", isDefault: true }, { key: "ENABLE_BACKGROUND_THINKING", value: "true", default: "true", isDefault: true }, { key: "THINK_INTERVAL_SEC", value: "900", default: "900", isDefault: true }, { key: "THINK_QUIET_INTERVAL_SEC", value: "3600", default: "3600", isDefault: true }, { key: "QUIET_START_HOUR", value: "23", default: "23", isDefault: true }, { key: "QUIET_END_HOUR", value: "7", default: "7", isDefault: true }, ], quietHours: { startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00" }, }; // ── ① 值就是后端给的生效值(含单位),不重算 ───────────────────────────────── const tidy = memoryTidyRows(DEFAULTS); same(tidy.length, 7, "记忆整理恰好 7 行(后端的 7 个键)"); same( tidy.map((r) => r.key), [...MEMORY_TIDY_KEYS], "键序与后端事实表一致", ); const interval = tidy.find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC"); same(interval?.value, "86400 秒", "周期带单位显示,数值原样取后端"); same(interval?.state, "默认", "isDefault=true ⇒ 显示「默认」"); const threshold = tidy.find((r) => r.key === "MEMORY_TIDY_RECALL_THRESHOLD"); same(threshold?.value, "0.8", "比例值原样显示(后端已归一化)"); // ── ② 「已改」只由 isDefault 决定(不反推 .env) ───────────────────────────── const changed: AdminConfigFacts = { ...DEFAULTS, facts: DEFAULTS.facts.map((f) => f.key === "MEMORY_TIDY_INTERVAL_SEC" ? { ...f, value: "3600", isDefault: false } : f, ), }; const changedRow = memoryTidyRows(changed).find((r) => r.key === "MEMORY_TIDY_INTERVAL_SEC"); same(changedRow?.value, "3600 秒", "改过的值如实显示"); same(changedRow?.state, "已改", "isDefault=false ⇒ 显示「已改」"); same( memoryTidyRows(changed).filter((r) => r.state === "已改").length, 1, "只有真的改过的那一行是「已改」", ); // ── ③ 后端没给的键如实显示「未报告」,绝不编值 ────────────────────────────── const partial: AdminConfigFacts = { facts: DEFAULTS.facts.filter((f) => f.key !== "MEMORY_TIDY_SCAN_LIMIT"), quietHours: DEFAULTS.quietHours, }; const missing = memoryTidyRows(partial).find((r) => r.key === "MEMORY_TIDY_SCAN_LIMIT"); same(missing?.value, FACT_MISSING_VALUE, "缺的键显示「未报告」而不是 0 或默认值"); same(missing?.state, FACT_MISSING_VALUE, "缺的键状态也是「未报告」"); same(memoryTidyRows(null).length, 7, "整份响应都没拿到时,7 行仍然都在(全为未报告)"); ok( memoryTidyRows(null).every((r) => r.state === FACT_MISSING_VALUE), "没有响应时不留空白行,全部如实标「未报告」", ); // ── ④ 安静时段:窗口摘要 + 用途文案(新语义,不得说「夜间全静默」) ───────── same(quietHoursRows(DEFAULTS).map((r) => r.key), [...QUIET_HOURS_KEYS], "安静时段两行"); same(quietHoursSummary(DEFAULTS.quietHours), "23:00-07:00(跨午夜)", "跨午夜窗口如实标注"); same( quietHoursSummary({ startHour: 1, endHour: 5, disabled: false, label: "01:00-05:00" }), "01:00-05:00", "非跨午夜窗口不加多余后缀", ); same( quietHoursSummary({ startHour: 23, endHour: 23, disabled: true, label: "已关闭" }), "已关闭(安静时段从不生效)", "窗口关闭必须说「从不生效」,不能画成 23:00-23:00 的假窗口", ); same( quietHoursSummary({ startHour: 23, endHour: 7, disabled: false, label: "23:00-07:00", fallbackToDefault: true }), "23:00-07:00(两端写 0 = 未填,运行时回落到默认窗口)", "0/0 被当成「未填」时必须说明回落,不能谎报成「已关闭」", ); same(quietHoursSummary(null), FACT_MISSING_VALUE, "没有 quietHours 时如实报未报告"); const purpose = quietHoursPurpose(); ok(has(purpose, "定时主动思考"), "用途文案必须点明它服务「定时主动思考 → 主动搭话」"); ok(has(purpose, "不静默回复") || has(purpose, "不会因为到了安静时段就被静默"), "用途文案必须说清回复侧不被静默"); ok(!has(purpose, "夜间全静默"), "不得沿用已废弃的旧语义「夜间全静默」"); ok(!has(purpose, "群聊静默"), "群聊夜间静默已被取消,不得写成有"); // ── ⑤ factRows 对未知键不崩、原样透出键名 ─────────────────────────────────── const unknown = factRows(DEFAULTS, ["NOT_A_REAL_KEY"]); same(unknown[0].label, "NOT_A_REAL_KEY", "没有元数据的键用键名兜底(不编中文标签)"); same(unknown[0].value, FACT_MISSING_VALUE, "没有元数据的键如实「未报告」"); // ── ⑥ 后台思考心跳:3 行 + 静默降频的用途文案(design/12 §N A4) ───────────── same( heartbeatRows(DEFAULTS).map((r) => r.key), [...BACKTHINK_HEARTBEAT_KEYS], "心跳 3 行,键序与后端事实表一致", ); same( heartbeatRows(DEFAULTS).find((r) => r.key === "THINK_QUIET_INTERVAL_SEC")?.value, "3600 秒", "降频间隔带单位显示,数值原样取后端", ); same( heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.value, "true", "总开关原样显示后端布尔值", ); // ⚠️ 诚实边界:这个开关**未接线**(全仓无消费者,DevDocs/audit/07 §1-17)。 // 面板必须说清「配了不生效」,否则它会把一个死开关显示成生效开关。 ok( has( heartbeatRows(DEFAULTS).find((r) => r.key === "ENABLE_BACKGROUND_THINKING")?.label ?? "", "未接线", ), "未接线的总开关必须在标签里标明,不能显示成生效开关", ); same(heartbeatRows(null).length, 3, "整份响应都没拿到时,3 行仍然都在(全为未报告)"); ok( heartbeatRows(null).every((r) => r.state === FACT_MISSING_VALUE), "没有响应时全部如实标「未报告」,不编 0 或默认值", ); const hbPurpose = heartbeatPurpose(); ok(has(hbPurpose, "只把「例行自主思考」的心跳放慢"), "必须点明降频只作用于周期性心跳"); ok(has(hbPurpose, "事件驱动"), "必须点明事件驱动通路不受影响(安全域照旧立刻触发)"); ok(has(hbPurpose, "≤0"), "必须说明 ≤0 = 关闭降频"); ok(has(hbPurpose, "不是「半夜完全不思考」"), "不得把 ≤0 说成「半夜完全不思考」"); // eslint-disable-next-line no-console console.log(`configFacts: ${checks} checks passed`);