Files
Cyrene_v2/frontend/web/src/admin/modules/configFacts.test.ts
T
AskaEth 236b202fda feat(backthink): 静默时段心跳降频(A)—— THINK_QUIET_INTERVAL_SEC 默认 3600
主人裁定「先做 A(静默降频)再转向 C」;背景实测:静默期**思考频率不变**(恒定 900s 心跳),只拦打扰 ⇒ 成本照付。
- 新键 THINK_QUIET_INTERVAL_SEC(默认 3600):窗口内用该间隔、窗外仍用 THINK_INTERVAL_SEC;**≤0 = 关闭降频**(不许出现「半夜完全不思考」暗坑);窗口关闭/钟点非法 ⇒ 不降频
- 心跳**每拍按时刻选间隔**(放到循环外就永远不生效——变异实测能打红)
- 🔴 不变量:**事件驱动不被降频影响**(TestBackthink_EventTrigger_NotSlowedByQuietInterval 钉住)
- 事实卡 9→12 条(+思考总开关/普通间隔/静默间隔,标「改需重启」);契约形状未变、未升版
- ⚠️ 如实登记:ENABLE_BACKGROUND_THINKING 是**死配置**(全仓无消费者),已在 env/面板标签/契约三处标注,**是否接线待主人拍**
- 变异 5 条全红;C 的缺口清单(唤醒面 7 处)已回给主会话排期
2026-10-05 22:38:17 +08:00

181 lines
8.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.
// 配置只读事实面(契约 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<T>(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`);