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 条准入用例在「准入失效」变异下三条必红、对照组保持绿。
227 lines
9.4 KiB
Go
227 lines
9.4 KiB
Go
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)
|
||
}
|
||
}
|