// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。 // // 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 / // 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者 // 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里, // 因此不需要渲染就能钉住。 // // 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \ // && node /tmp/cyrene-memory-tidy-tuning.test.cjs // // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import type { AdminMemoryTidyTuningSpec, AdminMemoryTidyTuningView, } from "../../lib/api/adminMemories"; import { buildTuningPatch, draftFromView, fallbackLabel, fieldError, patchSummary, secondsHint, } from "./memoryTidyTuning"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function eq(got: T, want: T, what: string): void { checks += 1; if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`); } function deepEq(got: unknown, want: unknown, what: string): void { const g = JSON.stringify(got); const w = JSON.stringify(want); checks += 1; if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`); } /** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */ function spec(key: string, kind: string, extra: Partial = {}): AdminMemoryTidyTuningSpec { return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec; } /** view 造一份 GET 响应形状的 fixture。 */ function view(values: Record, envValues?: Record): AdminMemoryTidyTuningView { return { path: "config/memory-tidy.json", present: true, hot: false, hotError: "运行期没有接线可热生效的实现", specs: [ spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }), spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }), spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }), spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }), spec("ingestGate", "bool", { fallback: "code-default" }), spec("autoApply", "bool", { fallback: "code-default" }), spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }), ], values, envValues, }; } // ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。 { const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false })); eq(draft.intervalSec, "10800", "草稿回填盘上值"); eq(draft.ingestGate, "false", "布尔键回填为字符串"); eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)"); eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)"); } // ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。 { const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false }); const draft = draftFromView(base); const untouched = buildTuningPatch(base, draft); deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)"); deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误"); const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" }); deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)"); deepEq(changed.errors, {}, "合法值 ⇒ 无错误"); } // ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底); // 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。 { const base = view({ intervalSec: 3600, tempTtlSec: 604800 }); const draft = draftFromView(base); const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" }); deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)"); const alreadyEmpty = view({ ingestGate: true }); const mix = view({ ingestGate: true }); const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" }); deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)"); } // ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。 { const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 }); const draft = draftFromView(base); const cases: Array<[string, string, string]> = [ ["intervalSec", "abc", "非整数"], ["intervalSec", "0", "低于下界(0 会让整理空转)"], ["intervalSec", "-60", "负数"], ["quietStartHour", "24", "钟点越上界"], ["quietEndHour", "-1", "钟点越下界"], ["tempTtlSec", "1.5", "小数不是整数"], ]; for (const [key, raw, why] of cases) { const res = buildTuningPatch(base, { ...draft, [key]: raw }); ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`); ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`); } // 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。 const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" }); ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处"); eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)"); // 布尔键只认三个取值。 eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错"); eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法"); } // ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。 { eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法"); eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法"); eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法"); } // ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。 { eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })), "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案"); eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })), "跟随面板里的 intervalSec", "面板内兜底文案"); eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案"); eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示"); eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示"); eq(secondsHint(0), "", "非正数不提示"); eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案"); eq(patchSummary({}), "没有需要改的键", "空补丁回执"); } // ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。 { const base = view({ intervalSec: 3600 }); const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " }); deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍"); const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" }); ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数"); } console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);