- 只翻注释/文档字符串:`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
233 lines
8.0 KiB
Go
233 lines
8.0 KiB
Go
package backthink
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// dayAt 返回 UTC 的午后时间(在安静时段窗口之外)。
|
|
func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) }
|
|
|
|
// nightAt 返回 UTC 的 02:00(在默认 23-7 安静窗口之内)。
|
|
func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) }
|
|
|
|
// testBackthink 用归一化的空 Config 构建 Backthink,因此 Decide 使用
|
|
// 默认安静时段窗口(23:00-07:00)。
|
|
func testBackthink() *Backthink {
|
|
return &Backthink{cfg: normalize(Config{})}
|
|
}
|
|
|
|
func TestDecide_DoNotDisturb(t *testing.T) {
|
|
t.Parallel()
|
|
b := testBackthink()
|
|
// 所有用例都带上她的结论要求推送(ConclusionPush),确保命中的是级联本身
|
|
// 而不是「结论不推」的短路。
|
|
cases := []struct {
|
|
name string
|
|
in DecisionInput
|
|
want bool
|
|
}{
|
|
{
|
|
name: "她的结论不推送_不推送",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "凌晨+睡眠_不打扰",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Sleeping: true}, ConclusionPush: true},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "睡眠_危急也不打扰",
|
|
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Sleeping: true}, ConclusionPush: true},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "上班_不打扰",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Working: true}, ConclusionPush: true},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "忙碌_普通_不打扰",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Busy: true}, ConclusionPush: true},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "安静时段_普通_不打扰",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Boredom: 0.9}, ConclusionPush: true},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "安静时段_心跳_不打扰",
|
|
in: DecisionInput{Trigger: "heartbeat", Import: ImportanceLow, At: nightAt(), UserState: UserState{}, ConclusionPush: true, Heartbeat: true},
|
|
want: false,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := b.Decide(tc.in)
|
|
if got.ShouldPush != tc.want {
|
|
t.Fatalf("Decide(%+v).ShouldPush = %v, want %v (reason %q)", tc.in, got.ShouldPush, tc.want, got.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDecide_ShouldPush(t *testing.T) {
|
|
t.Parallel()
|
|
b := testBackthink()
|
|
cases := []struct {
|
|
name string
|
|
in DecisionInput
|
|
}{
|
|
{
|
|
name: "白天+重要_推",
|
|
in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: dayAt(), UserState: UserState{}, ConclusionPush: true},
|
|
},
|
|
{
|
|
name: "白天+危急_推",
|
|
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}, ConclusionPush: true},
|
|
},
|
|
{
|
|
name: "白天+普通+她的结论_推",
|
|
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Boredom: 0.8}, ConclusionPush: true},
|
|
},
|
|
{
|
|
name: "安静时段+重要_推",
|
|
in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: nightAt(), UserState: UserState{}, ConclusionPush: true},
|
|
},
|
|
{
|
|
name: "安静时段+危急_安全域穿透_推",
|
|
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: nightAt(), UserState: UserState{}, ConclusionPush: true},
|
|
},
|
|
{
|
|
name: "危急_可打断忙碌_推",
|
|
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Working: true}, ConclusionPush: true},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := b.Decide(tc.in)
|
|
if !got.ShouldPush {
|
|
t.Fatalf("Decide(%+v).ShouldPush = false, want true (reason %q)", tc.in, got.Reason)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInQuietHours(t *testing.T) {
|
|
t.Parallel()
|
|
b := &Backthink{cfg: Config{QuietStartHour: 23, QuietEndHour: 7}}
|
|
cases := []struct {
|
|
name string
|
|
at time.Time
|
|
want bool
|
|
}{
|
|
{name: "午夜", at: time.Date(2026, 9, 6, 0, 0, 0, 0, time.UTC), want: true},
|
|
{name: "凌晨两点", at: nightAt(), want: true},
|
|
{name: "早上七点_偏出", at: time.Date(2026, 9, 6, 7, 0, 0, 0, time.UTC), want: false},
|
|
{name: "傍晚", at: time.Date(2026, 9, 6, 18, 30, 0, 0, time.UTC), want: false},
|
|
{name: "深夜十一点", at: time.Date(2026, 9, 6, 23, 0, 0, 0, time.UTC), want: true},
|
|
}
|
|
for _, tc := range cases {
|
|
tc := tc
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := b.inQuietHours(tc.at); got != tc.want {
|
|
t.Fatalf("inQuietHours(%v) = %v, want %v", tc.at, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestClassifyPush 验证主动消息正文映射到预期的风格
|
|
// (问候/提醒/分享/关心),使频率门能分别限制每种风格。
|
|
func TestClassifyPush(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
in string
|
|
want PushKind
|
|
}{
|
|
{in: "记得明天体检", want: KindReminder},
|
|
{in: "提醒你到点吃药了", want: KindReminder},
|
|
{in: "最近累不累,注意身体", want: KindCare},
|
|
{in: "想你了,照顾好自己", want: KindCare},
|
|
{in: "早安,昨晚睡得好吗", want: KindGreeting},
|
|
{in: "晚上早点睡,晚安", want: KindGreeting},
|
|
{in: "发现一个有意思的地方,分享给你", want: KindShare},
|
|
{in: "跟往常一样没什么特别", want: KindCare}, // no keyword → generic fallback
|
|
}
|
|
for _, tc := range cases {
|
|
if got := classifyPush(tc.in); got != tc.want {
|
|
t.Fatalf("classifyPush(%q) = %v, want %v", tc.in, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDecide_ActivityGate 验证活动感知软门:刚开始的
|
|
// 对话会扣下琐碎内容(critical 除外),长期静默的主人只会被
|
|
// 重要事件唤醒——但心跳轮除外,它永远不会被长期
|
|
// 静默的间隔拦下(裁决 07.4.3)——而中间地带不会过度抑制。
|
|
func TestDecide_ActivityGate(t *testing.T) {
|
|
t.Parallel()
|
|
b := testBackthink()
|
|
|
|
// 刚聊过(在 RecentChatCooldown=10min 内):白天普通推送
|
|
// 会被扣下,除非该事件是安全关键。
|
|
fresh := DecisionInput{
|
|
Import: ImportanceNormal,
|
|
At: dayAt(),
|
|
UserState: UserState{Boredom: 0.9},
|
|
LastActivity: dayAt().Add(-5 * time.Minute),
|
|
HasActivity: true,
|
|
ConclusionPush: true,
|
|
}
|
|
if got := b.Decide(fresh); got.ShouldPush {
|
|
t.Fatalf("fresh conversation should hold a normal push, got %+v", got)
|
|
}
|
|
fresh.Import = ImportanceCritical
|
|
if got := b.Decide(fresh); !got.ShouldPush {
|
|
t.Fatalf("critical event should push even in a fresh conversation, got %+v", got)
|
|
}
|
|
|
|
// 离开很久(> longIdleThreshold):只有重要事件值得
|
|
// 唤醒——但心跳轮仍会通过(裁决 07.4.3 长期静默段不拦心跳)。
|
|
idle := DecisionInput{
|
|
Import: ImportanceNormal,
|
|
At: dayAt(),
|
|
UserState: UserState{Boredom: 0.9},
|
|
LastActivity: dayAt().Add(-3 * time.Hour),
|
|
HasActivity: true,
|
|
ConclusionPush: true,
|
|
}
|
|
if got := b.Decide(idle); got.ShouldPush {
|
|
t.Fatalf("long idle should hold a normal event push, got %+v", got)
|
|
}
|
|
idle.Import = ImportanceHigh
|
|
if got := b.Decide(idle); !got.ShouldPush {
|
|
t.Fatalf("long idle high-importance should push, got %+v", got)
|
|
}
|
|
idle.Import = ImportanceLow
|
|
idle.Heartbeat = true
|
|
if got := b.Decide(idle); !got.ShouldPush {
|
|
t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got)
|
|
}
|
|
|
|
// 中等活动(30 分钟前)不会被过度抑制。
|
|
middle := DecisionInput{
|
|
Import: ImportanceNormal,
|
|
At: dayAt(),
|
|
UserState: UserState{Boredom: 0.9},
|
|
LastActivity: dayAt().Add(-30 * time.Minute),
|
|
HasActivity: true,
|
|
ConclusionPush: true,
|
|
}
|
|
if got := b.Decide(middle); !got.ShouldPush {
|
|
t.Fatalf("middle-band activity should allow a normal push, got %+v", got)
|
|
}
|
|
}
|