Files
Cyrene_v2/internal/services/channelbridge/turnid_wiring_test.go
T
AskaEth 7d9b2132bb test(testhealth): 替身竞态加锁 + 墙钟断言硬度(M33)
四处测试健康债务,只改 *_test.go,不动生产代码、不降并发:

- channelbridge:stubChatter / stubSessions 的跨 goroutine 字段一律经 mu 访问
  (Run/GetOrCreate 在车道 worker 上写、测试 goroutine 读,两者无 happens-before;
  负向断言「等一会儿再看循环有没有被驱动」尤其如此)。读写走访问器,Messages()
  返回副本;t.Parallel 与并发度不变。
- plugins/tools/io:claimProbeTimeout 2s(真实时间预算)→ 60s,并由 claimProbeWait
  按 t.Deadline 收缩。探针只等 goroutine 被调度(无时间语义),放大余量把
  「机器快慢」从断言里去掉,死锁仍以断言失败暴露。
- backthink:TestSelfWakeGate_WindowExpiry 窗口 120ms → 6s(给 waitFor 的 4s 上界
  留 2s 余量),到期等待改为按首次受理时刻推算 window+250ms,不再盲睡 3×窗口。
  另修 TestSelfWakeStatus_MatchesReceiptAndTracksPendingSlot:worker 先 take 再进
  Think,卡 Think 并不占待跑槽 —— 改为「上一轮把 worker 占住再断言 Pending」。
- memory:墙钟实测用例显式给闸 5s 预算(measureGateBudget),不再因默认 150ms
  超时 fail-open 打炸「每轮都真扫真判」前置断言;并把 GateFailed 纳入该断言。
2026-10-24 22:24:14 +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])
}
}