feat(channelbridge): 群聊静默窗口合并与回合中续跑(M23)
主人 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/。
This commit is contained in:
1 parent
679c8abf1d
commit
ca8577f05b
21 files changed
+3616
-244
No files matched your search
@@ -0,0 +1,140 @@
|
||||
// 群聊静默窗口卡(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();
|
||||
Reference in new issue
Block a user