package backthink import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" ) // 本文件承载三条已裁决改动的生产行为回归(裁决 07.4.2 / 07.4.3 / 07.4.4, // design/12 §1 §2 §5-§6 与 design/20 §0-§2),从 backthink_test.go 拆出以守住 // 文件 ≤500 行的硬约束。 // TestBackthink_QuietHours_CriticalSafetyStillPushes 是裁决 07.4.2 的真断言(生产 // 路径 think()):安静时段 + critical 安全事件 → 仍然推送——安全域穿透安静时段, // 安静时段只挡常规推送。 func TestBackthink_QuietHours_CriticalSafetyStillPushes(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "[我决定:push]\n[渠道:web]\n检测到一次异常登录,需要你留意"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Pusher: pusher, Now: func() time.Time { return nightAt() }, }) ev := triggerForEvent(bus.Event{Subject: SubjectSystemEvent, Payload: SystemEvent{ Summary: "security alert: abnormal login", Import: ImportanceCritical, }}) b.think(context.Background(), ev) if got := pusher.count(); got != 1 { t.Fatalf("pusher calls = %d, want 1 (critical safety event bypasses quiet hours)", got) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if len(recs) != 1 || !recs[0].ShouldPush { t.Fatalf("records = %+v, want one should-push record", recs) } if !recs[0].PushResult.Pushed { t.Fatalf("PushResult = %+v, want pushed", recs[0].PushResult) } } // TestBackthink_QuietHours_RegularPushHeld 是裁决 07.4.2 的对照断言:安静时段 + // 常规事件(她的结论要求推送)→ 不推送,仅记录。 func TestBackthink_QuietHours_RegularPushHeld(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "[我决定:push]\n洗衣机洗好了,衣服可以收了"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Pusher: pusher, Now: func() time.Time { return nightAt() }, }) ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", }}) b.think(context.Background(), ev) if got := pusher.count(); got != 0 { t.Fatalf("pusher calls = %d, want 0 (quiet hours hold a regular push)", got) } recs, _ := b.List(context.Background(), 10) if len(recs) != 1 || recs[0].ShouldPush { t.Fatalf("records = %+v, want one held record", recs) } if recs[0].PushResult.Route != RouteNone { t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone) } if !strings.Contains(recs[0].PushResult.Detail, "quiet hours") { t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", recs[0].PushResult.Detail) } } // TestBackthink_LongIdle_HeartbeatStillPushes 是裁决 07.4.3 的真断言:主人长时间 // 未回消息(长期静默段)时,心跳仍可照常发出主动关心。 func TestBackthink_LongIdle_HeartbeatStillPushes(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "好久没听你说话了,想你了,最近还好吗"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Pusher: pusher, Recent: &fakeRecent{msgs: []history.Message{{ Role: history.RoleUser, Content: "我先去忙了", CreatedAt: dayAt().Add(-3 * time.Hour).UnixMilli(), }}}, Now: func() time.Time { return dayAt() }, }) b.think(context.Background(), heartbeatTrigger()) if got := pusher.count(); got != 1 { t.Fatalf("pusher calls = %d, want 1 (heartbeat not held by long silence)", got) } recs, _ := b.List(context.Background(), 10) if len(recs) != 1 || !recs[0].ShouldPush { t.Fatalf("records = %+v, want one should-push record", recs) } } // TestBackthink_LongIdle_NormalEventHeld 是裁决 07.4.3 的对照断言:长期静默段里, // 非心跳的常规事件推送仍被活动门拦下——只有真正重要的事件才值得唤醒久未回应的主人。 func TestBackthink_LongIdle_NormalEventHeld(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "洗衣机洗好了,衣服可以收了"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, State: &mockState{state: UserState{}}, Pusher: pusher, Recent: &fakeRecent{msgs: []history.Message{{ Role: history.RoleUser, Content: "我先去忙了", CreatedAt: dayAt().Add(-3 * time.Hour).UnixMilli(), }}}, Now: func() time.Time { return dayAt() }, }) ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", }}) b.think(context.Background(), ev) if got := pusher.count(); got != 0 { t.Fatalf("pusher calls = %d, want 0 (long silence holds a regular event push)", got) } } // TestBackthink_Startup_ThinksImmediately 是裁决 07.4.4 的真断言:进程启动立即先 // 思考一轮(不等满一个心跳周期),触发源为 startup、走启动专属引导。默认 900s 的 // 心跳周期在测试窗口内不可能 tick,出现的记录只能是启动轮。 func TestBackthink_Startup_ThinksImmediately(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "我回来啦"} b, _ := newBackthink(t, Config{}, Deps{ Thinker: thinker, Now: func() time.Time { return dayAt() }, }) b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) return len(recs) >= 1 && recs[0].Trigger == "startup" }) if got := thinker.lastTrigger(); got != StartupSummary { t.Fatalf("startup think trigger = %q, want %q", got, StartupSummary) } recs, _ := b.List(context.Background(), 10) if recs[0].Trigger != "startup" || recs[0].At.IsZero() { t.Fatalf("startup record = %+v, want a stamped startup record", recs[0]) } } // TestStartupPrompt_HumanizedGreeting 验证启动轮使用拟人化启动引导(裁决 07.4.4): // 以角色口吻说一句「回来」的问候,并保留固定的决策结尾格式;普通触发不会误用。 func TestStartupPrompt_HumanizedGreeting(t *testing.T) { t.Parallel() p := guidancePrompt(StartupSummary) for _, want := range []string{"回来", "[我决定:push]", "[渠道:"} { if !strings.Contains(p, want) { t.Fatalf("startup prompt missing %q: %q", want, p) } } if p2 := guidancePrompt("heartbeat"); !strings.Contains(p2, "无人在旁") || strings.Contains(p2, "刚刚回来") { t.Fatalf("regular guidance should stay the regular prompt: %q", p2) } }