package backthink import ( "testing" "time" ) // dayAt returns a mid-afternoon time in UTC (outside the quiet-hours window). func dayAt() time.Time { return time.Date(2026, 9, 6, 14, 0, 0, 0, time.UTC) } // nightAt returns a 02:00 time in UTC (inside the default 23-7 quiet window). func nightAt() time.Time { return time.Date(2026, 9, 6, 2, 0, 0, 0, time.UTC) } // testBackthink builds a Backthink with a normalized empty Config so Decide uses // the default quiet-hours window (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 verifies the proactive message body maps to the intended flavor // (问候/提醒/分享/关心) so the frequency gate can limit each flavor separately. 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 verifies the activity-aware soft gate: a fresh // conversation holds trivia (unless critical), a long-idle owner is only woken by // an important event — except a heartbeat pass, which is never held by a // long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress. func TestDecide_ActivityGate(t *testing.T) { t.Parallel() b := testBackthink() // Just chatted (within RecentChatCooldown=10min): a normal daytime push is // held unless the event is safety-critical. 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) } // Away a long while (> longIdleThreshold): only an important event is worth // it — but a heartbeat pass still goes through (裁决 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) } // Middling activity (30 min ago) is not over-suppressed. 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) } }