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,343 @@
|
||||
/**
|
||||
* 渠道「静默窗口合并」卡(M23)。真源:internal/services/gateway/admin_coalesce.go。
|
||||
*
|
||||
* 它回答的是主人的原话「要让群聊聊天很自然,很流畅的做法」:群里「一鼓作气打三句」
|
||||
* 不该让她回三次 —— 她在群里等一小段静默,把这几句**合并成一轮**回答(批内每条仍是
|
||||
* 独立的一条消息,绝不拼成一条)。
|
||||
*
|
||||
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
|
||||
* 1. **主人私聊没有这个窗口**:她的私聊一条一条立刻回(`ownerPrivate:"never"` 是后端
|
||||
* 下发的只读事实,面板**不给**开关 —— 那条车道由身份元数据决定,配置翻不开);
|
||||
* 2. **越界不拒绝、会夹紧**:规则随读下发(`rules`),面板就地校验,后端仍会 clamp
|
||||
* 并留 WARN —— 不让主人白跑,也不假装「你配的数就是生效的数」;
|
||||
* 3. **保存即热生效**:桥每次发车前重读同一份配置,所以 `hot:true` 是事实。
|
||||
*
|
||||
* 呈现与取数分层:`CoalesceCardBody` 只吃状态(渲染测试直接喂 fixture),
|
||||
* `ChannelCoalesceCard` 负责取数 + 表单草稿 + 保存。
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { request } from "../../../lib/api/core";
|
||||
import { Button, Surface, Tag } from "../../../components/primitives";
|
||||
import { Notice } from "../../components";
|
||||
import { useAuditStore } from "../../audit";
|
||||
|
||||
/** 一组窗口参数(后端 AdminCoalescePolicy 的同形投影)。 */
|
||||
export interface CoalescePolicyView {
|
||||
enabled: boolean;
|
||||
windowMs: number;
|
||||
maxWaitMs: number;
|
||||
maxBatchMessages: number;
|
||||
applyToStrangers: boolean;
|
||||
strangerWindowMs: number;
|
||||
}
|
||||
|
||||
/** 取值规则(后端 AdminCoalesceRules)。 */
|
||||
export interface CoalesceRules {
|
||||
windowMs: [number, number];
|
||||
maxWaitMs: [number, number];
|
||||
maxBatchMessages: [number, number];
|
||||
clamp: string;
|
||||
flushReasons: string[];
|
||||
}
|
||||
|
||||
/** GET/PUT 的 data 形状(后端 AdminCoalesceView)。 */
|
||||
export interface CoalesceView {
|
||||
path: string;
|
||||
present: boolean;
|
||||
hot: boolean;
|
||||
default: CoalescePolicyView;
|
||||
patch: Record<string, unknown>;
|
||||
conversations: Record<string, CoalescePolicyView>;
|
||||
rules: CoalesceRules;
|
||||
ownerPrivate: string;
|
||||
}
|
||||
|
||||
/** 表单草稿:一律字符串(输入框的原样值),保存时才转数字。 */
|
||||
export interface CoalesceDraft {
|
||||
enabled: string;
|
||||
windowMs: string;
|
||||
maxWaitMs: string;
|
||||
maxBatchMessages: string;
|
||||
applyToStrangers: string;
|
||||
strangerWindowMs: string;
|
||||
}
|
||||
|
||||
/** 只读事实的文案(渲染测试钉住它必须出现)。 */
|
||||
export const OWNER_PRIVATE_NOTE =
|
||||
"主人私聊不受此设置影响:她的私聊没有静默窗口,一条一条立刻回(由身份元数据决定,面板没有开关)";
|
||||
|
||||
/** 热生效文案。 */
|
||||
export const COALESCE_HOT_LABEL = "已热生效(下一次发车就用新值,不用重启后端)";
|
||||
|
||||
/** 字段顺序与中文标签(表单项由它驱动,避免手写六段重复代码)。 */
|
||||
export const COALESCE_FIELDS: ReadonlyArray<{
|
||||
key: keyof CoalesceDraft;
|
||||
label: string;
|
||||
kind: "bool" | "int";
|
||||
hint: string;
|
||||
}> = [
|
||||
{ key: "enabled", label: "启用群聊静默窗口", kind: "bool", hint: "关掉 = 恢复到「到达即回、一条一轮」" },
|
||||
{ key: "windowMs", label: "静默窗口(毫秒)", kind: "int", hint: "新消息会重置计时(等她把话说完)" },
|
||||
{ key: "maxWaitMs", label: "绝对上限(毫秒)", kind: "int", hint: "从这一批第一条算起,到点必发车" },
|
||||
{ key: "maxBatchMessages", label: "单批上限(条)", kind: "int", hint: "超出不是丢弃,是多出来的排成下一批" },
|
||||
{ key: "applyToStrangers", label: "对陌生人私聊生效", kind: "bool", hint: "关掉 = 陌生人私聊逐条回" },
|
||||
{ key: "strangerWindowMs", label: "陌生人私聊窗口(毫秒)", kind: "int", hint: "仅在「对陌生人私聊生效」时使用" },
|
||||
];
|
||||
|
||||
/** draftFromView 把生效值铺成草稿。 */
|
||||
export function draftFromView(view: CoalesceView): CoalesceDraft {
|
||||
return {
|
||||
enabled: String(view.default.enabled),
|
||||
windowMs: String(view.default.windowMs),
|
||||
maxWaitMs: String(view.default.maxWaitMs),
|
||||
maxBatchMessages: String(view.default.maxBatchMessages),
|
||||
applyToStrangers: String(view.default.applyToStrangers),
|
||||
strangerWindowMs: String(view.default.strangerWindowMs),
|
||||
};
|
||||
}
|
||||
|
||||
/** parseCoalescePatch 把草稿按**三态**转成补丁:只有与当前生效值不同的键才写。 */
|
||||
export function parseCoalescePatch(
|
||||
view: CoalesceView,
|
||||
draft: CoalesceDraft
|
||||
): { patch: { default: Record<string, number | boolean> }; errors: Record<string, string>; changed: string[] } {
|
||||
const errors: Record<string, string> = {};
|
||||
const out: Record<string, number | boolean> = {};
|
||||
const changed: string[] = [];
|
||||
const intIn = (key: string, value: string, lo: number, hi: number): number | null => {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
errors[key] = "要填整数(毫秒/条)";
|
||||
return null;
|
||||
}
|
||||
const n = Number(trimmed);
|
||||
if (n < lo || n > hi) {
|
||||
errors[key] = `取值范围 ${lo}–${hi}`;
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
};
|
||||
const window = intIn("windowMs", draft.windowMs, view.rules.windowMs[0], view.rules.windowMs[1]);
|
||||
const maxWait = window === null
|
||||
? null
|
||||
: intIn("maxWaitMs", draft.maxWaitMs, Math.max(view.rules.maxWaitMs[0], window), view.rules.maxWaitMs[1]);
|
||||
const batch = intIn("maxBatchMessages", draft.maxBatchMessages, view.rules.maxBatchMessages[0], view.rules.maxBatchMessages[1]);
|
||||
const strangerWindow = intIn(
|
||||
"strangerWindowMs",
|
||||
draft.strangerWindowMs,
|
||||
view.rules.windowMs[0],
|
||||
view.rules.windowMs[1]
|
||||
);
|
||||
const enabled = draft.enabled === "true";
|
||||
const applyToStrangers = draft.applyToStrangers === "true";
|
||||
const put = (key: string, next: number | boolean, current: number | boolean): void => {
|
||||
if (next !== current) {
|
||||
out[key] = next;
|
||||
changed.push(key);
|
||||
}
|
||||
};
|
||||
if (window !== null) put("windowMs", window, view.default.windowMs);
|
||||
if (maxWait !== null) put("maxWaitMs", maxWait, view.default.maxWaitMs);
|
||||
if (batch !== null) put("maxBatchMessages", batch, view.default.maxBatchMessages);
|
||||
if (strangerWindow !== null) put("strangerWindowMs", strangerWindow, view.default.strangerWindowMs);
|
||||
put("enabled", enabled, view.default.enabled);
|
||||
put("applyToStrangers", applyToStrangers, view.default.applyToStrangers);
|
||||
return { patch: { default: out }, errors, changed };
|
||||
}
|
||||
|
||||
/** fetchCoalesce 读一次配置(面板配置面,writeAuth = owner|admin)。 */
|
||||
export function fetchCoalesce(): Promise<CoalesceView> {
|
||||
return request<CoalesceView>("/admin/channels/coalesce");
|
||||
}
|
||||
|
||||
/** saveCoalesce 提交三态补丁(返回**重新读盘**后的真值)。 */
|
||||
export function saveCoalesce(patch: { default: Record<string, number | boolean> }): Promise<CoalesceView> {
|
||||
return request<CoalesceView>("/admin/channels/coalesce", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
}
|
||||
|
||||
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
|
||||
export interface CoalesceBodyProps {
|
||||
view: CoalesceView;
|
||||
draft: CoalesceDraft;
|
||||
errors: Record<string, string>;
|
||||
busy: boolean;
|
||||
receipt: string | null;
|
||||
savedView: CoalesceView | null;
|
||||
onDraftChange: (key: keyof CoalesceDraft, value: string) => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/** CoalesceCardBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
|
||||
export function CoalesceCardBody({
|
||||
view,
|
||||
draft,
|
||||
errors,
|
||||
busy,
|
||||
receipt,
|
||||
savedView,
|
||||
onDraftChange,
|
||||
onSave,
|
||||
}: CoalesceBodyProps): React.ReactNode {
|
||||
const hasErrors = Object.keys(errors).length > 0;
|
||||
const inputCls =
|
||||
"w-36 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tag tone="accent">{COALESCE_HOT_LABEL}</Tag>
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
{view.present ? "面板配置已存在" : "面板配置还没写过(运行期走代码默认:2.0s / 6s / 24 条)"}
|
||||
</span>
|
||||
</div>
|
||||
<Notice>{OWNER_PRIVATE_NOTE}</Notice>
|
||||
{COALESCE_FIELDS.map((field) => (
|
||||
<div key={field.key} className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{field.label}</span>
|
||||
{field.kind === "bool" ? (
|
||||
<select
|
||||
value={draft[field.key]}
|
||||
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||||
>
|
||||
<option value="true">开</option>
|
||||
<option value="false">关</option>
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={draft[field.key]}
|
||||
inputMode="numeric"
|
||||
className={inputCls}
|
||||
onChange={(e) => onDraftChange(field.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{field.key}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-[var(--mu-text-lo)]">{field.hint}</div>
|
||||
{errors[field.key] ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {errors[field.key]}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
|
||||
{busy ? "保存中…" : "保存窗口设置"}
|
||||
</Button>
|
||||
{hasErrors ? (
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
|
||||
) : null}
|
||||
</div>
|
||||
{receipt ? <Notice>{receipt}</Notice> : null}
|
||||
{savedView ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">保存后回显(后端重新读盘的真值):</span>
|
||||
<div className="font-mono text-[var(--mu-text-lo)]">
|
||||
窗口 {savedView.default.windowMs}ms · 上限 {savedView.default.maxWaitMs}ms · 单批{" "}
|
||||
{savedView.default.maxBatchMessages} 条 · {savedView.default.enabled ? "已启用" : "已关闭"}
|
||||
</div>
|
||||
<div className="text-[var(--mu-text-mid)]">{savedView.hot ? COALESCE_HOT_LABEL : "已落盘,但未能热生效"}</div>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.keys(view.conversations).length > 0 ? (
|
||||
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
|
||||
<span className="text-[var(--mu-text-mid)]">每会话覆盖(在配置文件里维护,键 = 平台|渠道):</span>
|
||||
{Object.entries(view.conversations).map(([key, policy]) => (
|
||||
<div key={key} className="font-mono text-[var(--mu-text-lo)]">
|
||||
{key} → {policy.enabled ? "启用" : "关闭"} · 窗口 {policy.windowMs}ms · 单批 {policy.maxBatchMessages} 条
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<p className="text-[11px] text-[var(--mu-text-lo)]">
|
||||
规则:窗口 {view.rules.windowMs[0]}–{view.rules.windowMs[1]}ms、绝对上限 ≥ 窗口且 ≤ {view.rules.maxWaitMs[1]}ms、单批{" "}
|
||||
{view.rules.maxBatchMessages[0]}–{view.rules.maxBatchMessages[1]} 条;越界值由后端夹紧({view.rules.clamp})并留日志。
|
||||
发车原因:{view.rules.flushReasons.join(" / ")}。
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** ChannelCoalesceCard 负责取数、表单草稿与保存。 */
|
||||
export function ChannelCoalesceCard(): React.ReactNode {
|
||||
const [view, setView] = useState<CoalesceView | null>(null);
|
||||
const [draft, setDraft] = useState<CoalesceDraft | null>(null);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
const [receipt, setReceipt] = useState<string | null>(null);
|
||||
const [savedView, setSavedView] = useState<CoalesceView | null>(null);
|
||||
const log = useAuditStore((s) => s.log);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setFail(null);
|
||||
return fetchCoalesce()
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
})
|
||||
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取窗口配置失败"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const onDraftChange = useCallback((key: keyof CoalesceDraft, value: string) => {
|
||||
setDraft((prev) => (prev ? { ...prev, [key]: value } : prev));
|
||||
setReceipt(null);
|
||||
}, []);
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
if (!view || !draft) return;
|
||||
const check = parseCoalescePatch(view, draft);
|
||||
setErrors(check.errors);
|
||||
if (Object.keys(check.errors).length > 0) return;
|
||||
if (check.changed.length === 0) {
|
||||
setReceipt("没有改动(一个键都没写)");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setFail(null);
|
||||
saveCoalesce(check.patch)
|
||||
.then((next) => {
|
||||
setView(next);
|
||||
setDraft(draftFromView(next));
|
||||
setSavedView(next);
|
||||
setErrors({});
|
||||
setReceipt(`已保存:${check.changed.join(", ")}。${COALESCE_HOT_LABEL}`);
|
||||
log("channels.coalesce", "ok");
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
setFail(e instanceof Error ? e.message : "保存窗口配置失败");
|
||||
log("channels.coalesce", "error");
|
||||
})
|
||||
.finally(() => setBusy(false));
|
||||
}, [view, draft, log]);
|
||||
|
||||
return (
|
||||
<Surface className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">群聊静默窗口合并</h3>
|
||||
<Tag tone="neutral">GET / PUT /api/v1/admin/channels/coalesce</Tag>
|
||||
</div>
|
||||
{fail ? <Notice tone="err">{fail}</Notice> : null}
|
||||
{view === null || draft === null ? (
|
||||
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
|
||||
) : (
|
||||
<CoalesceCardBody
|
||||
view={view}
|
||||
draft={draft}
|
||||
errors={errors}
|
||||
busy={busy}
|
||||
receipt={receipt}
|
||||
savedView={savedView}
|
||||
onDraftChange={onDraftChange}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { PLATFORM_LABEL, PLATFORM_TYPES } from "../channelFields";
|
||||
import { adapterNameOf, configuredAccountCount, configuredPlatforms, platformOf } from "./channelUtils";
|
||||
import { channelListState, channelStatus } from "./channelStatus";
|
||||
import { ChannelLoadError, ChannelStatusTag } from "./channelStatusView";
|
||||
import { ChannelCoalesceCard } from "./CoalesceCard";
|
||||
import type { AdapterNav } from "./useAdapterNav";
|
||||
|
||||
/** Page ① — aggregate all adapters: connection state, message volume, health.
|
||||
@@ -79,6 +80,9 @@ export function OverviewPage({ navigate }: { navigate: (n: AdapterNav) => void }
|
||||
onOpenList={() => navigate({ kind: "page", page: "list" })}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{/* 群聊静默窗口合并(M23):窗口/上限这类调优只该在面板改,不该 SSH 改文件。 */}
|
||||
<ChannelCoalesceCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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