Files
Cyrene_v2/internal/services/backthink/decide_test.go
T
Aska 12031bd13c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
2026-09-08 13:08:57 +08:00

217 lines
7.0 KiB
Go

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()
cases := []struct {
name string
in DecisionInput
want bool
}{
{
name: "凌晨+睡眠_不打扰",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Sleeping: true}},
want: false,
},
{
name: "睡眠_重要也不打扰",
in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: dayAt(), UserState: UserState{Sleeping: true}},
want: false,
},
{
name: "上班_不打扰",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Working: true}},
want: false,
},
{
name: "忙碌_普通_不打扰",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Busy: true}},
want: false,
},
{
name: "安静时段_普通_不打扰",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Boredom: 0.9}},
want: false,
},
{
name: "白天_普通_不无聊_不打扰",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{}},
want: false,
},
{
name: "白天_低重要_不打扰",
in: DecisionInput{Trigger: "heartbeat", Import: ImportanceLow, At: dayAt(), UserState: UserState{}},
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{}},
},
{
name: "白天+危急_推",
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}},
},
{
name: "白天+普通+无聊_推",
in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Boredom: 0.8}},
},
{
name: "安静时段+重要_推",
in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: nightAt(), UserState: UserState{}},
},
{
name: "危急_可打断忙碌_推",
in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Working: 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, 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 boredom
// 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,
}
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.
idle := DecisionInput{
Import: ImportanceNormal,
At: dayAt(),
UserState: UserState{Boredom: 0.9},
LastActivity: dayAt().Add(-3 * time.Hour),
HasActivity: true,
}
if got := b.Decide(idle); got.ShouldPush {
t.Fatalf("long idle should hold a normal 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)
}
// 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,
}
if got := b.Decide(middle); !got.ShouldPush {
t.Fatalf("middle-band activity should allow a normal push, got %+v", got)
}
}