主人 2026-10-23/24:「要让群聊聊天很自然,很流畅的做法」。群里「一鼓作气打三句」 此前会让她回三次;本片在**每会话车道头部**加一段静默等待,把这几句合成**一轮**回答 (批内每条仍是独立的一条消息,design/15 §2.1 不许拼成一条)。 四条不可走样: - 🔴 主人私聊**结构性**无窗口:车道分 `newDirectLane`(无 batcher 字段、拿不到定时器 工厂)与 `newBatchLane`,类型由 actor 元数据(IsOwner() && !IsGroupChat())决定, 配置无权翻转; - 🔴 窗口内零丢失:唯一容器是车道队列(takeAll 单消费者取出),合并阶段无新增丢弃点 (CoalescedDroppedTotal 恒 0);超单批上限的**尾部回插队首**保序、锁内、发车前; - 🔴 一条批 = 一个 turnID = 一次 loop.Run = 一条助手回复; - 🔴 不改「回什么」:闸门仍逐条跑(prepareInbound 逐字搬运 M22 的 processFrom), 被静默的消息进不了批。 实现落点: - lane.go:direct/batch 两种车道 + front(回插队首)+ takeAll + 停机诚实登记; - coalesce.go:生效参数解析、滑动窗口 + 绝对上限等待、发车原因与计数; - coalesce_lane.go:攒批 → 拆分 → 陈旧度/闸门 → 发车; - coalesce_prepare.go / coalesce_batch.go:processFrom 拆成 prepareInbound + runBatch; - runtimeconfig/channels_coalesce.go:$DATA_DIR/config/coalesce.json(零新 .env 键); - gateway/admin_coalesce.go:GET/PUT /api/v1/admin/channels/coalesce(三态补丁 + writeAuth + 审计 channels.coalesce + 热生效),routes.go 两行注册(塔已批); - 前端:渠道总览页一张卡(六个旋钮;主人私聊只给只读说明,不给开关)。 M22 既有用例仍在(lane_test.go 夹具显式关窗以钉住车道本身);stranger_test.go 的那条 按塔裁定 #4 改写:「已有回合在跑 ⇒ 只登记不回」变成排队回答(留 WARN,绝不静默)。 回归:go build/vet/test ./... 全绿;三包 -race 全绿;前端 tsc --noEmit + npm run build + 渲染测试 36 项通过。不 push、不重启后端、未改 .env 与 Docs/。
141 lines
5.5 KiB
TypeScript
141 lines
5.5 KiB
TypeScript
// 群聊静默窗口卡(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> = {}): 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<ReturnType<typeof draftFromView>>): 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();
|