Files
Cyrene_v2/internal/services/channelbridge/ownerlane_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +08:00

395 lines
16 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
import (
"bytes"
"context"
"log/slog"
"strings"
"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/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖 design/18 §11「乙 = C」(主人 2026-10-13 裁决「乙 C」):
// - owner 车道默认「不对她说就不开口」:主人群里不对她说的消息**仍进上下文**(落进该群
// 隔离会话,转录读得到)但**不驱动回复**;
// - 指向她本人(@她 / @全体 / 未接线渠道旧判据)**必回**,不被旋钮关掉;
// - 结构化旋钮(每群开关 + 频率)可读可改、可观测,且只影响说不说/多频繁。
// ownerLaneGroupMsg 构造一条来自主人(由白名单结构化判主)的群消息。
// Sender.Role 故意留 member:判主只看白名单(裁决 06.4.2)。
func ownerLaneGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "qq-obv11", ChannelID: "g1", ChannelType: adapter.ChannelGroup,
GroupID: "g1", GroupName: "测试群", SenderID: "10001", SenderName: "主人",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
Content: content, ContentType: adapter.ContentText,
}
}
// ownerGroupSession 是主人在群里的受限会话绑定(群 + 主人 + 记忆只读)。
func ownerGroupSession() session.Session {
return session.Session{SessionID: "sess-og", IsGroup: true, Owner: true, MemWritable: false}
}
// newOwnerLaneBridge 构建一条接了 qq-obv11 适配器、主人白名单命中的测试桥接器。
// now 为 nil 时用固定时钟。
func newOwnerLaneBridge(t *testing.T, chat *stubChatter, sess *stubSessions, hist *captureHistory, now func() time.Time, log *slog.Logger) (*Bridge, *fakeAdapter) {
t.Helper()
if now == nil {
at := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return at }
}
ad := &fakeAdapter{name: "qq-obv11"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("qq-obv11", "10001"),
History: hist, Log: log, Now: now,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, ad
}
// TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied 钉住默认口径「不对她说就不开口」:
// 主人群里一句不对她说的普通消息**只进上下文**(落进该群隔离会话)、**不驱动回合**。
// 变异自证:把 inbound.go 的 owner 短路恢复(`!actor.IsOwner() && !notice.Applicable`)⇒
// 本用例立刻变红(循环会被驱动、chat.SessionID() 非空)。
func TestOwnerLaneUnaddressedGroupMessageRegisteredNotReplied(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
hist := &captureHistory{}
b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil)
b.process(context.Background(), ownerLaneGroupMsg("大家好啊,我今天在家"))
if chat.SessionID() != "" || len(chat.Messages()) != 0 {
t.Fatalf("不对她说的主人群消息驱动了循环:session=%q messages=%+v", chat.SessionID(), chat.Messages())
}
if len(ad.sends) != 0 {
t.Fatalf("不对她说的主人群消息被回复了:%+v", ad.sends)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("records = %d, want 1(进上下文但不回)", len(recs))
}
if recs[0].SessionID != "sess-og" || recs[0].Role != history.RoleUser || recs[0].Content != "大家好啊,我今天在家" {
t.Fatalf("登记记录 = %+v, want 该群隔离会话的 user 原样正文", recs[0])
}
if recs[0].SenderName != "" {
t.Fatalf("主人被标了发送者名 %q(主人恒无标注,design/16 §4.6)", recs[0].SenderName)
}
}
// TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled 钉住硬保留:
// 主人**明确 @ 她本人 / @全体** → 必回,**即使该群旋钮已降到最保守档(降频)**。
func TestOwnerLaneAddressedToHerAlwaysRepliesEvenWhenThrottled(t *testing.T) {
t.Parallel()
cases := []struct {
name string
msg func() adapter.UnifiedMessage
}{
{"direct mention", func() adapter.UnifiedMessage {
m := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"})
m.SelfID = "bot-10001"
m.AddressedToMe = m.AddressedToSelf()
return m
}},
{"mention all", func() adapter.UnifiedMessage {
m := ownerLaneGroupMsg("都来看一眼")
m.SelfID = "bot-10001"
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
}},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
b, ad := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
// 事先把该群压到最保守档 + 一小时频率闸。
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil {
t.Fatalf("SetOwnerLanePolicy: %v", err)
}
b.process(context.Background(), tc.msg())
if chat.SessionID() != "sess-og" {
t.Fatalf("指向她的主人群消息被旋钮挡住了:session=%q", chat.SessionID())
}
if len(ad.sends) != 1 || ad.sends[0].Content != "在的" {
t.Fatalf("指向她的主人群消息未回复:%+v", ad.sends)
}
})
}
}
// TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion 钉住判据的后半句:
// - 未接线渠道(SelfID 为空)上「有提及」按**旧判据**算指向她 —— 保证 kook / webhook
// 的群 @ 仍然必回(不能把「没有信号」当成「没在对我说话」);
// - 已接线渠道上「提及的不是她」→ 不指向她 → 不回。
func TestOwnerLaneUnwiredChannelFallsBackToMentionsCriterion(t *testing.T) {
t.Parallel()
t.Run("unwired channel with mention replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"})
// SelfID 留空 = 未接线;AddressedToMe 恒 false(fail-closed)。
b.process(context.Background(), m)
if chat.SessionID() != "sess-og" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.SessionID())
}
})
t.Run("wired channel mentioning someone else is not addressed", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, nil, nil)
m := ownerLaneGroupMsg("hello", adapter.Mention{UserID: "someone-else"})
m.SelfID = "bot-10001"
m.AddressedToMe = m.AddressedToSelf() // 提及的不是她 ⇒ false
b.process(context.Background(), m)
if chat.SessionID() != "" {
t.Fatalf("已接线渠道里 @ 别人被当成指向她:session=%q", chat.SessionID())
}
})
}
// TestOwnerLaneThrottleEffectiveAndRestorable 钉住结构化旋钮的**生效与可恢复**:
// 打开该群开关 → 非指向消息回;主人说「少说点」→ 落结构化配置、降频生效(不回);
// 写回打开档 → 又回。同时断言旋钮变化留下带 source 的可见日志。
func TestOwnerLaneThrottleEffectiveAndRestorable(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
hist := &captureHistory{}
var logBuf bytes.Buffer
log := slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelDebug}))
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, log)
// 1) 打开该群开关(= 主人/面板要求「多说点」):非指向消息也回。
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil {
t.Fatalf("SetOwnerLanePolicy: %v", err)
}
b.process(context.Background(), ownerLaneGroupMsg("大家今天都在忙什么"))
if chat.SessionID() != "sess-og" {
t.Fatalf("打开开关后非指向消息仍不回:session=%q", chat.SessionID())
}
// 2) 主人说「少说点」→ 落成结构化配置(降频),这一条自己也不再驱动回复。
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
if chat.SessionID() != "" {
t.Fatalf("降频后仍回:session=%q", chat.SessionID())
}
if p := b.OwnerLanePolicy("qq-obv11", "g1"); p.ReplyToUnaddressed {
t.Fatalf("「少说点」未把旋钮落到保守档:%+v", p)
}
// 3) 可恢复:写回打开档 → 又回。
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true}); err != nil {
t.Fatalf("SetOwnerLanePolicy restore: %v", err)
}
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("我再说一句普通的话"))
if chat.SessionID() != "sess-og" {
t.Fatalf("恢复旋钮后仍不回:session=%q", chat.SessionID())
}
// 4) 可观测:旋钮变化留下带 source 的日志。
if logged := logBuf.String(); !strings.Contains(logged, "owner lane") ||
!strings.Contains(logged, "source=services/channelbridge/ownerlane") {
t.Fatalf("旋钮变化没有带 source 的可见日志:%q", logged)
}
}
// TestOwnerLaneReplyGapThrottlesUnaddressedReplies 钉住「多频繁」那一档:
// 开关打开后,非指向消息之间还要过最小间隔闸;但指向她的消息**不受间隔约束**(必回)。
func TestOwnerLaneReplyGapThrottlesUnaddressedReplies(t *testing.T) {
t.Parallel()
now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
clock := func() time.Time { return now }
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, &captureHistory{}, clock, nil)
if err := b.SetOwnerLanePolicy("qq-obv11", "g1", OwnerLanePolicy{ReplyToUnaddressed: true, MinReplyGapSec: 300}); err != nil {
t.Fatalf("SetOwnerLanePolicy: %v", err)
}
// 第一条非指向消息:过闸、回。
b.process(context.Background(), ownerLaneGroupMsg("第一句普通的话"))
if chat.SessionID() != "sess-og" {
t.Fatalf("第一条非指向消息未回:session=%q", chat.SessionID())
}
// 间隔内第二条:被频率闸挡下,只进上下文。
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("第二条普通的话"))
if chat.SessionID() != "" {
t.Fatalf("间隔内的非指向消息仍回:session=%q", chat.SessionID())
}
// 间隔内**指向她**的消息:仍必回(硬保留不受频率闸约束)。
chat.clearSessionID()
addressed := ownerLaneGroupMsg("在吗", adapter.Mention{UserID: "bot-10001"})
addressed.SelfID = "bot-10001"
addressed.AddressedToMe = addressed.AddressedToSelf()
b.process(context.Background(), addressed)
if chat.SessionID() != "sess-og" {
t.Fatalf("间隔内指向她的消息被频率闸挡住:session=%q", chat.SessionID())
}
// 时钟推进过间隔:非指向消息恢复。
now = now.Add(301 * time.Second)
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("过了五分钟再说一句"))
if chat.SessionID() != "sess-og" {
t.Fatalf("过了间隔后非指向消息仍未回:session=%q", chat.SessionID())
}
}
// TestOwnerLanePolicySeamReadsAndPersists 钉住面板写入留好的形状(读写接缝):
// OwnerLaneFor 决定该群生效档位,OwnerLaneSet 收到每次写入(组合根据此落盘)。
func TestOwnerLanePolicySeamReadsAndPersists(t *testing.T) {
t.Parallel()
var saved []OwnerLanePolicy
var savedGroup string
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: ownerGroupSession()},
Owner: testOwnerProfile("qq-obv11", "10001"),
History: &captureHistory{},
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
OwnerLaneFor: func(platform, groupID string) *OwnerLanePolicy {
return &OwnerLanePolicy{ReplyToUnaddressed: true}
},
OwnerLaneSet: func(platform, groupID string, p OwnerLanePolicy) error {
saved = append(saved, p)
savedGroup = groupID
return nil
},
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 读接缝:外部配置决定该群档位 ⇒ 非指向消息也回。
b.process(context.Background(), ownerLaneGroupMsg("普通的一句话"))
if chat.SessionID() != "sess-og" {
t.Fatalf("读接缝未生效:session=%q", chat.SessionID())
}
// 写接缝:主人说「少说点」⇒ 写入被外部存储收到。
chat.clearSessionID()
b.process(context.Background(), ownerLaneGroupMsg("少说点"))
if len(saved) != 1 || savedGroup != "g1" || saved[0].ReplyToUnaddressed {
t.Fatalf("写接缝未收到结构化降频:saved=%+v group=%q", saved, savedGroup)
}
}
// TestOwnerLanePrivateChatsUnaffected 钉住回归:旋钮只管**群**。
// 主人私聊恒回;未进白名单的群消息走既有非主人分级(被 @ 仍回)。
func TestOwnerLanePrivateChatsUnaffected(t *testing.T) {
t.Parallel()
t.Run("owner private always replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}},
Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
if err := b.SetOwnerLanePolicy("telegram", "-20", OwnerLanePolicy{ReplyToUnaddressed: false, MinReplyGapSec: 3600}); err != nil {
t.Fatalf("SetOwnerLanePolicy: %v", err)
}
b.process(context.Background(), directMessage())
if chat.SessionID() != history.MainSessionID || len(ad.sends) != 1 {
t.Fatalf("主人私聊被 owner 车道旋钮影响:session=%q sends=%+v", chat.SessionID(), ad.sends)
}
})
t.Run("non-owner group mention still replies", func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: &captureHistory{},
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
if chat.SessionID() != "sess-g" {
t.Fatalf("非主人群消息的既有分级被 owner 车道影响:session=%q", chat.SessionID())
}
})
}
// TestOwnerLaneSilenceKeepsSpeakerLabelInvariant 钉住 M10 的归属不变量在 owner 车道
// 静默路径上仍闭合:**主人静默行恒无标注**(受限会话里空标注 = 主人本人),
// **非主人静默行恒有标注**(第三方绝不被读成主人)。
func TestOwnerLaneSilenceKeepsSpeakerLabelInvariant(t *testing.T) {
t.Parallel()
t.Run("owner silenced row has no label", func(t *testing.T) {
t.Parallel()
hist := &captureHistory{}
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
b, _ := newOwnerLaneBridge(t, chat, &stubSessions{sess: ownerGroupSession()}, hist, nil, nil)
b.process(context.Background(), ownerLaneGroupMsg("不对她说的主人群消息"))
recs := hist.records()
if len(recs) != 1 || recs[0].SenderName != "" {
t.Fatalf("主人静默行 = %+v, want 无标注(主人本人)", recs)
}
})
t.Run("non-owner silenced row keeps a label", func(t *testing.T) {
t.Parallel()
hist := &captureHistory{}
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
Owner: &user.OwnerProfile{}, History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("群里随手一句普通的话"))
recs := hist.records()
if len(recs) != 1 || recs[0].SenderName != "张三" {
t.Fatalf("非主人静默行 = %+v, want 带发送者标注「张三」", recs)
}
})
}