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 全绿。
137 lines
5.3 KiB
Go
137 lines
5.3 KiB
Go
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)
|
||
}
|
||
}
|