package channelbridge // coalesce_fixture_test.go 是 M23(静默窗口合并 + 回合中续跑)全部用例共用的夹具: // // - **注入时钟**(laneClock):窗口与陈旧度共用同一个时间源(Options.Now), // 因此「2.0s 静默」「6s 绝对上限」「30s 超龄」都能被确定性推进; // - **注入定时器工厂**(coalesceTimers):它既让窗口等待可以被手动触发, // **又是「主人私聊从未建窗口」的证据面** —— calls 必须为 0; // - **群消息夹具**:非主人、带提及(⇒ 必然进回合)、同一群共用一个隔离会话。 // // 夹具全部落在 *_test.go 里,不进生产二进制。 import ( "context" "sync" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session" "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/user" ) // coalesceOffFor 是「把静默窗口关掉」的注入面:lane_test.go 的 M22 语义用例用它, // 使那些用例仍以毫秒级钉住车道本身(顺序/串行/背压/陈旧度)。 func coalesceOffFor(string) *runtimeconfig.CoalescePolicy { p := runtimeconfig.DefaultCoalescePolicy() p.Enabled = false return &p } // coalesceOn 返回一组「群聊与陌生人私聊都开窗口」的生效参数。 func coalesceOn(windowMs, maxWaitMs, maxBatch int) runtimeconfig.CoalescePolicy { p := runtimeconfig.DefaultCoalescePolicy() p.Enabled = true p.WindowMs = windowMs p.MaxWaitMs = maxWaitMs p.MaxBatchMessages = maxBatch p.ApplyToStrangers = true p.StrangerWindowMs = windowMs return p } // coalesceFor 把一组固定参数包成注入面(同一实例可被多条车道共用)。 func coalesceFor(p runtimeconfig.CoalescePolicy) func(string) *runtimeconfig.CoalescePolicy { return func(string) *runtimeconfig.CoalescePolicy { c := p return &c } } // newCoalesceClock 返回一个**本地正午**的注入时钟。 // // 起点刻意避开免打扰时段(23-07):本文件测的是窗口合并,而凌晨的注入时钟会让 // 非主人群消息先在「免打扰」那一档被静默掉 —— 那样每条用例都变成了空断言 // (测试要观察的只是「什么时候发车」,不是「说不说」)。 func newCoalesceClock() *laneClock { c := &laneClock{} c.t = time.Date(2026, 5, 1, 12, 0, 0, 0, time.Local) return c } // coalesceTimers 是注入的定时器工厂:记录每次请求的时长,交回一个由测试手动触发的 // 通道(带缓冲,测试的发送永远不会阻塞)。 // // 🔴 它同时是红线用例的证据面:主人私聊**一次都不该**调用它 // (TestOwnerPrivateNeverCreatesWindow 断言 calls==0)。 type coalesceTimers struct { mu sync.Mutex calls []time.Duration pending []coalesceTimer } // coalesceTimer 是一次定时器请求:通道 + 当时请求的时长(测试按它推进注入时钟, // 因此「到点」与「请求的时长」永远一致,不存在测试把时钟推多了的伪影)。 type coalesceTimer struct { ch chan time.Time d time.Duration } func (t *coalesceTimers) after(d time.Duration) <-chan time.Time { ch := make(chan time.Time, 1) t.mu.Lock() t.calls = append(t.calls, d) t.pending = append(t.pending, coalesceTimer{ch: ch, d: d}) t.mu.Unlock() return ch } // callCount 返回定时器被请求的总次数。 func (t *coalesceTimers) callCount() int { t.mu.Lock() defer t.mu.Unlock() return len(t.calls) } // durations 返回每次请求的时长(按请求顺序)。 func (t *coalesceTimers) durations() []time.Duration { t.mu.Lock() defer t.mu.Unlock() return append([]time.Duration(nil), t.calls...) } // take 取走**最早一个尚未触发**的定时器(等它有请求为止)。 func (t *coalesceTimers) take(tb *testing.T, timeout time.Duration) coalesceTimer { tb.Helper() deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { t.mu.Lock() if len(t.pending) > 0 { timer := t.pending[0] t.pending = t.pending[1:] t.mu.Unlock() return timer } t.mu.Unlock() time.Sleep(time.Millisecond) } tb.Fatalf("no coalesce timer registered within %s (calls=%d)", timeout, t.callCount()) return coalesceTimer{} } // advanceAndFire 按**该定时器请求的时长**推进注入时钟,然后触发它 // (= 「静默窗口正好到点」)。 func (t *coalesceTimers) advanceAndFire(tb *testing.T, clock *laneClock) { tb.Helper() timer := t.take(tb, 3*time.Second) clock.advance(timer.d) timer.ch <- clock.Now() } // tryTake 非阻塞地取走最早一个尚未触发的定时器(没有就返回 false)。 func (t *coalesceTimers) tryTake() (coalesceTimer, bool) { t.mu.Lock() defer t.mu.Unlock() if len(t.pending) == 0 { return coalesceTimer{}, false } timer := t.pending[0] t.pending = t.pending[1:] return timer, true } // pump 反复「有定时器就按它请求的时长推进时钟并触发」,直到 done() 成立(或 3s 超时)。 // // 为什么不是「按次数触发」:窗口到达的方式有两种 —— 静默到点(要定时器)与**绝对上限 // 已过**(循环头直接发车,**根本不建定时器**)。测试若假定「一定会有定时器」, // 就会被调度时序(worker 什么时候启动)绊倒;pump 因此只看**结果条件**。 func (f *coalesceBridge) pump(t *testing.T, done func() bool) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { if done() { return } if timer, ok := f.timers.tryTake(); ok { f.clock.advance(timer.d) timer.ch <- f.clock.Now() continue } time.Sleep(time.Millisecond) } t.Fatalf("condition never met within 3s (runs=%d, stats=%+v)", f.chat.runCount(), f.b.CoalesceStats()) } // coalesceGroupSessions 给**每个群**一个稳定的隔离会话:同一群的多个成员 = 同一个会话 // (真实 SessionManager 就是这么派生的),因此「合成一轮」的落库行落在同一处。 type coalesceGroupSessions struct{} func (coalesceGroupSessions) GetOrCreate(_ context.Context, msg adapter.UnifiedMessage, _ *user.OwnerProfile) (session.Session, error) { return session.Session{ Platform: msg.Platform, ChannelID: msg.ChannelID, SessionID: "sess-" + msg.GroupID, IsGroup: true, MemWritable: false, }, nil } // coalesceGroupMsg 是一条**非主人、指向她本人**的群消息:它因此必然进回合 // (不受最小打扰分级影响),是静默窗口最干净的夹具。 func coalesceGroupMsg(platform, groupID, sender, senderName, content string) adapter.UnifiedMessage { return adapter.UnifiedMessage{ Platform: platform, ChannelID: groupID, ChannelType: adapter.ChannelGroup, GroupID: groupID, SenderID: sender, SenderName: senderName, Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: []adapter.Mention{{UserID: sender}}, Content: content, ContentType: adapter.ContentText, } } // coalesceBridge 是一个装好注入时钟与定时器工厂的桥接器(外加它的日志/历史/审计面)。 type coalesceBridge struct { b *Bridge chat *laneChatter ad *laneAdapter clock *laneClock timers *coalesceTimers } // newCoalesceBridge 构造夹具:opts.Loop 必须是 *laneChatter(用例要读回合记录), // 时钟与定时器工厂由夹具注入(opts.Now / opts.Coalesce.After 若已给出则不覆盖)。 func newCoalesceBridge(t *testing.T, opts Options) *coalesceBridge { t.Helper() chat, ok := opts.Loop.(*laneChatter) if !ok { t.Fatalf("newCoalesceBridge needs a *laneChatter loop, got %T", opts.Loop) } clock := newCoalesceClock() timers := &coalesceTimers{} if opts.Now == nil { opts.Now = clock.Now } opts.Coalesce.After = timers.after if opts.Sessions == nil { opts.Sessions = coalesceGroupSessions{} } if opts.Owner == nil { opts.Owner = &user.OwnerProfile{} } b, err := New(opts) if err != nil { t.Fatalf("New: %v", err) } ad := &laneAdapter{name: "telegram"} b.Add(ad) return &coalesceBridge{b: b, chat: chat, ad: ad, clock: clock, timers: timers} } // snapshot 在锁内返回某个会话已落库的行(测试直接读 h.rows 会与车道 worker 的 // 写入构成数据竞争 —— -race 下必红,因此统一走这里)。 func (h *laneHistory) snapshot(sessionID string) []history.Message { h.mu.Lock() defer h.mu.Unlock() return append([]history.Message(nil), h.rows[sessionID]...) } // send 投递一条消息(走真实的 onMessage 入口:Validate → 流控闸 → 车道)。 // // ctx 取 b.ctx(与生产的读循环一致:`readLoop` 传的就是桥自己的生命周期 context), // 这样 Stop 取消的车道 worker 与用例投递的消息用的是同一棵树 —— 否则「停机诚实」 // 这条用例根本验证不到真实路径(b.ctx 为 nil 时退回 Background)。 func (f *coalesceBridge) send(msg adapter.UnifiedMessage) { ctx := f.b.ctx if ctx == nil { ctx = context.Background() } f.b.onMessage(ctx, msg, f.ad) } // userTextsIn 返回一次回合的模型可见消息里的 user 文本(按顺序)。 func userTextsIn(msgs []model.Message) []string { out := make([]string, 0, len(msgs)) for _, m := range msgs { if m.Role != model.RoleUser { continue } out = append(out, lastText(m)) } return out } // waitRuns 等至少 n 个回合开跑(封装 chatter 的同名方法,省得到处传超时)。 func (f *coalesceBridge) waitRuns(t *testing.T, n int) { t.Helper() f.chat.waitRuns(t, n, 3*time.Second) } // waitIdle 等到车道全部退休。 func (f *coalesceBridge) waitIdle(t *testing.T) { t.Helper() waitLaneIdle(t, f.b, 2*time.Second) }