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

+146
View File
@@ -0,0 +1,146 @@
package iot
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/urgency"
)
// captureUpdates 订阅 DeviceUpdateSubject 并把收到的载荷送进返回的通道。
func captureUpdates(t *testing.T, m *Manager) <-chan DeviceUpdate {
t.Helper()
ch := make(chan DeviceUpdate, 8)
unsub, err := m.Bus().Subscribe(context.Background(), DeviceUpdateSubject, func(_ context.Context, e bus.Event) error {
if d, ok := e.Payload.(DeviceUpdate); ok {
ch <- d
}
return nil
})
if err != nil {
t.Fatalf("Subscribe: %v", err)
}
t.Cleanup(unsub)
return ch
}
// TestControl_UnlockIsCriticalSafetyEvent 验证门锁开启被标成安全域:
// 这是 IoT 侧唯一的严重度升级出处,也是 quiet hours 豁免的生产端。
func TestControl_UnlockIsCriticalSafetyEvent(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
if _, err := m.Control(context.Background(), "lock.front", "unlock", nil); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityCritical {
t.Fatalf("Severity = %v, want %v (an unlocked door is a safety event)", got.Severity, SeverityCritical)
}
if !strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want it to name the safety domain", got.Message)
}
}
// TestControl_RoutineStaysRoutine 是保守默认的反面断言:常规设备变化(以及
// 锁门这类非安全域动作)绝不被升级——安静时段照旧扣下它们。
func TestControl_RoutineStaysRoutine(t *testing.T) {
t.Parallel()
cases := []struct {
name string
device string
action string
}{
{name: "开灯", device: "light.living", action: "on"},
{name: "空调设定温度", device: "ac.living", action: "set_temperature"},
{name: "锁门", device: "lock.front", action: "lock"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
m, err := New(config.IoTConfig{Provider: "mock"}, nil)
if err != nil {
t.Fatalf("New: %v", err)
}
updates := captureUpdates(t, m)
params := map[string]any{"temperature": 25}
if _, err := m.Control(context.Background(), tc.device, tc.action, params); err != nil {
t.Fatalf("Control: %v", err)
}
got := recvIoT(t, updates, 2*time.Second)
if got.Severity != SeverityRoutine {
t.Fatalf("Severity = %v, want %v (routine transitions must stay routine)", got.Severity, SeverityRoutine)
}
if strings.Contains(got.Message, "安全域") {
t.Fatalf("Message = %q, want no safety-domain wording", got.Message)
}
})
}
}
// TestAlertSeverity_String 验证严重度标签经通用词汇转发(日志与记录依赖它),
// 且 IoT 侧的类型就是通用紧急度的别名 —— 它没有自己的专有严重度体系。
func TestAlertSeverity_String(t *testing.T) {
t.Parallel()
cases := []AlertSeverity{SeverityRoutine, SeverityWarning, SeverityCritical, AlertSeverity(7)}
for _, in := range cases {
if got, want := in.String(), urgency.Level(int(in)).String(); got != want {
t.Fatalf("AlertSeverity(%d).String() = %q, want the generic label %q", int(in), got, want)
}
}
if got := SeverityCritical.String(); got != "critical" {
t.Fatalf("SeverityCritical.String() = %q, want %q", got, "critical")
}
}
// TestDeviceUpdate_DeclaresUrgencyOnGenericChannel 钉住 IoT 走的是**通用**通道:
// 设备跃迁载荷实现 urgency.Declarer,backthink 因此不必认识 IoT 的专有字段;
// 未标注严重度的跃迁声明零值 routine(默认保守)。
func TestDeviceUpdate_DeclaresUrgencyOnGenericChannel(t *testing.T) {
t.Parallel()
var declared urgency.Declarer = DeviceUpdate{Severity: SeverityCritical}
if got := declared.Urgency(); got != urgency.LevelCritical {
t.Fatalf("DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelCritical)
}
if got := (DeviceUpdate{}).Urgency(); got != urgency.LevelRoutine {
t.Fatalf("unlabelled DeviceUpdate.Urgency() = %v, want %v", got, urgency.LevelRoutine)
}
}
// TestSafetySeverity_RuleTable 直接钉住规则表:未登记的设备类型与动作一律不升级。
func TestSafetySeverity_RuleTable(t *testing.T) {
t.Parallel()
cases := []struct {
name string
deviceType string
action string
wantSev AlertSeverity
wantHit bool
}{
{name: "门锁开启_命中", deviceType: "lock", action: "unlock", wantSev: SeverityCritical, wantHit: true},
{name: "门锁锁闭_不命中", deviceType: "lock", action: "lock", wantSev: SeverityRoutine, wantHit: false},
{name: "门锁翻转_不命中", deviceType: "lock", action: "toggle", wantSev: SeverityRoutine, wantHit: false},
{name: "灯_不命中", deviceType: "light", action: "on", wantSev: SeverityRoutine, wantHit: false},
{name: "传感器_不命中", deviceType: "sensor", action: "unlock", wantSev: SeverityRoutine, wantHit: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
sev, hit := safetySeverity(tc.deviceType, tc.action)
if hit != tc.wantHit || sev != tc.wantSev {
t.Fatalf("safetySeverity(%q, %q) = (%v, %v), want (%v, %v)",
tc.deviceType, tc.action, sev, hit, tc.wantSev, tc.wantHit)
}
})
}
}