Files
Cyrene_v2/internal/services/channelbridge/notice_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

381 lines
13 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 (
"context"
"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/model"
)
// 本文件覆盖 design/12 §6 策略一补充的「系统通知传入前审查」:归一化 / 分类 /
// fail-safe 三条路径,以及它与审查模型(策略四同一注入点)的复用点。
// fail-safe 的保守方向写死在 noticeFailSafeProceeds(false = 静默),变异验证即把它
// 改成 true —— 下面 TestNoticeReviewFailureFailsSafeSilent、
// TestNoticeReviewerMissingFailsSafeSilent 与 TestPreEntryReviewFailSafeIsSilent
// 会变红。
// noticeAdapter 是最小的 PlatformAdapter + SystemNoticeNormalizer:
// 它按夹具给定的结论回答归一化,并记录收到的原始消息与调用次数。
type noticeAdapter struct {
fakeAdapter
notice adapter.SystemNotice
ok bool
panics bool
calls int
rawSeen []byte
}
func (n *noticeAdapter) NormalizeSystemNotice(msg adapter.UnifiedMessage) (adapter.SystemNotice, bool) {
n.calls++
n.rawSeen = msg.RawData
if n.panics {
panic("malformed platform notice")
}
return n.notice, n.ok
}
// plainAdapter 是**未实现**归一化 seam 的适配器(可选接口缺席时的 fail-safe 路径)。
type plainAdapter struct{ fakeAdapter }
// groupNoticeMessage 是一条带结构化系统事件标记的群通知:ContentType=ContentAction
// 是适配器给出的「非聊天动作信号」标记(判「是否系统」只看这个元数据,绝不看正文)。
func groupNoticeMessage() adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "qq-obv11",
ChannelID: "g1",
ChannelType: adapter.ChannelGroup,
GroupID: "g1",
GroupName: "群一",
SenderID: "system",
SenderName: "system",
Sender: adapter.Actor{Role: adapter.RoleSystem, UserID: "system"},
ContentType: adapter.ContentAction,
RawData: []byte(`{"post_type":"notice","notice_type":"group_upload"}`),
}
}
// noticeFixture 组装一条入站通知所需的桥接器:受限群会话 + 捕获历史 +
// 记录调用的循环。review 为 nil 表示未装配审查模型。
func noticeFixture(t *testing.T, ad adapter.PlatformAdapter, review adapter.Reviewer) (*Bridge, *stubChatter, *stubSessions, *captureHistory) {
t.Helper()
chat := &stubChatter{res: nil}
sess := &stubSessions{sess: session.Session{SessionID: "sess-g1", IsGroup: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, History: hist,
Owner: testOwnerProfile("qq-obv11"),
Review: review,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, chat, sess, hist
}
// systemLines 返回循环收到的系统角色消息文本。
func systemLines(msgs []model.Message) []string {
var out []string
for _, m := range msgs {
if m.Role == model.RoleSystem {
for _, c := range m.Content {
out = append(out, c.Text)
}
}
}
return out
}
// TestNoticeIgnorableRegisteredOnly 覆盖「可忽略 → 仅登记,不喂 LLM,不驱动 loop」。
func TestNoticeIgnorableRegisteredOnly(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeJoin, SafeText: "有成员加入了群聊", Ignorable: true,
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("可忽略的系统通知驱动了 loop:%q", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("登记的记录数 = %d,want 1(仅登记)", len(recs))
}
if recs[0].Content != "有成员加入了群聊" || recs[0].SessionID != "sess-g1" {
t.Errorf("登记内容 = %+v", recs[0])
}
}
// TestNoticeHighValueEscalatesWithoutReview 覆盖「高价值 → 升级真会话」:
// 它越过审查模型(结构判定优先),进入真会话,并以系统通知行**限定注入**。
func TestNoticeHighValueEscalatesWithoutReview(t *testing.T) {
t.Parallel()
reviewed := 0
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) {
reviewed++
return nil, nil
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeLeave, SafeText: "系统通知:她(机器人账号)被移出了群聊", HighValue: true,
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if reviewed != 0 {
t.Errorf("高价值通知不该走剔噪审查,调用次数 = %d", reviewed)
}
if chat.sessionID != "sess-g1" {
t.Fatalf("高价值通知没进真会话:session=%q", chat.sessionID)
}
lines := systemLines(chat.messages)
if len(lines) != 1 || !strings.HasPrefix(lines[0], noticeSystemPrefix) {
t.Fatalf("限定注入的系统行 = %v", lines)
}
for _, m := range chat.messages {
if m.Role == model.RoleUser {
t.Errorf("系统通知被当成用户消息注入:%v", m.Content)
}
}
}
// TestNoticeLimitedInjectReusesReviewModel 覆盖「其余 → 提取安全文本、剥离异常字段后
// 限定注入」,并证明它复用的是 design/12 §6 策略四的**同一个**审查模型注入点:
// 审查器只看得到剥离后的安全文本,且原始载荷与附件已被剥离。
func TestNoticeLimitedInjectReusesReviewModel(t *testing.T) {
t.Parallel()
var probe adapter.UnifiedMessage
review := func(_ context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
if len(msgs) != 1 {
t.Fatalf("审查器应收到单条消息,收到 %d 条", len(msgs))
}
probe = msgs[0]
return []int{0}, nil
}
dirty := "群文件\u200b上传\x1b[31m\x1b[0m\n\x07超长" + strings.Repeat("啊", 300)
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeOther, SafeText: dirty}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "sess-g1" {
t.Fatalf("审查保留的通知没进上下文:session=%q", chat.sessionID)
}
if probe.RawData != nil || len(probe.Media) != 0 {
t.Errorf("审查器看到了未剥离字段:raw=%q media=%d", probe.RawData, len(probe.Media))
}
if strings.ContainsAny(probe.Content, "\u200b\x1b\x07\n") {
t.Errorf("审查器看到的安全文本未剥离异常字符:%q", probe.Content)
}
if len([]rune(probe.Content)) > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("安全文本未截断:%d runes", len([]rune(probe.Content)))
}
lines := systemLines(chat.messages)
if len(lines) != 1 || !strings.Contains(lines[0], "群文件上传") {
t.Fatalf("限定注入的系统行 = %v", lines)
}
if got := len([]rune(strings.TrimPrefix(lines[0], noticeSystemPrefix))); got > adapter.NoticeSafeTextMaxRunes+1 {
t.Errorf("注入文本未截断:%d runes", got)
}
}
// TestNoticeReviewDroppedStaysSilent 覆盖审查模型判「没必要」那一侧:只登记、不进 loop。
func TestNoticeReviewDroppedStaysSilent(t *testing.T) {
t.Parallel()
review := func(context.Context, []adapter.UnifiedMessage) ([]int, error) { return nil, nil }
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, hist := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("被剔噪的通知仍驱动了 loop:%q", chat.sessionID)
}
if recs := hist.records(); len(recs) != 1 || recs[0].Content != "群/客户端系统事件" {
t.Errorf("被剔噪后未登记:%+v", recs)
}
}
// TestNoticeReviewFailureFailsSafeSilent 是 fail-safe 的核心用例:
// 审查模型报错 / 超时(这里用 DeadlineExceeded 模拟超时)时**保守侧 = 静默**。
// 变异验证(把 noticeFailSafeProceeds 改成 true)会让本用例变红。
func TestNoticeReviewFailureFailsSafeSilent(t *testing.T) {
t.Parallel()
var hadDeadline bool
review := func(ctx context.Context, _ []adapter.UnifiedMessage) ([]int, error) {
_, hadDeadline = ctx.Deadline()
return nil, context.DeadlineExceeded
}
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, review)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if !hadDeadline {
t.Error("审查调用没有超时上限(design/12 §6 实现要点的超时条款)")
}
if chat.sessionID != "" {
t.Fatalf("审查失败时未 fail-safe 静默:%q", chat.sessionID)
}
}
// TestNoticeReviewerMissingFailsSafeSilent 覆盖「未装配审查模型」:
// 与审查失败同侧(静默),绝不放行未审查内容。
func TestNoticeReviewerMissingFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{
Kind: adapter.NoticeOther, SafeText: "群/客户端系统事件",
}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未装配审查模型时未静默:%q", chat.sessionID)
}
}
// TestNoticeUnparsableFailsSafeSilent 覆盖「格式异常 / 未知 / 解析失败」:
// 归一化 seam 说不认识 ⇒ 只静默,且绝不把原始载荷喂进上下文。
func TestNoticeUnparsableFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: false}
b, chat, _, hist := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("未识别的系统事件驱动了 loop:%q", chat.sessionID)
}
for _, m := range chat.messages {
if strings.Contains(m.Content[0].Text, "notice_type") {
t.Errorf("原始载荷进了上下文:%v", m.Content)
}
}
if len(hist.records()) != 0 {
t.Errorf("无安全文本时不该落一条空登记:%+v", hist.records())
}
}
// TestNoticeNormalizerPanicFailsSafeSilent 覆盖畸形载荷把适配器解析器打崩:
// panic 必须折成 fail-safe 静默,绝不牵连常驻后端。
func TestNoticeNormalizerPanicFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{ok: true, panics: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("归一化 panic 后仍驱动了 loop:%q", chat.sessionID)
}
}
// TestNoticeWithoutNormalizerFailsSafeSilent 覆盖适配器未实现归一化 seam 的情况:
// 看不懂平台事件 ⇒ 静默(宁可登记,也不放行)。
func TestNoticeWithoutNormalizerFailsSafeSilent(t *testing.T) {
t.Parallel()
ad := &plainAdapter{fakeAdapter{name: "qq-obv11"}}
b, chat, _, _ := noticeFixture(t, ad, nil)
b.processFrom(context.Background(), groupNoticeMessage(), ad)
if chat.sessionID != "" {
t.Fatalf("无归一化器时未静默:%q", chat.sessionID)
}
}
// TestPreEntryReviewFailSafeIsSilent 直接钉住审查函数本身的 fail-safe 方向
// (未识别 / 空安全文本都不放行)。变异验证会让它变红。
func TestPreEntryReviewFailSafeIsSilent(t *testing.T) {
t.Parallel()
cases := []struct {
name string
ad adapter.PlatformAdapter
msg adapter.UnifiedMessage
}{
{"未识别", &noticeAdapter{ok: false}, groupNoticeMessage()},
{"无归一化器", &plainAdapter{fakeAdapter{name: "qq-obv11"}}, groupNoticeMessage()},
{"安全文本为空", &noticeAdapter{ok: true, notice: adapter.SystemNotice{Kind: adapter.NoticeOther}}, groupNoticeMessage()},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
b, _, _, _ := noticeFixture(t, tc.ad, nil)
out := b.preEntryReview(context.Background(), &tc.msg, tc.ad)
if !out.Applicable {
t.Fatal("系统事件未被受理")
}
if out.Proceed {
t.Fatalf("fail-safe 方向被反转(应为静默):reason=%q", out.Reason)
}
})
}
}
// TestChatMessageIsNotAReviewSubject 是反向钉:普通聊天消息绝不进传入前审查
// (成本受控:只有系统事件走这条轻审查)。
func TestChatMessageIsNotAReviewSubject(t *testing.T) {
t.Parallel()
ad := &noticeAdapter{notice: adapter.SystemNotice{Kind: adapter.NoticeJoin, SafeText: "x", Ignorable: true}, ok: true}
b, chat, _, _ := noticeFixture(t, ad, nil)
msg := groupNoticeMessage()
msg.ContentType = adapter.ContentText
msg.Content = "大家好"
msg.Sender.Role = adapter.RoleMember
msg.SenderID = "42"
b.processFrom(context.Background(), msg, ad)
if ad.calls != 0 {
t.Errorf("普通聊天消息被送进了归一化 seam:calls=%d", ad.calls)
}
for _, line := range systemLines(chat.messages) {
if strings.HasPrefix(line, noticeSystemPrefix) {
t.Errorf("普通聊天消息被当成系统通知注入:%q", line)
}
}
}
// TestNoticeReviewTimeoutIsBounded 钉住超时常量本身,避免被改成无上限。
func TestNoticeReviewTimeoutIsBounded(t *testing.T) {
t.Parallel()
if noticeReviewTimeout <= 0 || noticeReviewTimeout > 30*time.Second {
t.Fatalf("noticeReviewTimeout = %v,应在 (0, 30s] 内", noticeReviewTimeout)
}
}
// TestNoticeFailSafeDirectionConstant 把保守方向钉在测试里(design/12 §6 策略一补充:
// 审查钩子失败一侧 = 静默)。变异验证改的就是这个常量。
func TestNoticeFailSafeDirectionConstant(t *testing.T) {
t.Parallel()
if noticeFailSafeProceeds {
t.Fatal("fail-safe 保守方向被反转:审查失败/未知/未装配时必须静默(仅登记)")
}
}