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

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.clearSessionID()
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.clearSessionID()
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)
}
}