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