- selfwake.go:WakeNow + 待跑槽容量 1(先探槽合并不过闸、槽空才过闸、同锁)+ 闸 4 轮/小时
($DATA_DIR/config/backthink.json,零新 env 键)+ 审计四条 what + 启动日志两键 + 组合根接线
- G-A(主人已批):TriggerUnknown 作零值、既有两值编号后移(语义不动),未知触发走**最紧分支**
- 5 个变异自检(拆闸/≤0 未回落/过闸挪到探槽前=G1 失效形态/槽无界/零值挪走)
⚠️ 服务侧已就绪但**她仍调不到**(自排工具面属 C1-3)⇒ 不得写「C 已完成」
345 lines
13 KiB
Go
345 lines
13 KiB
Go
package backthink
|
||
|
||
import (
|
||
"strings"
|
||
"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, Kind: TriggerHeartbeat},
|
||
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),而她自己排起的一轮**不享**这条豁免
|
||
// (design/12 §N C.4,见紧随其后的用例)——中间地带不会过度抑制。
|
||
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,
|
||
Kind: TriggerEvent,
|
||
}
|
||
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 长期静默段不拦心跳)。
|
||
// Kind 必须显式给出:TriggerKind 的零值是 TriggerHeartbeat,
|
||
// 省掉它这一档就从「事件」静默变成「心跳豁免」。
|
||
idle := DecisionInput{
|
||
Import: ImportanceNormal,
|
||
At: dayAt(),
|
||
UserState: UserState{Boredom: 0.9},
|
||
LastActivity: dayAt().Add(-3 * time.Hour),
|
||
HasActivity: true,
|
||
ConclusionPush: true,
|
||
Kind: TriggerEvent,
|
||
}
|
||
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.Kind = TriggerHeartbeat
|
||
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,
|
||
Kind: TriggerEvent,
|
||
}
|
||
if got := b.Decide(middle); !got.ShouldPush {
|
||
t.Fatalf("middle-band activity should allow a normal push, got %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestDecide_ActivityGate_SelfWakeNeedsDeclaredUrgency 是 design/12 §C.4 的真断言
|
||
// (判据 4):长期静默段里,她自己排起的一轮要求她说得出理由(折算后 Import ≥ High,
|
||
// 即声明 ≥ warning)才放行;例常自排被扣下、理由点名「self-initiated wake」;
|
||
// 而例行心跳的既有豁免**不许回归**(裁决 07.4.3)。把 activityHeld 里的自排分支
|
||
// 删掉、或把三分支改回「非事件一律豁免」,这条即变红。
|
||
func TestDecide_ActivityGate_SelfWakeNeedsDeclaredUrgency(t *testing.T) {
|
||
t.Parallel()
|
||
b := testBackthink()
|
||
in := DecisionInput{
|
||
Trigger: SelfWakeSource,
|
||
Import: ImportanceNormal,
|
||
At: dayAt(),
|
||
UserState: UserState{Boredom: 0.9},
|
||
LastActivity: dayAt().Add(-3 * time.Hour),
|
||
HasActivity: true,
|
||
ConclusionPush: true,
|
||
Kind: TriggerSelfWake,
|
||
}
|
||
|
||
got := b.Decide(in)
|
||
if got.ShouldPush {
|
||
t.Fatalf("undeclared self wake in long silence should be held, got %+v", got)
|
||
}
|
||
if !strings.Contains(got.Reason, "self-initiated wake") {
|
||
t.Fatalf("Reason = %q, want it to name the self-initiated wake hold", got.Reason)
|
||
}
|
||
|
||
// 她声明了 warning ⇒ selfWakeTrigger 折算成 High ⇒ 放行。
|
||
in.Import = ImportanceHigh
|
||
if got := b.Decide(in); !got.ShouldPush {
|
||
t.Fatalf("self wake with a declared reason (>= warning) should push, got %+v", got)
|
||
}
|
||
|
||
// 对照:同样久未回应,例行心跳照旧放行(07.4.3 豁免不许被这次改动带走)。
|
||
hb := DecisionInput{
|
||
Trigger: "heartbeat",
|
||
Import: ImportanceLow,
|
||
At: dayAt(),
|
||
UserState: UserState{Boredom: 0.9},
|
||
LastActivity: dayAt().Add(-3 * time.Hour),
|
||
HasActivity: true,
|
||
ConclusionPush: true,
|
||
Kind: TriggerHeartbeat,
|
||
}
|
||
if got := b.Decide(hb); !got.ShouldPush {
|
||
t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got)
|
||
}
|
||
}
|
||
|
||
// TestTriggerKind_ZeroValueIsUnknownAndTight 是 G-A 修法(design/12 §C.4 的零值语义,
|
||
// 主人 2026-10-06 已批)的探针:`TriggerKind` 的零值必须是 **TriggerUnknown**,且它
|
||
// 在判定侧走**最紧**分支 —— 长期静默段里不豁免(`{Unknown, Low/Normal}` 扣下)、
|
||
// 推送侧不被当成事件(照旧过 PushGate)。
|
||
//
|
||
// 它守的是 fail-open 的方向:改动前零值 = TriggerHeartbeat,「漏填 Kind」静默变成
|
||
// 「拿最宽的豁免」。变异:把 TriggerUnknown 挪出零值(如让 TriggerHeartbeat 回到 0),
|
||
// 或把零值分支混进心跳豁免 ⇒ 本用例变红。
|
||
func TestTriggerKind_ZeroValueIsUnknownAndTight(t *testing.T) {
|
||
t.Parallel()
|
||
var zero TriggerKind
|
||
if zero != TriggerUnknown {
|
||
t.Fatalf("TriggerKind 的零值 = %v(%d),want TriggerUnknown", zero, int(zero))
|
||
}
|
||
if TriggerUnknown != 0 {
|
||
t.Fatalf("TriggerUnknown = %d, want 0(零值必须是最紧的那一档)", int(TriggerUnknown))
|
||
}
|
||
if got := TriggerUnknown.String(); got != "unknown" {
|
||
t.Fatalf("TriggerUnknown.String() = %q, want %q", got, "unknown")
|
||
}
|
||
// 既有三个值的标签一个字都不许变(落库/面板/审计都以它们为真源)。
|
||
for kind, want := range map[TriggerKind]string{
|
||
TriggerHeartbeat: "heartbeat",
|
||
TriggerEvent: "event",
|
||
TriggerSelfWake: SelfWakeSource,
|
||
} {
|
||
if got := kind.String(); got != want {
|
||
t.Fatalf("TriggerKind(%d).String() = %q, want %q", int(kind), got, want)
|
||
}
|
||
}
|
||
|
||
b := testBackthink()
|
||
longIdle := DecisionInput{
|
||
Trigger: "unknown.source",
|
||
At: dayAt(),
|
||
UserState: UserState{Boredom: 0.9},
|
||
LastActivity: dayAt().Add(-3 * time.Hour),
|
||
HasActivity: true,
|
||
ConclusionPush: true,
|
||
Kind: TriggerUnknown, // 零值:漏填 Kind 的等价形态
|
||
}
|
||
for _, imp := range []Importance{ImportanceLow, ImportanceNormal} {
|
||
in := longIdle
|
||
in.Import = imp
|
||
if got := b.Decide(in); got.ShouldPush {
|
||
t.Fatalf("未知触发(Import=%v)在长期静默段必须扣下,got %+v", imp, got)
|
||
}
|
||
}
|
||
// 高重要度仍按「值得说」的既有规则放行:G-A 收的是**豁免**,不是把未知一律封死。
|
||
high := longIdle
|
||
high.Import = ImportanceHigh
|
||
if got := b.Decide(high); !got.ShouldPush {
|
||
t.Fatalf("未知触发但重要度高(>= High)应放行,got %+v", got)
|
||
}
|
||
}
|