Files
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

313 lines
11 KiB
Go
Raw Permalink 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 backthink
import (
"context"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
)
func TestPusher_AutoRoutesIM(t *testing.T) {
t.Parallel()
// 主人要求:默认(auto)推送经 IM 桥到达 ONE 个渠道
//(桥依次挑她的 [渠道:...] 选择、她最近的平台、
// 白名单默认)——默认不是活跃 Web 客户端。
active := &mockActive{n: 2} // 有 Web 客户端在线,但它不是默认目标
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
if !res.Pushed {
t.Fatalf("Pushed = false, want true")
}
if res.Route != RouteIMBridge {
t.Fatalf("Route = %q, want %q", res.Route, RouteIMBridge)
}
if im.count() != 1 {
t.Fatalf("im calls = %d, want 1 (single IM target)", im.count())
}
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (web is not the default target)", active.calls)
}
}
func TestPusher_WebHintRoutesActive(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{ChannelHint: channelWeb, Message: adapter.UnifiedMessage{}})
if !res.Pushed {
t.Fatalf("Pushed = false, want true")
}
if res.Route != RouteActiveClient {
t.Fatalf("Route = %q, want %q", res.Route, RouteActiveClient)
}
if active.calls != 1 {
t.Fatalf("active calls = %d, want 1", active.calls)
}
if im.count() != 0 {
t.Fatalf("im calls = %d, want 0 (web hint targets only the active client)", im.count())
}
}
func TestPusher_ConcreteHintRoutesIM_SingleTarget(t *testing.T) {
t.Parallel()
// 具体的决策渠道经 IM 桥被尊重,且只用 ONE 个
// 目标(由 IM 桥承载;活跃客户端不被触碰)。
active := &mockActive{n: 2}
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{ChannelHint: "kook", Message: adapter.UnifiedMessage{}})
if !res.Pushed {
t.Fatalf("Pushed = false, want true")
}
if res.Route != RouteIMBridge {
t.Fatalf("Route = %q, want %q", res.Route, RouteIMBridge)
}
if im.count() != 1 {
t.Fatalf("im calls = %d, want 1", im.count())
}
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (a concrete hint targets only the IM bridge)", active.calls)
}
if im.last.Platform != "kook" {
t.Fatalf("im platform = %q, want kook", im.last.Platform)
}
}
func TestPusher_NoIMSink_DegradesToActive(t *testing.T) {
t.Parallel()
// 未接线 IM 槽(backthink 的 IM 接缝缺失)时推送降级到
// 活跃 Web 客户端,好让主人仍被触达而不是被丢弃。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
if !res.Pushed {
t.Fatalf("Pushed = false, want true")
}
if res.Route != RouteActiveClient {
t.Fatalf("Route = %q, want %q", res.Route, RouteActiveClient)
}
if active.calls != 1 {
t.Fatalf("active calls = %d, want 1", active.calls)
}
}
func TestPusher_NoTarget_Skipped(t *testing.T) {
t.Parallel()
p := newPusher(nil, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
if res.Pushed {
t.Fatalf("Pushed = true, want false")
}
if res.Route != RouteNone {
t.Fatalf("Route = %q, want %q", res.Route, RouteNone)
}
}
func TestPusher_GateDenied(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
im := &mockIMAdapter{}
p := newPusher(active, im, &mockGate{allow: false}, discardLog())
res := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
if res.Pushed {
t.Fatalf("Pushed = true, want false")
}
if res.Route != RouteRateLimited {
t.Fatalf("Route = %q, want %q", res.Route, RouteRateLimited)
}
if active.calls != 0 || im.count() != 0 {
t.Fatalf("targets called despite denial: active=%d im=%d", active.calls, im.count())
}
}
func TestPusher_CancelledContext_Denied(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := p.Push(ctx, PushRequest{Message: adapter.UnifiedMessage{}})
if res.Pushed {
t.Fatalf("Pushed = true, want false")
}
if res.Route != RouteNone {
t.Fatalf("Route = %q, want %q", res.Route, RouteNone)
}
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0", active.calls)
}
}
// TestPusher_RateLimit 在 session.RateLimiter 上验证生产 rateGate:
// 窗口内 limit 为 1 次推送时,三次尝试中只有第一次通过。
func TestPusher_RateLimit(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
first := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
second := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
third := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{}})
if !first.Pushed || first.Route != RouteActiveClient {
t.Fatalf("first push = %+v, want active_client pushed", first)
}
if second.Pushed || second.Route != RouteRateLimited {
t.Fatalf("second push = %+v, want rate_limited not pushed", second)
}
if third.Pushed || third.Route != RouteRateLimited {
t.Fatalf("third push = %+v, want rate_limited not pushed", third)
}
if active.calls != 1 {
t.Fatalf("active calls = %d, want 1 (rate limit caps the sink)", active.calls)
}
}
// TestRateGate_CooldownAfterSuccess 验证推送后冷却:一次成功
// 投递之后,在冷却期结束前再推送会被拒绝,
// 与已配置的窗口计数无关。
func TestRateGate_CooldownAfterSuccess(t *testing.T) {
t.Parallel()
t0 := time.Now()
g := &rateGate{
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 30 * time.Minute,
kindWindow: 0, // 置零以单独验证冷却
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
}
if !g.Allow(context.Background(), KindCare) {
t.Fatal("first push should be allowed")
}
g.RecordPush(KindCare)
if g.Allow(context.Background(), KindReminder) {
t.Fatal("push within cooldown should be denied")
}
g.now = func() time.Time { return t0.Add(31 * time.Minute) }
if !g.Allow(context.Background(), KindReminder) {
t.Fatal("push after the cooldown has elapsed should be allowed")
}
}
// TestRateGate_KindWindowLimitsSameFlavor 验证按风格(问候/提醒/分享/
// 关心)限制:同一风格不能在风格窗口内重复,而
// 不同风格与更晚的同一风格推送(窗口之后)仍可通过。
func TestRateGate_KindWindowLimitsSameFlavor(t *testing.T) {
t.Parallel()
t0 := time.Now()
g := &rateGate{
log: discardLog(),
limiter: session.NewRateLimiter(10, time.Hour),
cooldown: 0, // 置零以单独验证风格窗口
kindWindow: 2 * time.Hour,
now: func() time.Time { return t0 },
lastKind: make(map[PushKind]time.Time),
}
if !g.Allow(context.Background(), KindReminder) {
t.Fatal("first reminder should be allowed")
}
g.RecordPush(KindReminder)
if g.Allow(context.Background(), KindReminder) {
t.Fatal("same flavor within the kind window should be denied")
}
if !g.Allow(context.Background(), KindShare) {
t.Fatal("a different flavor should be allowed")
}
g.now = func() time.Time { return t0.Add(3 * time.Hour) }
if !g.Allow(context.Background(), KindReminder) {
t.Fatal("same flavor after the kind window should be allowed")
}
}
// TestPusher_EventBypassesGate 验证事件驱动的推送跳过 PushGate:
// 窗口内 limit 为 1 次推送时,连续三次事件推送全部
// 被投递(主人要求 "该发就发"),每次都标记 Bypassed,且都不推进
// 冷却/风格窗口。
func TestPusher_EventBypassesGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
req := PushRequest{Message: adapter.UnifiedMessage{Content: "洗衣机洗好了"}, TriggerKind: TriggerEvent}
first := p.Push(context.Background(), req)
second := p.Push(context.Background(), req)
third := p.Push(context.Background(), req)
if !first.Pushed || !first.Bypassed {
t.Fatalf("first event push = %+v, want delivered + Bypassed", first)
}
if !second.Pushed || !second.Bypassed {
t.Fatalf("second event push = %+v, want delivered + Bypassed (gate bypassed)", second)
}
if !third.Pushed || !third.Bypassed {
t.Fatalf("third event push = %+v, want delivered + Bypassed (gate bypassed)", third)
}
if active.calls != 3 {
t.Fatalf("active calls = %d, want 3 (events are not rate-capped)", active.calls)
}
}
// TestPusher_HeartbeatStillRateLimited 验证心跳驱动的推送仍
// 受门控管辖:窗口内 limit 为 1 次推送时,第一次通过,
// 第二次作为 rate_limited 被拒,绝不是 Bypassed。
func TestPusher_HeartbeatStillRateLimited(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
req := PushRequest{Message: adapter.UnifiedMessage{Content: "想跟你说声晚安"}, TriggerKind: TriggerHeartbeat}
first := p.Push(context.Background(), req)
second := p.Push(context.Background(), req)
if !first.Pushed || first.Bypassed {
t.Fatalf("first heartbeat push = %+v, want delivered non-bypassed", first)
}
if second.Pushed || second.Route != RouteRateLimited || second.Bypassed {
t.Fatalf("second heartbeat push = %+v, want rate_limited non-bypassed", second)
}
if active.calls != 1 {
t.Fatalf("active calls = %d, want 1 (heartbeat still gated)", active.calls)
}
}
// TestPusher_EventBypass_DoesNotAdvanceGate 验证事件驱动的推送不会
// 消耗心跳额度:一次事件推送之后,紧随的心跳推送
// 仍拿到自己那份完整额度。
func TestPusher_EventBypass_DoesNotAdvanceGate(t *testing.T) {
t.Parallel()
active := &mockActive{n: 1}
p := newPusher(active, nil, newRateGate(1, time.Minute, 0, 0, discardLog()), discardLog())
ev := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "洗衣机洗好了"}, TriggerKind: TriggerEvent})
hb := p.Push(context.Background(), PushRequest{Message: adapter.UnifiedMessage{Content: "想跟你说声晚安"}, TriggerKind: TriggerHeartbeat})
if !ev.Pushed || !ev.Bypassed {
t.Fatalf("event push = %+v, want delivered + Bypassed", ev)
}
if !hb.Pushed || hb.Bypassed {
t.Fatalf("heartbeat push after an event = %+v, want delivered non-bypassed (event did not consume the gate)", hb)
}
if active.calls != 2 {
t.Fatalf("active calls = %d, want 2", active.calls)
}
}