Files
Cyrene_v2/internal/services/channelbridge/coalesce_admission_test.go
T
AskaEth 4c9cabce1b fix(channelbridge): 窗口准入落到车道头 + lane.go 按职责拆分(M23 复审 r1 P2)
P2-1(行为缺陷):窗口准入此前只在 prepareInbound 里算 `solo`,而 prepare 发生在
**窗口等待之后** —— 平台帧(action / 系统通知 / 我们自己的 message_sent 回执)照样
进窗、照样把滑动计时重置:一条回执就能把这一批的静默期一直续住(群友说了三句话,
她因为「撤回事件」迟迟不开口)。这与任务书「窗口只收 chat/image/voice/file」相反。

- `laneWindowAdmits(msg)` 成为**唯一准入判据**,只看结构化事实(ContentType +
  isSystemNoticeMessage,绝不解析正文);
- `splitWindowItems` 在窗口等待阶段把到达的条目分成「进窗口」与「不进窗口」两半;
- 非准入帧**不建窗口、不重置计时**(首条即到达即发车),窗口中途到达的只收进 solos,
  这一批发车后**原样回插队首**各自单跑(保序、不合并、不丢弃);
- prepareInbound 里的 `solo` 保留为第二道保险(适配器将来把平台事件标成 text 时,
  仍会在组批前被切出去)。

P2-2(硬约束):lane.go 682 行 → 按职责纯搬移成 5 个文件(最大 246 行):
lane.go(车道类型与注册表)/ lane_queue.go(入队与取队列)/
lane_worker.go(单消费者与回合槽)/ lane_drop.go(丢弃与停机诚实)/
lane_owner.go(主人私聊判据与车道键)。语义零改动:去注释后的声明集合
(34 个顶层声明,含函数体/字段/常量值)逐字节全等;既有用例一条未改。

顺带:把两处「IsOwner() && !IsGroupChat()」的注释改成代码真正用的判据
(ownerPrivateLane = !msg.IsGroup() && actor.IsOwner()),并记下这处 P3 分叉。

验证:全树 build/vet/test 全绿(73 包 ok);三包 -race 全绿;
新增 4 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
2026-10-24 21:28:26 +08:00

