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

211 lines
8.8 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/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// 本文件覆盖主人 2026-10-05 裁决①②③的三条口径:
// - 陌生人私聊 = 正常聊天:非群 + 非主人一律直接进入完整回合,任何时刻(含安静时段
// 23:30)都不受安静期或概率门影响;
// - 安静时段的硬静默已从规则层摘除(断言在 light_test.go);
// - 陌生人**必须有流控**:同一发送者每分钟 ≤ FlowDirectDefault 条、同时最多 1 个在跑的
// 回合;触发时只登记 + 一条带 source 的可见告警,绝不静默。
// strangerPrivateSession 是陌生人私聊的隔离会话绑定:非群、非主人、记忆只读。
func strangerPrivateSession() session.Session {
return session.Session{SessionID: "sess-s", IsGroup: false, Owner: false, MemWritable: false}
}
// strangerDirectMsg 构造一条来自陌生人(非主人,由 owner 白名单结构化判定)的私聊消息。
// 私聊的会话键是 (platform, channel, sender),因此换一个 sender 就换一个限流桶。
func strangerDirectMsg(sender, text string) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram",
ChannelID: "100",
ChannelType: adapter.ChannelDirect,
SenderID: sender,
SenderName: "陌生人" + sender,
Sender: adapter.Actor{Role: adapter.RoleMember},
Content: text,
ContentType: adapter.ContentText,
}
}
// newStrangerBridge 构建一条接好 telegram 适配器的陌生人私聊测试桥接器。
func newStrangerBridge(t *testing.T, chat *stubChatter, hist *captureHistory, at time.Time, log *slog.Logger) (*Bridge, *stubSessions, *fakeAdapter) {
t.Helper()
sess := &stubSessions{sess: strangerPrivateSession()}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist, Log: log,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return at },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
return b, sess, ad
}
// TestStrangerPrivateChatRespondsIncludingQuietHours 钉住裁决①:陌生人私聊(非群 + 非主人)
// 在任何时刻都是正常聊天 —— 白天如此,安静时段 23:30 也如此(安静时段不设在她与陌生人之间)。
// 变异验证:把「陌生人跳过概率门」那段摘掉,本用例立刻变红(shouldRespond 会落到规则/参与度
// 链上,无信号 ⇒ 静默、循环不跑)。
func TestStrangerPrivateChatRespondsIncludingQuietHours(t *testing.T) {
t.Parallel()
cases := []struct {
name string
at time.Time
}{
{"daytime", time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)},
{"quiet hours 23:30", time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "你好呀"}}
b, sess, ad := newStrangerBridge(t, chat, &captureHistory{}, tc.at, nil)
msg := strangerDirectMsg("777", "在吗,普通的一句话")
actor := session.ResolveActor(msg, b.owner)
if ok, reason := b.shouldRespond(context.Background(), msg, actor, sess.sess, ad); !ok {
t.Fatalf("shouldRespond = false (%s), want true(陌生人私聊一律正常聊天)", reason)
}
b.process(context.Background(), msg)
if chat.sessionID != "sess-s" {
t.Fatalf("loop session = %q, want the isolated sess-s", chat.sessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "你好呀" {
t.Fatalf("stranger replies = %+v, want one text reply", ad.sends)
}
})
}
}
// TestStrangerPrivateFlowControlPerSender 钉住裁决③:陌生人私聊有强制流控。
// 同一发送者在额度内连发都会被回答;超出 FlowDirectDefault 的第 N+1 条**不回**、
// 只登记,并留一条**带 source 的可见告警**;另一条发送者的额度完全独立、不受挤占。
func TestStrangerPrivateFlowControlPerSender(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}))
at := time.Date(2026, 1, 1, 23, 30, 0, 0, time.UTC)
b, sess, ad := newStrangerBridge(t, chat, hist, at, log)
for i := 0; i < adapter.FlowDirectDefault; i++ {
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "第 "+itoa(i)+" 句"))
if chat.sessionID != "sess-s" {
t.Fatalf("额度内第 %d 条被拒(session=%q)", i+1, chat.sessionID)
}
}
// 第 N+1 条超限:不回,只登记,且有可见日志。
before := len(hist.records())
chat.sessionID = ""
b.process(context.Background(), strangerDirectMsg("777", "刷屏第 N+1 句"))
if chat.sessionID != "" {
t.Fatalf("超限消息驱动了循环:session=%q", chat.sessionID)
}
if got := len(hist.records()) - before; got != 1 {
t.Fatalf("超限消息新增登记 %d 条,want 1(只登记不回)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "stranger private chat rate limited") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("被限流没有留下带 source 的可见日志:%q", logged)
}
// 另一条发送者:额度独立,第一条照常回答。
other := strangerDirectMsg("888", "我是另一个人")
if ok, reason := b.shouldRespond(context.Background(), other, session.ResolveActor(other, b.owner), sess.sess, ad); !ok {
t.Fatalf("另一个发送者被同一额度挤占:reason=%s", reason)
}
}
// TestStrangerPrivateSecondTurnAnsweredNotThrottled 钉住 M23 对裁决③后半句的**改写**:
// 「同一发送者同时最多 1 个在跑的回合」这道闸仍然保留(runBatch 开跑前取、回合结束后还),
// 但「已经有回合在跑 ⇒ 这条只登记、不回」已经被 **排队**取代 —— 塔 2026-10-24 的裁定原文:
// 「陌生人私聊 `AcquireTurn` 那段由 M23 步 3 改(把「已有回合 ⇒ 不回」改成排队)」。
//
// 结构上「排队」由车道提供(同一会话一条车道、单消费者 ⇒ 不可能有两轮同时跑),
// 因此能走到「槽被占」的只剩并发路径(例如本用例直接调 process 而另一条路正占着槽)。
// 这时**绝不静默**:留一条带 source 的 WARN,消息照答(宁可晚回,不许不回)。
//
// 变异检验:把 runBatch 里的那个 WARN 分支改回 registerThrottled + return ⇒ 本用例必红。
func TestStrangerPrivateSecondTurnAnsweredNotThrottled(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, _, ad := newStrangerBridge(t, chat, hist, time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC), log)
msg := strangerDirectMsg("777", "在吗")
gate := b.gateFor(ad)
if gate == nil || !gate.AcquireTurn(msg) {
t.Fatal("占位失败:测试前置条件不成立")
}
t.Cleanup(func() { gate.ReleaseTurn(msg) })
b.process(context.Background(), msg)
if chat.sessionID == "" {
t.Fatal("回合槽被占时消息没有回答:M23 的口径是排队回答,不是不回")
}
if got := len(hist.records()); got != 2 {
t.Fatalf("登记 %d 条,want 2(user + assistant:这条被回答了)", got)
}
logged := logBuf.String()
if !strings.Contains(logged, "answering anyway") ||
!strings.Contains(logged, "source=services/channelbridge") {
t.Fatalf("槽被占照答没有留下带 source 的可见日志:%q", logged)
}
}
// TestNonOwnerLowValueSystemEventRegisteredOnly 钉住既有行为不变:系统/合成发送者的
// 低价值事件只登记、绝不驱动完整循环(design/12 §6 策略一)。
func TestNonOwnerLowValueSystemEventRegisteredOnly(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "不该回"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
msg := nonOwnerGroupMsg("群成员变更通知")
msg.Sender.Role = adapter.RoleSystem
b.process(context.Background(), msg)
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("低价值系统事件驱动了循环:session=%q messages=%+v", chat.sessionID, chat.messages)
}
if got := len(hist.records()); got != 1 {
t.Fatalf("records = %d, want 1(只登记,不回复)", got)
}
}