Files
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

464 lines
18 KiB
Go
Raw Permalink 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/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
)
// stubLightJudge 是可配置的 LightJudge,记录自己被咨询了多少次,
// 并保留最后一次收到的输入(用于断言 inQuiet 真值确实传到了下游判定器)。
type stubLightJudge struct {
resp LightDecision
err error
calls int
last LightInput
}
func (s *stubLightJudge) Judge(_ context.Context, in LightInput) (LightDecision, error) {
s.calls++
s.last = in
return s.resp, s.err
}
// nonOwnerGroupMsg 返回一条来自成员发送者的第三方群消息,带
// 指定的提及。内容不做加工,便于测试自行控制信号。
func nonOwnerGroupMsg(content string, mentions ...adapter.Mention) adapter.UnifiedMessage {
return adapter.UnifiedMessage{
Platform: "telegram", ChannelID: "-20", ChannelType: adapter.ChannelGroup,
GroupID: "-20", GroupName: "Gang", SenderID: "9", SenderName: "张三",
Sender: adapter.Actor{Role: adapter.RoleMember}, Mentions: mentions,
Content: content, ContentType: adapter.ContentText,
}
}
// nonOwnerSession 是非主人测试所用的受限(群聊 / 陌生人)会话
// 绑定:记忆只读、无主人。
func nonOwnerSession() session.Session {
return session.Session{SessionID: "sess-g", IsGroup: true, Owner: false, MemWritable: false}
}
func TestNonOwnerSilencedRegisteredOnly(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)
}
// 无 @/关键词/名字信号的非主人消息被静默(设计/12 §6 策略二):
// 它只登记到隔离会话日志,绝不驱动循环。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran for a no-signal non-owner message: session=%q messages=%+v", chat.sessionID, chat.messages)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("records = %d, want 1 (inbound registered, no assistant reply)", len(recs))
}
if recs[0].SessionID != "sess-g" || recs[0].Role != history.RoleUser || recs[0].Content != "hello all" {
t.Fatalf("registered record = %+v, want isolated sess-g user 'hello all'", recs[0])
}
}
func TestNonOwnerMentionResponds(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)
}
// 被点名(提及)的非主人消息值得回复(策略二),因此它
// 针对隔离群会话驱动完整循环。
b.process(context.Background(), nonOwnerGroupMsg("hello", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g", chat.sessionID)
}
// 受限群上下文 → 第三方边界系统消息加上
// 用户消息(design/16 §4.6 §1),与 TestGroupMessageThirdPartySenderNameAndBoundary 一致。
if len(chat.messages) != 2 || chat.messages[1].Role != model.RoleUser {
t.Fatalf("loop messages = %+v, want boundary + user", chat.messages)
}
if chat.messages[1].Content[0].Text != "hello" {
t.Fatalf("loop user text = %q, want undecorated 'hello'", chat.messages[1].Content[0].Text)
}
if !strings.Contains(chat.messages[0].Content[0].Text, "群「Gang」") {
t.Fatalf("origin/boundary system = %q, want group Gang", chat.messages[0].Content[0].Text)
}
}
// memberLaneBridge 构建一条非主人群消息用的桥接器:主人档案为空(不命中白名单 ⇒
// 走非主人分级),未装配参与度引擎与 LightJudge ⇒ 规则得不出结论时失败关闭为静默,
// 使「必不必回」只由规则判据决定,用例因此确定。
func memberLaneBridge(t *testing.T, chat *stubChatter, hist *captureHistory) *Bridge {
t.Helper()
b, err := New(Options{
Loop: chat, Sessions: &stubSessions{sess: nonOwnerSession()},
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)
}
return b
}
// TestMemberLaneAddressedToHerCriterion 钉住 design/18 §2 触发①(2026-10-13 修订)
// 在**成员车道**上的落地。2026-10-14 真机报障:群员互相 @ 的消息也被她必回
// (`@3889001246`、`@867587435` 两条),病灶就是 `ruleLightJudge` 拿
// 「`Mentions` 非空」当「在叫她」。判据现在与 owner 车道同一份
// (`ownerAddressedToMe`):**指向她本人**(@她 / @全体)才必回。
//
// 「必不必回」用「完整循环有没有被驱动」(chat.sessionID)判定;
// 「不回」还必须是**登记但不回**(历史里恰好一条 user 记录),而不是消息被丢掉。
//
// 变异自证(把判据换回旧的 `if len(msg.Mentions) > 0`):
// - 「@ 别人」一例立刻变红(旧实现把 @ 别人也当成在叫她,循环被驱动);
// - 「AddressedToMe 已置位、提及表为空」一例同样变红(旧实现只认提及表,
// 结构化字段被无视 ⇒ @全体 那种「字段到位但 at 段没落表」的情形丢掉必回)。
func TestMemberLaneAddressedToHerCriterion(t *testing.T) {
t.Parallel()
const selfID = "3443115524" // 她自己的 self_id(2026-10-14 报障现场的同一个号)
cases := []struct {
name string
msg func() adapter.UnifiedMessage
wantReply bool
wantRecords int
}{
{
name: "mentioning someone else (wired) is not addressed to her",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("/今日猪猪", adapter.Mention{UserID: "3889001246"})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf() // @ 的不是她 ⇒ false
return m
},
wantReply: false,
wantRecords: 1,
},
{
name: "addressed to her by name (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@她 在吗", adapter.Mention{UserID: selfID})
m.SelfID = selfID
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
name: "mention all (wired) still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 都来看一眼", adapter.Mention{UserID: "all"})
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
{
// 结构化字段是**主判据**:@全体 的标记到位而 at 段没落进提及表时,
// AddressedToMe 仍为 true,必回语义不得丢。
name: "addressed flag set without mention list still replies",
msg: func() adapter.UnifiedMessage {
m := nonOwnerGroupMsg("@全体成员 集合")
m.SelfID = selfID
m.MentionAll = true
m.AddressedToMe = m.AddressedToSelf()
return m
},
wantReply: true,
},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
hist := &captureHistory{}
b := memberLaneBridge(t, chat, hist)
b.process(context.Background(), tc.msg())
if got := chat.sessionID != ""; got != tc.wantReply {
t.Fatalf("循环被驱动 = %v, want %v(session=%q)", got, tc.wantReply, chat.sessionID)
}
if !tc.wantReply && len(hist.records()) != tc.wantRecords {
t.Fatalf("records = %d, want %d(只登记不回,不是丢掉)", len(hist.records()), tc.wantRecords)
}
})
}
}
// TestMemberLaneUnwiredChannelKeepsMentionFallback 钉住判据的后半句在**成员车道**上
// 同样成立:未接线渠道(SelfID 为空 ⇒ AddressedToMe 恒 false)上的群 @ 按**旧判据**
// 必回。「没有信号」绝不能被当成「没在对我说话」——否则 kook / webhook 等尚未填
// SelfID 的平台,群 @ 会被一并打死。这是 owner 车道 M11 reviewer 给死的写法,
// 本片只是把它接到成员车道上。
func TestMemberLaneUnwiredChannelKeepsMentionFallback(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "收到"}}
b := memberLaneBridge(t, chat, &captureHistory{})
m := nonOwnerGroupMsg("@别人 你好", adapter.Mention{UserID: "someone-else"})
// SelfID 留空 = 未接线 ⇒ AddressedToMe 恒 false(fail-closed)。
if m.AddressedToMe {
t.Fatalf("未接线渠道的 AddressedToMe = true, want false(fail-closed)")
}
b.process(context.Background(), m)
if chat.sessionID != "sess-g" {
t.Fatalf("未接线渠道的群提及未回退到旧判据:session=%q", chat.sessionID)
}
}
func TestNonOwnerHighValueKeywordResponds(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 高价值关键词(求助)即使没有 @ 也值得回复(策略二)。
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want isolated sess-g for a high-value keyword", chat.sessionID)
}
}
// TestNonOwnerQuietHoursRulesDoNotSilenceGroup 钉住裁决②(主人 2026-10-05):
// **安静时段不再是规则层的硬静默**——群里没被 @、无关键词的普通消息在 02:00
// 仍会走到轻思考(LightJudge),由它决定回不回,而不是连判定器都不咨询就被
// 静默。变异验证:把 `if inQuiet { return verdictSilent }` 加回 ruleLightJudge,
// 本用例立刻变红(judge.calls 会是 0、循环不会跑)。
func TestNonOwnerQuietHoursRulesDoNotSilenceGroup(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "夜里这场值得聊"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 无 @、无关键词、无主人显示名的群消息,落在安静时段内。
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1(安静时段不得在规则层硬静默,应交给轻思考)", judge.calls)
}
// 安静时段作为**信号**必须是真的(而非硬编码 false)传到下游判定器。
if !judge.last.InQuietHours {
t.Fatalf("light judge InQuietHours = false, want true at 02:00(安静时段信号失真)")
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g(轻思考说回就该回)", chat.sessionID)
}
}
// TestNonOwnerQuietHoursMentionResponds 保留「安静时段不是硬性全静默」的正面口径:
// 被直接点名的群消息在 02:00 仍直接回,且**根本不咨询轻思考**(规则已能给结论)。
func TestNonOwnerQuietHoursMentionResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "在的"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 策略二:被直接点名的消息在安静时段仍会得到回复。
b.process(context.Background(), nonOwnerGroupMsg("在吗", adapter.Mention{UserID: "bot"}))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (mention should break quiet-hours silence)", chat.sessionID)
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(@ 是规则可直接判定的强信号)", judge.calls)
}
}
// TestNonOwnerQuietHoursKeywordResponds 补齐另一条强信号:命中高价值关键词的群消息
// 在 02:00 也直接回,同样不咨询轻思考。
func TestNonOwnerQuietHoursKeywordResponds(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不该被问到"}}
chat := &stubChatter{res: &loop.Result{Text: "我看看"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, Light: judge,
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("有人在吗,紧急求助"))
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (keyword should break quiet-hours silence)", chat.sessionID)
}
if judge.calls != 0 {
t.Fatalf("light judge calls = %d, want 0(高价值关键词是规则可直接判定的强信号)", judge.calls)
}
}
func TestOwnerUnaffectedInQuietHours(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "主人好"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
ad := &fakeAdapter{name: "telegram"}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: &captureHistory{},
QuietStartHour: 23, QuietEndHour: 7,
Now: func() time.Time { return time.Date(2026, 1, 1, 2, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(ad)
// 主人消息绝不被拦,即使处于安静时段(策略五只管
// 非主人流量):主人私聊仍路由到 Web 主会话
// 并回复。
b.process(context.Background(), directMessage())
if chat.sessionID != history.MainSessionID {
t.Fatalf("owner loop session = %q, want %q", chat.sessionID, history.MainSessionID)
}
if len(ad.sends) != 1 || ad.sends[0].Content != "主人好" {
t.Fatalf("owner reply = %+v, want '主人好'", ad.sends)
}
}
func TestLightJudgeConsultedWhenInconclusive(t *testing.T) {
t.Parallel()
// 白天无信号的消息对规则而言无结论,因此注入的
// LightJudge 获得一次判断机会(设计/12 §6 策略二:规则不确定才轻 LLM)。
judge := &stubLightJudge{resp: LightDecision{Respond: true, Reason: "值得回"}}
chat := &stubChatter{res: &loop.Result{Text: "嗯"}}
sess := &stubSessions{sess: nonOwnerSession()}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{},
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1 (inconclusive rules escalated)", judge.calls)
}
if chat.sessionID != "sess-g" {
t.Fatalf("loop session = %q, want sess-g (judge said respond)", chat.sessionID)
}
}
func TestLightJudgeSilenceIsRespected(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{resp: LightDecision{Respond: false, Reason: "不值得"}}
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,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" {
t.Fatalf("loop ran after the judge said silence: session=%q", chat.sessionID)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}
func TestLightJudgeErrorFailsClosedToSilence(t *testing.T) {
t.Parallel()
judge := &stubLightJudge{err: context.DeadlineExceeded}
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,
Light: judge,
Now: func() time.Time { return time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 轻量判定器出错时失败关闭为静默(不打扰为第一原则)。
b.process(context.Background(), nonOwnerGroupMsg("今天天气不错"))
if judge.calls != 1 {
t.Fatalf("light judge calls = %d, want 1", judge.calls)
}
if chat.sessionID != "" || len(chat.messages) != 0 {
t.Fatalf("loop ran after judge error: session=%q messages=%+v", chat.sessionID, chat.messages)
}
if len(hist.records()) != 1 {
t.Fatalf("records = %d, want 1 (registered, no reply)", len(hist.records()))
}
}