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

199 lines
9.0 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"
"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
// 由塔同步,这里只钉行为):
//
// ① 同句负面态度优先于例外:一句里既有「安静点」又有「报错」⇒ 仍进冷却、仍静默;
// ② **真故障/求救信号**(严格子集 coldShoulderPierceKeywords)穿透冷却 ——
// 只放行这一条,且**不解除**冷却;**不是**高价值关键词全表(`bug`/`error`/`help`
// 与情绪歧义的 `崩溃`/`坏了`/`坏掉` 被剔出去,矩阵里有对应行);
// ③ @她 / @全体 **不穿透**(那正是主人 2026-10-14「给」的实质);
// ④ 主人无需例外:owner 车道在 shouldRespond 第一步就分岔,走不到冷避闸。
//
// 冷避闸本体的顺序修正(M18 主体)与登记/词表/可见性等用例见
// engage_coldshoulder_test.go(同包,共享 coldShoulderMention / coldShoulderBridge 夹具)。
// TestColdShoulderCooldownMatrix 是塔 2026-10-15 例外口径定稿后的**冷却期矩阵**:
// 逐个钉住「谁能穿透、谁不能」,行序刻意把「穿透之后仍在冷却」紧排在高价值关键词行之后。
//
// 每行的 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: "本轮红前红;例外②按**正文**判定,@她 不在排除项里(与裁定 ①的差别在于本条含真故障)",
},
{
// 真机实证(群 531082168,2026-10-14):机器人卡片文案里出现 `bug`。
// 例外②收窄前(用 hasHighValueKeyword 全表)这条会**穿透冷却**;
// 收窄成严格子集后不穿透 —— 这正是塔 2026-10-15 改裁定的依据。
name: "bot card containing bug does NOT pierce",
msg: func() adapter.UnifiedMessage {
return nonOwnerGroupMsg("【猪工智能】Lv.2 正在学习 —— 唯一的bug是你至今没搞懂为什么猪喜欢拱泥")
},
wantReply: false,
bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行这张卡片)",
},
{
// ASCII `error` / `help` 同属被剔的三类。如实记录代价:用英文写的
// 真求救不再穿透(塔裁定以「宁可少穿透」为准)。
name: "english error/help does NOT pierce",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("there is an error in my code, help me") },
wantReply: false,
bite: "**本轮红前红**(上一提交用全表 ⇒ 会放行);同时如实记录严格子集的已知代价",
},
{
// 情绪/口语歧义的 `崩溃` 同属被剔的三类(「我崩溃了」= 情绪,不是故障)。
name: "emotional 崩溃 does NOT pierce",
msg: func() adapter.UnifiedMessage { return nonOwnerGroupMsg("这周稿子又没写完,我崩溃了") },
wantReply: false,
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)
}
}
// TestColdShoulderPierceKeywordSubset 直接钉住例外②的**严格子集**本身:保留的八条命中,
// 被剔的三类(ASCII `bug`/`error`/`help`、情绪歧义的 `崩溃`、口语「糟糕」义的 `坏了`/`坏掉`)
// 不命中。这是防「后人顺手把全表并回来」的第一道闸(第二道是矩阵里的卡片/英文/情绪三行)。
//
// 同时钉住**分工边界**:`hasHighValueKeyword` 全表一个字未改 —— `bug` 在 ruleLightJudge
// 眼里仍是高价值词(基线行为),只是不再拥有「穿透冷避」的权力。
func TestColdShoulderPierceKeywordSubset(t *testing.T) {
t.Parallel()
cases := []struct {
name string
text string
want bool
}{
{"report error", "服务报错了,谁知道怎么回事", true},
{"error occurred", "这里出错了", true},
{"fault", "群文件传输故障", true},
{"urgent", "this is urgent", true},
{"rescued", "救命啊", true},
{"asking for help", "帮帮我看看这个", true},
{"help me", "谁能帮我一下", false}, // 「帮我」不在表里,只有「帮帮我」在
{"bug in a bot card", "唯一的bug是你至今没搞懂为什么猪喜欢拱泥", false},
{"english error/help", "there is an error in my code, help me", false},
{"emotional collapse", "这周稿子又没写完,我崩溃了", false},
{"colloquial oops", "坏了,我忘了", false},
{"plain chatter", "今晚吃点什么", false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := hasColdShoulderPierceKeyword(tc.text); got != tc.want {
t.Fatalf("hasColdShoulderPierceKeyword(%q) = %v, want %v", tc.text, got, tc.want)
}
})
}
// 分工边界:全表(基线)不许被这次收窄波及。
if !hasHighValueKeyword("唯一的bug是你至今没搞懂为什么猪喜欢拱泥") {
t.Fatal("hasHighValueKeyword 被改动了:它是基线行为(ruleLightJudge 用),本片不许动")
}
}