Files
Cyrene_v2/internal/services/backthink/decide_test.go
T
AskaEth 94da6bcd6d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 静默期自排 C 的判定侧(C1-1,切片三之一)
- 追加 TriggerSelfWake(不动既有编号)+ SelfWakeRequest(走既有 urgency 声明通道,不新造第二条)
- escalateImport 折算收成唯一 foldDeclared(事件轮与自排轮共用、只上抬取较高档)
- DecisionInput.Heartbeat bool → Kind TriggerKind;心跳仍免检、**自排不享心跳豁免**(要求声明 ≥ warning)
- push 与档位分离:push.go 零改动即自动正确(eventDriven 对 self_wake 为 false ⇒ 照旧过 PushGate)
- 4 个变异自检(折算空操作/删自排分支/常量传心跳/非事件一律豁免)
⚠️ 本片只是判定侧:WakeNow/worker/闸属 C1-2,工具面与提示词属 C1-3 ⇒ **不得写『C 已完成』**
⚠️ 遗留 G-A(建议登记进 design/12 C.4):TriggerKind 零值=TriggerHeartbeat ⇒ 漏填=最宽豁免(fail-open);
   本片已在字段注释与单测里显式化,但规格侧应补一句或引入 TriggerUnknown
2026-10-06 18:46:27 +08:00

289 lines
10 KiB
Go
Raw 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 (
"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)
}
}