package channelbridge import ( "context" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop" ) // 本文件覆盖冷避**冷却期内的例外口径**(塔 2026-10-15 定稿;文档面 design/18 §5/§9 // 由塔同步,这里只钉行为): // // ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默; // ② 高价值关键词(真故障)**穿透**冷却 —— 只放行这一条,且**不解除**冷却; // ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质); // ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。 // // 冷避闸本体的顺序修正(M18 主体)与登记/词表/可见性等用例见 // engage_coldshoulder_test.go(同包,共享 coldShoulderMention / coldShoulderBridge 夹具)。 // TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**: // 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。 // // 每行对「本轮修前」= M18 提交 fe53e02 的性质用 bite 标出(红前红 / 护栏)—— // 不得把护栏吹成证据。 func TestColdShoulderCooldownMatrix(t *testing.T) { t.Parallel() cases := []struct { name string msg func() adapter.UnifiedMessage wantReply bool bite string }{ { name: "fault keyword pierces the cooldown", msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("服务报错了,谁知道怎么回事") }, wantReply: true, bite: "本轮红前红(fe53e02 的冷避闸会把它静默掉,即「让她漏掉真故障」)", }, { name: "fault keyword addressed to her pierces too", msg: func() adapter.UnifiedMessage { return coldShoulderMention("系统报错了,快看看") }, wantReply: true, bite: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)", }, { name: "cooldown is NOT lifted by the exception", msg: func() adapter.UnifiedMessage { return coldShoulderMention("晚上有空吗") }, wantReply: false, bite: "护栏(fe53e02 亦绿):钉住穿透只放行那一条,不撤诉 ---- 本片的命根子", }, { name: "plain chatter stays silenced", msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("今晚吃点什么") }, wantReply: false, bite: "护栏(fe53e02 亦绿)", }, { name: "negative attitude wins inside the same message", msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("别刷屏了,线上报错了") }, wantReply: false, bite: "护栏(fe53e02 亦绿):钉住例外②不得被插到负面态度判定之前(对 base 是红)", }, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() // 每行一张干净的桥接器、各自由真机原话①触发冷却,使各行的前状态完全一致。 now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) chat := &stubChatter{res: &loop.Result{Text: "好嘛"}} hist := &captureHistory{} b := coldShoulderBridge(t, chat, hist, func() time.Time { return now }) b.process(context.Background(), coldShoulderMention("减少文本输出量")) if chat.sessionID != "" { t.Fatalf("未能进入冷却:循环被驱动 = %q", chat.sessionID) } before := len(hist.records()) b.process(context.Background(), tc.msg()) if got := chat.sessionID != ""; got != tc.wantReply { t.Fatalf("冷却期内循环被驱动 = %v, want %v(%s)", got, tc.wantReply, tc.bite) } // 静默 ≠ 丢弃:本条必须已登记(被放行的那条在回合里另有一份落库)。 if len(hist.records()) <= before { t.Fatalf("本条没有被登记:records 停在 %d", before) } }) } } // TestColdShoulderHighValueKeywordBaselineOutsideCooldown 是反例护栏(塔要求 2 的第 4 条): // 冷却期**已过**后,高价值关键词与普通消息的行为与基线完全一致 —— 前者照旧必回 // (ruleLightJudge 的高价值关键词路径),后者照旧只登记(未装配引擎时失败关闭)。 // // 诚实标注:本条对 base 与本轮修前**都是绿的**,它是**护栏而非红前红** —— // 它守的是「例外只改冷却期内,不碰冷却之外的既有语义」。 func TestColdShoulderHighValueKeywordBaselineOutsideCooldown(t *testing.T) { t.Parallel() now := time.Date(2026, 10, 14, 0, 27, 0, 0, time.UTC) chat := &stubChatter{res: &loop.Result{Text: "我看看"}} b := coldShoulderBridge(t, chat, &captureHistory{}, func() time.Time { return now }) b.process(context.Background(), coldShoulderMention("减少文本输出量")) // 进冷却 now = now.Add(defaultCooldownDuration + time.Minute) // 推过冷却 chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("服务报错了")) if chat.sessionID != "sess-g" { t.Fatalf("冷却过后的高价值关键词未回:session = %q, want sess-g", chat.sessionID) } chat.sessionID = "" b.process(context.Background(), nonOwnerGroupMsg("今晚吃点什么")) if chat.sessionID != "" { t.Fatalf("冷却过后的普通消息不该被驱动:session = %q", chat.sessionID) } }