Files
Cyrene_v2/internal/services/backthink/iot_safety_test.go
T
AskaEth f91a83ff81
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
2026-10-05 13:11:04 +08:00

178 lines
6.9 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 (
"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)
}
}