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

138 lines
5.3 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"
"strings"
"testing"
"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/user"
)
// 本文件断言 IM 入站车道的**回合标识接线**(design/15 §2.5.9 的缺口形状):
// IM 没有编排器,落库行过去一律是空 turn_id,于是前端按 (sessionId, turnId)
// 两级分桶时分不进任何回合,只能按时间平铺 —— 面板看着交错只是症状,数据缺
// 归属才是缺口。渠道桥自己就是这条车道的回合权威(turnid.go),因此一次入站
// 回合的两条行(用户消息 + 助手回复)必须带**同一个非空**的 turn id,且它与
// 挂到 loop ctx 上的那个值同源(流帧与历史行同值)。
//
// 主线(主人私聊镜像进主会话)与隔离线(群聊/陌生人留在 sess-xxx)都断言,
// 同时确认隔离语义没被破坏:受限消息仍绝不镜像进主会话。
// assertTurnIDsPaired 断言两条落库行带的 turn id 非空且同值。
func assertTurnIDsPaired(t *testing.T, userRow, assistantRow history.Message) {
t.Helper()
if userRow.TurnID == "" {
t.Fatalf("user row turn_id is empty; want a real turn id (record=%+v)", userRow)
}
if assistantRow.TurnID == "" {
t.Fatalf("assistant row turn_id is empty; want the same turn id as the user row")
}
if userRow.TurnID != assistantRow.TurnID {
t.Fatalf("turn ids differ: user=%q assistant=%q; want one turn id per turn",
userRow.TurnID, assistantRow.TurnID)
}
if !strings.HasPrefix(userRow.TurnID, "turn-") {
t.Fatalf("turn id = %q, want the gateway-compatible %q prefix", userRow.TurnID, "turn-")
}
}
// TestOwnerPrivateTurnIDPersistedAndPaired 覆盖主人私聊(镜像进 Web 主会话):
// 用户消息与助手回复落在主会话,且带同一个非空 turn id。
func TestOwnerPrivateTurnIDPersistedAndPaired(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "微信回复"}}
sess := &stubSessions{sess: session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: testOwnerProfile("telegram", "100"), History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.Add(&fakeAdapter{name: "telegram"})
b.process(context.Background(), directMessage())
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
for _, r := range recs {
if r.SessionID != history.MainSessionID {
t.Fatalf("record session = %q, want %q (owner private mirrors to main)", r.SessionID, history.MainSessionID)
}
}
assertTurnIDsPaired(t, recs[0], recs[1])
// 流帧与历史行**同源**:loop ctx 上挂的 turn id 必须就是落库的那个值,
// 否则前端按帧分桶与按行分桶会得到两个答案。
if got := loop.TurnIDFrom(chat.runCtx); got != recs[0].TurnID {
t.Fatalf("loop ctx turn id = %q, want %q (same as persisted rows)", got, recs[0].TurnID)
}
}
// TestIsolatedGroupTurnIDPersistedAndPaired 覆盖受限(群聊)车道:被放行的非主人
// 群消息仍留在其隔离会话(绝不镜像),同样带上成对的非空 turn id。
func TestIsolatedGroupTurnIDPersistedAndPaired(t *testing.T) {
t.Parallel()
chat := &stubChatter{res: &loop.Result{Text: "group ack"}}
sess := &stubSessions{sess: nonOwnerSession()}
hist := &captureHistory{}
b, err := New(Options{
Loop: chat, Sessions: sess, Owner: &user.OwnerProfile{}, History: hist,
})
if err != nil {
t.Fatalf("New: %v", err)
}
// 被点名 ⇒ 结构上被放行、驱动真回合(design/12 §6 策略二)。
b.process(context.Background(), nonOwnerGroupMsg("hello all", adapter.Mention{UserID: "9"}))
recs := hist.records()
if len(recs) != 2 {
t.Fatalf("appends = %d, want 2 (user + assistant)", len(recs))
}
for _, r := range recs {
if r.SessionID != "sess-g" {
t.Fatalf("record session = %q, want sess-g (restricted lanes never mirror to main)", r.SessionID)
}
}
assertTurnIDsPaired(t, recs[0], recs[1])
}
// TestSilencedNonOwnerStillHasNoTurnID 断言隔离行为不变的那一侧:被静默登记、
// **根本不会被回答**的非主人消息不存在回合,turn id 必须仍为空 —— 给它编一个
// 标识就是把不属于任何回合的行说成有回合。它同时确认这类消息仍只落隔离会话。
func TestSilencedNonOwnerStillHasNoTurnID(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,
})
if err != nil {
t.Fatalf("New: %v", err)
}
b.process(context.Background(), nonOwnerGroupMsg("hello all"))
if chat.sessionID != "" {
t.Fatalf("loop ran for a silenced message: session=%q", chat.sessionID)
}
recs := hist.records()
if len(recs) != 1 {
t.Fatalf("appends = %d, want 1 (registered only, no reply)", len(recs))
}
if recs[0].SessionID != "sess-g" || recs[0].TurnID != "" {
t.Fatalf("registered record = %+v, want isolated sess-g with empty turn_id", recs[0])
}
}