package backthink import ( "context" "strings" "testing" "time" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/config" "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" ) // 本文件守住 design/12 §6 安全域豁免的**生产端**闭环:IoT 事件的显式严重度 → // Trigger.Import → 安静时段穿透。此前 ImportanceCritical 只有判定侧(decide.go) // 而没有生产者,该豁免是死代码;这些用例覆盖整条路,摘掉映射即变红。 // TestImportanceForSeverity_Mapping 钉住严重度到思考轮重要度的映射:例行档保持 // Normal(默认档永不穿透安静时段),只有安全域拿到 Critical。 func TestImportanceForSeverity_Mapping(t *testing.T) { t.Parallel() cases := []struct { in iot.AlertSeverity want Importance }{ {in: iot.SeverityRoutine, want: ImportanceNormal}, {in: iot.SeverityWarning, want: ImportanceHigh}, {in: iot.SeverityCritical, want: ImportanceCritical}, } for _, tc := range cases { tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "lock.front", Type: "lock"}, Action: "unlock", Severity: tc.in, }}) if tr.Import != tc.want { t.Fatalf("severity %v → Import = %v, want %v", tc.in, tr.Import, tc.want) } if tr.Kind != TriggerEvent { t.Fatalf("severity %v → Kind = %v, want %v", tc.in, tr.Kind, TriggerEvent) } } } // TestDecide_NightSafetyEventBypassesQuietHours 断言凌晨的显式安全域 IoT 事件被 // 放行,且理由点明安全域穿透(裁决 1.8 / 07.4.2)。 func TestDecide_NightSafetyEventBypassesQuietHours(t *testing.T) { t.Parallel() tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "lock.front", Type: "lock"}, Action: "unlock", Severity: iot.SeverityCritical, }}) in := DecisionInput{ Trigger: tr.Source, Import: tr.Import, At: nightAt(), UserState: UserState{}, ConclusionPush: true, } if got := testBackthink().Decide(in); !got.ShouldPush { t.Fatalf("Decide = %+v, want ShouldPush (critical safety event bypasses quiet hours)", got) } // Decide 放行时统一回泛化标签("proactive push approved"),安全域穿透这条 // 具体理由由硬门级联给出。两条一起断言,理由文案改动即会变红。 if got := testBackthink().baseDecide(in); !strings.Contains(got.Reason, "critical safety event bypasses") { t.Fatalf("baseDecide reason = %q, want it to name the safety-domain bypass", got.Reason) } } // TestDecide_NightRoutineEventHeld 是对照断言:同一条路上,未标注严重度的例行设备 // 变化在凌晨仍被安静时段扣下(默认档保守)。 func TestDecide_NightRoutineEventHeld(t *testing.T) { t.Parallel() tr := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", }}) if tr.Import != ImportanceNormal { t.Fatalf("unlabelled device update → Import = %v, want %v", tr.Import, ImportanceNormal) } got := testBackthink().Decide(DecisionInput{ Trigger: tr.Source, Import: tr.Import, At: nightAt(), UserState: UserState{}, ConclusionPush: true, }) if got.ShouldPush { t.Fatalf("Decide = %+v, want the routine event held in quiet hours", got) } if !strings.Contains(got.Reason, "quiet hours") { t.Fatalf("Reason = %q, want it to mention quiet hours", got.Reason) } } // newIoTWiredBackthink 用同一条进程内 bus 把真实的 iot.Manager 与 backthink 接起来: // 测试因此走的是生产路径(Manager.Control → bus → onEvent → think → Decide), // 而不是手搓一个 Trigger。 func newIoTWiredBackthink(t *testing.T, thinker Thinker, pusher Pusher) (*Backthink, *iot.Manager) { t.Helper() bs := bus.New() mgr, err := iot.New(config.IoTConfig{Provider: "mock"}, nil, iot.WithBus(bs)) if err != nil { t.Fatalf("iot.New: %v", err) } b, _ := newBackthink(t, Config{}, Deps{ Bus: bs, Thinker: thinker, State: &mockState{state: UserState{}}, Pusher: pusher, Now: func() time.Time { return nightAt() }, }) b.Start(context.Background()) // 启动轮(07.4.4)先落库:它是低重要度心跳类,凌晨被照常扣下, // 因此这之后出现的推送只可能来自安全域事件。 eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) return len(recs) >= 1 && recs[0].Trigger == "startup" }) return b, mgr } // TestBackthink_AcuteIoTEventNightPush 是主人原话「大半夜急性 IoT 事件就得主动发消息」 // 的端到端回归:凌晨开启门锁(安全域)→ 走过整条生产路径 → 主动推送发出。 func TestBackthink_AcuteIoTEventNightPush(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "[我决定:push]\n门口有动静,我去确认一下"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, mgr := newIoTWiredBackthink(t, thinker, pusher) if _, err := mgr.Control(context.Background(), "lock.front", "unlock", nil); err != nil { t.Fatalf("Control: %v", err) } eventually(t, 2*time.Second, func() bool { return pusher.count() >= 1 }) if got := pusher.count(); got != 1 { t.Fatalf("pusher calls = %d, want 1 (one acute safety event)", got) } recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } if recs[0].Trigger != iot.DeviceUpdateSubject || !recs[0].ShouldPush || !recs[0].PushResult.Pushed { t.Fatalf("newest record = %+v, want a delivered push for the safety event", recs[0]) } if !strings.Contains(recs[0].Conclusion, "门口有动静") { t.Fatalf("record conclusion = %q, want her own words", recs[0].Conclusion) } } // TestBackthink_RoutineIoTEventNightHeld 是对照:同一条生产路径上,凌晨的常规设备 // 变化(未命中安全规则)仍被安静时段扣下——本次改动没有把 IoT 事件整体升级。 func TestBackthink_RoutineIoTEventNightHeld(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "[我决定:push]\n客厅灯开着呢"} pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} b, mgr := newIoTWiredBackthink(t, thinker, pusher) if _, err := mgr.Control(context.Background(), "light.living", "on", nil); err != nil { t.Fatalf("Control: %v", err) } var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == iot.DeviceUpdateSubject { rec = r return true } } return false }) if got := pusher.count(); got != 0 { t.Fatalf("pusher calls = %d, want 0 (routine device change held in quiet hours)", got) } if rec.ShouldPush || rec.PushResult.Route != RouteNone { t.Fatalf("record = %+v, want a held record", rec) } if !strings.Contains(rec.PushResult.Detail, "quiet hours") { t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", rec.PushResult.Detail) } }