178 lines
6.9 KiB
Go
178 lines
6.9 KiB
Go
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)
|
||
}
|
||
}
|