Files
AskaEth ed5005307a feat(backthink): 自排 C1-2 本体 + TriggerUnknown 修 fail-open(V1)
- selfwake.go:WakeNow + 待跑槽容量 1(先探槽合并不过闸、槽空才过闸、同锁)+ 闸 4 轮/小时
  ($DATA_DIR/config/backthink.json,零新 env 键)+ 审计四条 what + 启动日志两键 + 组合根接线
- G-A(主人已批):TriggerUnknown 作零值、既有两值编号后移(语义不动),未知触发走**最紧分支**
- 5 个变异自检(拆闸/≤0 未回落/过闸挪到探槽前=G1 失效形态/槽无界/零值挪走)
⚠️ 服务侧已就绪但**她仍调不到**(自排工具面属 C1-3)⇒ 不得写「C 已完成」
2026-10-06 20:25:04 +08:00

345 lines
13 KiB
Go
Raw Permalink 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)
}
}
// 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)
}
}