// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。 // // 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts): // ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」); // ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」; // ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。 // // 跑法(与 MemoryTidyDryRunCard.render.test.ts 同): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \ // && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs // // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories"; import { MemoryTidyTuningBody, TIDY_TUNING_COLD_LABEL, TIDY_TUNING_DISCLAIMER, TIDY_TUNING_HOT_LABEL, } from "./MemoryTidyTuningCard"; import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning"; let checks = 0; function ok(cond: boolean, what: string): void { checks += 1; if (!cond) throw new Error(`FAILED: ${what}`); } function has(haystack: string, needle: string): boolean { return haystack.includes(needle); } /** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */ function fixtureView(overrides: Partial = {}): AdminMemoryTidyTuningView { return { path: "config/memory-tidy.json", present: true, hot: false, hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效", specs: [ { key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }, { key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" }, { key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }, { key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }, { key: "ingestGate", kind: "bool", fallback: "code-default" }, { key: "autoApply", kind: "bool", fallback: "code-default" }, { key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" }, ], values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true }, envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 }, ...overrides, }; } function renderBody(view: AdminMemoryTidyTuningView, draft: Record, errors: Record = {}, extra: Record = {}): string { return renderToStaticMarkup( createElement(MemoryTidyTuningBody, { view, draft, errors, busy: false, receipt: null, savedView: null, onDraftChange: () => {}, onSave: () => {}, ...extra, } as never) ); } const view = fixtureView(); const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } }); // ── ① 7 个键:中文标签 + 键名 + 兜底说明 ─────────────────────────────────── for (const key of Object.keys(TUNING_LABELS)) { ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`); ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`); } ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」"); ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点"); ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)"); ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来"); ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec"); ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认"); ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)"); // ── ② 热生效与否:两种口径必须分得清 ─────────────────────────────────────── ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」"); ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值"); ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值"); const coldHtml = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: view, // hot: false }); ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」"); ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」"); ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因"); // ── ③ 坏值就地报错并拦住保存 ─────────────────────────────────────────────── const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, { intervalSec: "平时整理间隔 必须 ≥ 1 秒", }); ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案"); ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)"); ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用"); ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径"); ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)"); // 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。 const missing = fixtureView({ values: {} }); const missingHtml = renderBody(missing, draftFromView(missing)); ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」"); ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)"); // eslint-disable-next-line no-console console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);