Files
Cyrene_v2/internal/services/channelbridge/coalesce_owner_test.go
T
AskaEth ca8577f05b 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/。
2026-10-24 21:11:22 +08:00

137 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
// coalesce_owner_test.go 是 M23 的**红线**断言集:主人私聊必须**结构性**地没有静默窗口。
//
// 断言的层次(塔 2026-10-24 要求「断言状态,不是断言结果」):
//
// ① `timers.callCount() == 0` —— 定时器工厂一次都没被调用(窗口**从未被创建**);
// ② `lane.batcher == nil` —— 那条车道的类型里根本没有窗口状态;
// ③ `CoalesceStats().WindowsCreated == 0` —— 计数面也证明没有窗口;
// ④ 消息立刻被回答(没有 2s 迟滞)。
//
// 变异检验(塔说会亲自做):把车道类型判定(ownerPrivateLane)改成「主人私聊也走
// batch 车道」⇒ ①②③ 立刻红。
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// laneForTest 在锁内取一条车道(测试用;车道可能已退休 ⇒ nil)。
func laneForTest(b *Bridge, key string) *lane {
b.lanes.mu.Lock()
defer b.lanes.mu.Unlock()
return b.lanes.lanes[key]
}
// sent 返回该适配器已经送出去的回复(并发安全)。
func (a *laneAdapter) sent() []adapter.UnifiedMessage {
a.mu.Lock()
defer a.mu.Unlock()
return append([]adapter.UnifiedMessage(nil), a.sends...)
}
// ownerPrivateBridge 构造一个「主人私聊」夹具:主人白名单 telegram/100,会话层报
// Owner=true(与生产 SessionManager 的派生一致)。
func ownerPrivateBridge(t *testing.T, chat *laneChatter, p func(string) *runtimeconfig.CoalescePolicy) *coalesceBridge {
t.Helper()
return newCoalesceBridge(t, Options{
Loop: chat,
Sessions: &laneSessions{owner: true},
Owner: testOwnerProfile("telegram", "100"),
Coalesce: CoalesceOptions{For: p},
})
}
// assertNoTimer 在「等第一轮开跑」的同时盯住定时器工厂:一旦它被调用就立刻失败
// (变异体的**第一种红**正是这里:主人私聊被塞进 batch 车道 ⇒ 窗口被建出来)。
func assertNoTimer(t *testing.T, f *coalesceBridge, chat *laneChatter) {
t.Helper()
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if chat.runCount() > 0 {
return
}
time.Sleep(time.Millisecond)
}
}
// TestOwnerPrivateNeverCreatesWindow 断言红线:主人私聊**不建窗口、不建定时器**,
// 且消息立刻被回答。
func TestOwnerPrivateNeverCreatesWindow(t *testing.T) {
t.Parallel()
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(coalesceOn(2000, 6000, 24)))
f.send(ownerMsg("主人说话"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
// 车道还在(回合卡住)⇒ 这一刻的类型判定是可观察的。
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil {
t.Fatalf("owner private lane %q is missing while its turn is running", ownerPrivateLaneKey)
} else if l.batcher != nil {
t.Fatalf("owner private lane has a batch window (%+v): the type must be direct", l.batcher)
}
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("coalesce timer factory called %d times for owner private traffic, want 0", calls)
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 0 {
t.Fatalf("windows_created = %d for owner private traffic, want 0", stats.WindowsCreated)
}
close(gate)
f.waitIdle(t)
if got := f.ad.sent(); len(got) != 1 {
t.Fatalf("replies = %d, want 1 (owner private must be answered immediately)", len(got))
}
}
// TestOwnerPrivateIgnoresEnabledConfig 断言「配置无权翻转车道类型」:把配置开到最激进
// (enabled / applyToStrangers 全开、窗口 10s),主人私聊仍然没有定时器、没有窗口;
// 而同一条配置对**群聊**是生效的(同一个定时器工厂会被调用)—— 后者是这条用例的
// **对照组**,用来证明 calls==0 不是「工厂没接上」这种伪结论。
func TestOwnerPrivateIgnoresEnabledConfig(t *testing.T) {
t.Parallel()
aggressive := coalesceOn(10000, 30000, 24)
aggressive.Enabled = true
aggressive.ApplyToStrangers = true
gate := make(chan struct{})
chat := &laneChatter{}
chat.onRun = func(string) { <-gate }
f := ownerPrivateBridge(t, chat, coalesceFor(aggressive))
f.send(ownerMsg("主人私聊"))
assertNoTimer(t, f, chat)
chat.waitRuns(t, 1, 3*time.Second)
if calls := f.timers.callCount(); calls != 0 {
t.Fatalf("timer factory called %d times with an aggressive config, want 0", calls)
}
if l := laneForTest(f.b, ownerPrivateLaneKey); l == nil || l.batcher != nil {
t.Fatalf("owner private lane = %+v, want a direct lane (batcher == nil)", l)
}
// 对照组:同一条配置对群聊**生效**(定时器被请求并按点到发车)。
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "群里的消息"))
f.timers.advanceAndFire(t, f.clock)
close(gate)
chat.waitRuns(t, 2, 3*time.Second)
f.waitIdle(t)
if calls := f.timers.callCount(); calls == 0 {
t.Fatal("control group: group traffic created no timer, so the fixture proves nothing")
}
if stats := f.b.CoalesceStats(); stats.WindowsCreated != 1 {
t.Fatalf("windows_created = %d, want 1 (only the group batch waited)", stats.WindowsCreated)
}
}