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 全绿。
199 lines
9.0 KiB
Go
199 lines
9.0 KiB
Go
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 用),本片不许动")
|
||
}
|
||
}
|