Files
Cyrene_v2/internal/services/channelbridge/coalesce_owner_test.go
T
AskaEth 1ae9158250 test(channelbridge): 窗口夹具按截止时刻推钟 + 去掉无法证实的替身锁(M33 r1 复审)
P1-1(channelbridge 的 -race 门仍红,红在用例不确定性而不是竞态):
- coalesceTimers 改成**截止时刻**语义:每次 after(d) 记 at = 注入时钟 + d,
  推钟只把时钟推进到「当前最早的截止时刻」再触发它。旧写法按 FIFO 逐个触发并
  按请求时长累加,实测 12 条消息内攒到 5 个废弃定时器把时钟推 +10s(窗口 2s、
  绝对上限 6s)⇒ 循环头走「到点直接发车、不 takeAll」的分支,队列尾部落到下一批。
- 推钟前强制「车道排空」这条不变量(pump/advanceAndFire/waitIdle):
  时钟只表示时间过去,它一转就可能跳过 takeAll,因此先等 worker 把货收进本批。
- sendSettled:投递后按**序号**等「这次投递之后新挂的定时器」(旧的都已被 worker
  放弃、再没人监听),供「投递与推钟交替」的用例(每 500ms 一条的绝对上限用例)。
- fireLatest/advanceAndFire 带 done 谓词重试,避免「登记与触发之间续臂」导致
  推了钟却没人醒;waitIdle 一边等一边推窗口(worker 停在窗口里时永远走不到退休)。
- 用例侧只补相位与等待,不动断言:TwelveBurst 读历史前等 assistant 行落库;
  准入用例断言「最新一次续臂」(原来按下标取 d[1],隐含臂数假设);
  stale 用例两条都投完再推时钟(车道不会在两条之间退休)。

P2-1(替身锁无法用变异证实)选 A:删掉 stubChatter/stubSessions 的锁与访问器,
退回原字段访问,并写清理由 —— 21 个用例全走同步便捷路径
(b.process → processFrom → runBatch → Loop.Run),DEV HEAD 上 -race 数十次跑
(含把锁删掉的变异)DATA RACE 恒为 0;若将来从车道 worker 触碰这些字段必须加锁
并补 -race 用例。

证据:go test -race -count=3 ./internal/services/channelbridge/ 连跑 6 轮
18/18 全绿、DATA RACE=0;coalesce 子集 -count=80 全绿;整包 -race -count=20 全绿;
全树 go build/vet/test -count=1 全绿。
2026-10-24 23:18:45 +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.advanceAndFireUntil(t)
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)
}
}