227 lines
9.4 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_admission_test.go 钉住 M23 的**窗口准入条件**(复审 r1 的 P2-1):
//
// 窗口只收 chat/image/voice/file;**不收**系统通知 / action 帧 / 我们自己出站回执。
//
// 为什么这条要用例钉死(而不是靠注释):准入判断必须发生在**窗口等待阶段**(车道头),
// 而不是等窗口到了、prepareInbound 跑完才发现「这条本来不该进批」。否则一条我们自己的
// `message_sent` 回执(它与群聊共用同一条车道键)就能把这一批的静默计时**一直续住**:
// 群友说了三句话,她却因为「撤回事件」「入群事件」「我们自己的回执」而迟迟不开口。
//
// 四条断言(每条都能被「准入失效」这一变异打红):
// - 窗口中途的 action 帧**不重置计时**(剩余等待仍是 500ms,发车时刻不变);
// - 系统通知**不进这一批**(批内 user 行只有聊天消息);
// - 单条出站回执**不建窗口**(定时器一次都没被请求);
// - 对照组:普通聊天消息**仍然**重置计时(否则上面三条可能只是「窗口根本不工作」)。
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
// coalesceActionFrame 是一条**平台帧**(action):撤回 / 入群 / typing / 未知 post_type。
// 它的生产形状与本群聊天共用车道的键(ChannelType=group + GroupID),ContentType=action。
func coalesceActionFrame(platform, groupID, postType string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: platform,
ChannelID: groupID,
ChannelType: adapter.ChannelGroup,
GroupID: groupID,
SenderID: "9",
Sender: adapter.Actor{Role: adapter.RoleMember},
ContentType: adapter.ContentAction,
RawData: []byte(`{"post_type":"` + postType + `"}`),
}
}
// coalesceOutboundReceipt 是**我们自己那条消息的回执**(OneBot 的 message_sent):
// 它是一条 ContentType=action、没有正文的群帧 —— 也正是「能把窗口续住」的那条。
func coalesceOutboundReceipt(platform, groupID string) adapter.UnifiedMessage {
msg := coalesceActionFrame(platform, groupID, "message_sent")
msg.SenderID = "100" // 她自己
msg.Sender = adapter.Actor{Role: adapter.RoleOwner}
return msg
}
// waitTimerCalls 等到定时器工厂至少被请求 n 次,返回到目前为止的**请求时长序列**。
func waitTimerCalls(t *testing.T, f *coalesceBridge, n int) []time.Duration {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if d := f.timers.durations(); len(d) >= n {
return d
}
time.Sleep(time.Millisecond)
}
t.Fatalf("timer factory called %d times, want at least %d", f.timers.callCount(), n)
return nil
}
// fireLatest 按**最后一个**待定定时器自己的时长推进时钟并触发它。
//
// 为什么是最后一个:窗口被重置/续臂时,旧的定时器通道留在那里(不会被触发),
// 真正决定发车时刻的是**最近一次**请求的那个。
func (t *coalesceTimers) fireLatest(tb *testing.T, clock *laneClock) {
tb.Helper()
t.mu.Lock()
if len(t.pending) == 0 {
t.mu.Unlock()
tb.Fatalf("no pending coalesce timer to fire")
}
timer := t.pending[len(t.pending)-1]
t.pending = t.pending[:len(t.pending)-1]
t.mu.Unlock()
clock.advance(timer.d)
timer.ch <- clock.Now()
}
// TestCoalesceActionFrameDoesNotResetWindow 断言**准入在窗口等待阶段就生效**:
// 一条平台帧(action)落在窗口中途,既不进批、也不重置静默计时 ——
// 剩余等待仍是 500ms,发车时刻仍是「m1 到达 + 2.0s」。
//
// 变异:让 laneWindowAdmits 恒真(= 修前行为)⇒ 第二次请求的时长变成 2000ms,本用例红。
func TestCoalesceActionFrameDoesNotResetWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
// 第一条聊天消息 ⇒ 一个完整窗口(2.0s)。
if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond {
t.Fatalf("first window = %s, want 2s", d[0])
}
// 窗口走到 1.5s 时来了一条平台帧。
f.clock.advance(1500 * time.Millisecond)
f.send(coalesceActionFrame("telegram", "-20", "notice.group_recall"))
// 🔴 关键断言(状态,不是结果):下一次请求的**剩余**时长仍是 500ms。
// 若这条帧被当成聊天消息(修前),计时会重置成完整的 2000ms。
d := waitTimerCalls(t, f, 2)
if d[1] != 500*time.Millisecond {
t.Fatalf("re-armed window = %s, want 500ms (an action frame must not reset the quiet window)", d[1])
}
f.timers.fireLatest(t, f.clock)
f.waitRuns(t, 1)
f.waitIdle(t)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (the frame must not add a turn)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 1 || got[0] != "m1" {
t.Fatalf("batch user messages = %v, want [m1] (the frame must not join the batch)", got)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 1 || stats.BatchesTotal != 1 {
t.Fatalf("stats = %+v, want one window and one batch", stats)
}
}
// TestCoalesceSystemNoticeDoesNotJoinBatch 断言系统通知**不进这一批**:
// 两条聊天消息 + 中途一条通知 ⇒ 一轮、批内只有那两条聊天消息。
func TestCoalesceSystemNoticeDoesNotJoinBatch(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
waitTimerCalls(t, f, 1)
f.clock.advance(1500 * time.Millisecond)
f.send(coalesceActionFrame("telegram", "-20", "notice.group_increase"))
// 与 action 帧同理:通知也不许重置计时(剩余仍是 500ms)。
// 变异(准入失效)⇒ 这里拿到 2s,本用例红。
if d := waitTimerCalls(t, f, 2); d[1] != 500*time.Millisecond {
t.Fatalf("re-armed window = %s, want 500ms (a system notice must not reset the quiet window)", d[1])
}
f.timers.fireLatest(t, f.clock)
f.waitRuns(t, 1)
f.waitIdle(t)
if n := chat.runCount(); n != 1 {
t.Fatalf("turns started = %d, want 1 (the notice must not drive its own turn here)", n)
}
if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 || got[0] != "m1" || got[1] != "m2" {
t.Fatalf("batch user messages = %v, want [m1 m2] only", got)
}
if stats := f.b.CoalesceStats(); stats.BatchesTotal != 1 {
t.Fatalf("batches = %d, want 1 (the notice is not a batch)", stats.BatchesTotal)
}
}
// TestCoalesceOutboundReceiptCreatesNoWindow 断言**我们自己的出站回执**不建窗口:
// 单条回执到达 ⇒ 定时器工厂一次都没被请求、没有窗口、没有批、也没有回合。
//
// 变异:让 laneWindowAdmits 恒真 ⇒ 回执会建一个窗口(定时器被请求、windows_created=1),
// 本用例红 —— 这条正是「一条回执就能把窗口续住」的最小复现。
func TestCoalesceOutboundReceiptCreatesNoWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceOutboundReceipt("telegram", "-20"))
// 先看「有没有窗口」再等空闲:修前会建一个永不触发的窗口(calls=1),
// 这里因此留下的是「窗口被建出来了」这条最直接的证据,而不是一句「车道没退休」。
// 轮询到车道退休(或 300ms)是必要的:投递返回时 worker 可能还没起势,
// 一次性读 callCount() 会让这条断言在修前也偶尔通过。
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 an outbound receipt, want 0", calls)
}
if f.b.LaneStats().ActiveLanes == 0 {
break
}
time.Sleep(time.Millisecond)
}
stats := f.b.CoalesceStats()
if stats.WindowsCreated != 0 || stats.BatchesTotal != 0 {
t.Fatalf("stats = %+v, want no window and no batch for a lone receipt", stats)
}
if n := chat.runCount(); n != 0 {
t.Fatalf("turns started = %d for a lone receipt, want 0", n)
}
f.waitIdle(t)
}
// TestCoalesceChatStillResetsWindow 是对照组:普通聊天消息**仍然**重置计时
// (否则上面三条可能只是「窗口本来就不工作」这种伪结论)。
func TestCoalesceChatStillResetsWindow(t *testing.T) {
t.Parallel()
chat := &laneChatter{}
f := newCoalesceBridge(t, Options{
Loop: chat,
Coalesce: CoalesceOptions{For: coalesceFor(coalesceOn(2000, 6000, 24))},
})
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m1"))
if d := waitTimerCalls(t, f, 1); d[0] != 2000*time.Millisecond {
t.Fatalf("first window = %s, want 2s", d[0])
}
f.clock.advance(1500 * time.Millisecond)
f.send(coalesceGroupMsg("telegram", "-20", "9", "张三", "m2"))
if d := waitTimerCalls(t, f, 2); d[1] != 2000*time.Millisecond {
t.Fatalf("re-armed window = %s, want a full 2s (a chat message resets the window)", d[1])
}
f.timers.fireLatest(t, f.clock)
f.waitRuns(t, 1)
if got := userTextsIn(chat.runAt(0).messages); len(got) != 2 {
t.Fatalf("batch user messages = %v, want both chat messages", got)
}
}