feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-05 13:11:04 +08:00
1 parent 4a2d276881
commit f91a83ff81
14 files changed
+917 -15

No files matched your search

@@ -0,0 +1,177 @@
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)
}
}