// 群聊静默窗口卡(M23)的**真实渲染**测试。 // // 它钉住三件**呈现**的事(逻辑那一半在 CoalesceCard.tsx 的导出函数里): // ① 六个字段都有中文标签 + 一句人话说明(窗口/上限/单批/陌生人/开关); // ② 主人私聊**只给一行只读说明**(`ownerPrivate:"never"` 的呈现),**不给开关**; // ③ 越界/非整数就地报错并拦住保存(后端那道 400 仍在,只是不让主人白跑)。 // // 跑法(与 MemoryTidyTuningCard.render.test.ts 同): // // cd frontend/web \ // && ./node_modules/.bin/esbuild src/admin/modules/channels/coalesceCard.render.test.ts \ // --bundle --platform=node --format=cjs \ // --outfile=/tmp/cyrene-coalesce-card.render.test.cjs \ // && node /tmp/cyrene-coalesce-card.render.test.cjs // // `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。 import { createElement } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { COALESCE_FIELDS, COALESCE_HOT_LABEL, OWNER_PRIVATE_NOTE, CoalesceCardBody, draftFromView, parseCoalescePatch, type CoalesceView, } from "./CoalesceCard"; 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 响应形状的数据(代码默认 + 一条会话覆盖)。 */ function fixtureView(overrides: Partial = {}): CoalesceView { return { path: "/data/config/coalesce.json", present: true, hot: true, default: { enabled: true, windowMs: 2000, maxWaitMs: 6000, maxBatchMessages: 24, applyToStrangers: true, strangerWindowMs: 2000, }, patch: {}, conversations: { "telegram|group:-20": { enabled: false, windowMs: 2000, maxWaitMs: 6000, maxBatchMessages: 24, applyToStrangers: true, strangerWindowMs: 2000, }, }, rules: { windowMs: [300, 15000], maxWaitMs: [300, 30000], maxBatchMessages: [1, 50], clamp: "clamp", flushReasons: ["quiet", "max_wait", "all_stale", "off", "stop"], }, ownerPrivate: "never", ...overrides, }; } function render(view: CoalesceView, draftOverride?: Partial>): string { const draft = { ...draftFromView(view), ...(draftOverride ?? {}) }; const check = parseCoalescePatch(view, draft); return renderToStaticMarkup( createElement(CoalesceCardBody, { view, draft, errors: check.errors, busy: false, receipt: null, savedView: null, onDraftChange: () => undefined, onSave: () => undefined, }) ); } function main(): void { const view = fixtureView(); const html = render(view); // ① 六个字段的中文标签 + 说明都在,且「主人私聊」只有说明、没有开关。 for (const field of COALESCE_FIELDS) { ok(has(html, field.label), `field label missing: ${field.label}`); ok(has(html, field.hint), `field hint missing for ${field.key}`); ok(has(html, field.key), `field key not shown for ${field.key}`); } ok(has(html, OWNER_PRIVATE_NOTE), "owner-private read-only note missing"); ok(has(html, "面板没有开关"), "owner-private note must say the panel has no switch for it"); ok(has(html, COALESCE_HOT_LABEL), "hot label missing"); ok(has(html, "/data/config/coalesce.json"), "config path not shown"); ok(has(html, "telegram|group:-20"), "per-conversation override not listed"); ok(has(html, "300–15000ms"), "window rules not shown"); ok(has(html, "quiet"), "flush reasons not shown"); // ② 三态补丁:只有改过的键才写;没改 ⇒ 空补丁。 const unchanged = parseCoalescePatch(view, draftFromView(view)); ok(Object.keys(unchanged.patch.default).length === 0, "unchanged draft must produce an empty patch"); ok(unchanged.changed.length === 0, "unchanged draft must report no changed keys"); const changed = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "3000" }); ok(changed.patch.default.windowMs === 3000, "changed window must land in the patch"); ok(changed.changed.length === 1, `only the changed key may be written, got ${changed.changed.join(",")}`); // ③ 坏值就地报错,且补丁里不含那个键(不会把一半好一半坏的东西发出去)。 const bad = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "10", maxBatchMessages: "abc" }); ok(bad.errors.windowMs !== undefined, "out-of-range window must be reported"); ok(bad.errors.maxBatchMessages !== undefined, "non-numeric batch must be reported"); ok(bad.patch.default.windowMs === undefined, "invalid window must not be sent"); ok(bad.patch.default.maxBatchMessages === undefined, "invalid batch must not be sent"); // 绝对上限必须 ≥ 窗口(下层规则由面板现算,不能只信后端)。 const waitTooSmall = parseCoalescePatch(view, { ...draftFromView(view), windowMs: "5000", maxWaitMs: "3000" }); ok(waitTooSmall.errors.maxWaitMs !== undefined, "maxWait below the window must be rejected"); const badHtml = render(view, { windowMs: "10" }); ok(has(badHtml, "取值范围"), "invalid draft must render an inline error"); ok(has(badHtml, "有坏值,未提交"), "invalid draft must block the save button"); process.stdout.write(`coalesce card render: ${checks} checks passed\n`); } main();