Files
Cyrene_v2/internal/services/gateway/admin_backthink_urgency_test.go
T
AskaEth a0abf0199a
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway)!: 后台思考档位精确化(+ 面板三态 + 契约 v0.69/v0.70)
- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
2026-10-05 14:15:55 +08:00

307 lines
14 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 gateway
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件是 P4「后台思考行紧急度 / 穿透安静时段」与其 X2b 扩展(精确档位 + 下限标记)
// 的测试:钉住「穿透标记 ⇔ 档位」的对应关系、「精确 vs 只是下限 vs 未知」三态在线上的
// 可分辨性,以及「不可证时不下发」的诚实边界。
// quietCfg 构造安静时段 = 23:00-07:00(跨午夜)的生效配置。
func quietCfg() *config.Config {
cfg := cfgDefault()
cfg.Background.QuietStartHour = 23
cfg.Background.QuietEndHour = 7
return cfg
}
// localAt 造一个**本地时区**的墙钟时刻(安静时段是本地钟点窗口)。
func localAt(hour int) time.Time {
return time.Date(2026, 10, 6, hour, 30, 0, 0, time.Local)
}
// TestAdminBackthink_QuietHoursBypassMatchesUrgency 是判据 ④:管理行上的穿透标记
// 必须与 `urgency` 档位下限一一对应 ——
// - 安静时段内 + 判定要推 → urgency=warning(≥ high)+ quietHoursBypassed=true;
// - 安静时段内 + 判定不推 → urgency=routine + 无穿透键;
// - 安静时段外 → 两个键都不下发(存档不可证,绝不猜)。
func TestAdminBackthink_QuietHoursBypassMatchesUrgency(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{
ID: 3, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警,得叫她",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered to 1 active client(s), event_bypass"},
},
{
ID: 2, Trigger: "heartbeat", At: localAt(3), Conclusion: "深夜只是想一想",
ShouldPush: false,
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
},
{
ID: 1, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 3 {
t.Fatalf("items = %d, want 3", len(items))
}
body := string(raw)
byID := map[string]AdminBackthinkItem{}
for _, it := range items {
byID[it.ID] = it
}
// ① 安静时段内被放行 ⇒ 穿透 + 档位下限 warning(= 至少 high)。
bypass := byID["3"]
if !bypass.QuietHoursBypassed || bypass.Urgency != urgency.LevelWarning.String() || !bypass.UrgencyLowerBound {
t.Fatalf("穿透行 = %+v, want urgency=%s + quietHoursBypassed=true + 下限标记", bypass, urgency.LevelWarning)
}
// ② 安静时段内被扣下 ⇒ routine,且不得标成穿透。
held := byID["2"]
if held.QuietHoursBypassed || held.Urgency != urgency.LevelRoutine.String() || !held.UrgencyLowerBound {
t.Fatalf("静默行 = %+v, want urgency=routine(带下限标记)且不穿透", held)
}
// ③ 安静时段之外 ⇒ 两个键都不下发(存档不可证)。
day := byID["1"]
if day.QuietHoursBypassed || day.Urgency != "" || day.UrgencyLowerBound {
t.Fatalf("白天行 = %+v, want 两个键都缺席", day)
}
// 线上形状:真穿透的行必须带这三个键;不可证的行必须**不带**它们(不是 false)。
if !strings.Contains(body, `"urgency":"warning"`) || !strings.Contains(body, `"urgencyLowerBound":true`) || !strings.Contains(body, `"quietHoursBypassed":true`) {
t.Fatalf("响应缺少穿透/档位/下限键:%s", body)
}
if !strings.Contains(body, `"urgency":"routine"`) {
t.Fatalf("响应缺少被扣下那轮的档位:%s", body)
}
if strings.Contains(body, `"quietHoursBypassed":false`) {
t.Fatalf("「没穿透」不得写成 false(键必须缺席,才不把「未知」画成事实):%s", body)
}
}
// TestAdminBackthink_OmitsUrgencyForZeroTimestamp 钉住兜底:时间戳坏掉的行
// (backthink 存储层解析失败会给零值时间)**不下发**任何档位/穿透 ——
// 宁可不显示,也不把零值时间当成凌晨而谎报一轮穿透。
func TestAdminBackthink_OmitsUrgencyForZeroTimestamp(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ID: 1, Trigger: "broken", ShouldPush: true},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
got := items[0]
if got.Urgency != "" || got.QuietHoursBypassed {
t.Fatalf("零值时间戳不得产出档位/穿透:%+v", got)
}
if strings.Contains(string(raw), `"urgency"`) || strings.Contains(string(raw), `"quietHoursBypassed"`) {
t.Fatalf("零值时间戳必须省略两个键:%s", raw)
}
}
// TestQuietHoursActiveAt_WindowSemantics 钉住窗口口径(与 backthink.decide 的
// inQuietHours 逐字一致):半开区间、可跨午夜、两端相等即关闭。
func TestQuietHoursActiveAt_WindowSemantics(t *testing.T) {
t.Parallel()
at := func(hour, minute int) time.Time {
return time.Date(2026, 10, 6, hour, minute, 0, 0, time.Local)
}
cases := []struct {
name string
start, end int
when time.Time
want bool
}{
{"跨午夜:起点", 23, 7, at(23, 0), true},
{"跨午夜:午前", 23, 7, at(2, 30), true},
{"跨午夜:终点前", 23, 7, at(6, 59), true},
{"跨午夜:终点(半开区间不含)", 23, 7, at(7, 0), false},
{"跨午夜:起点前", 23, 7, at(22, 59), false},
{"跨午夜:白天", 23, 7, at(12, 0), false},
{"日间窗口", 9, 17, at(10, 0), true},
{"日间窗口:窗口外", 9, 17, at(22, 0), false},
{"两端相等 ⇒ 关闭", 23, 23, at(23, 30), false},
{"零值 ⇒ 关闭", 0, 0, at(1, 0), false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := quietHoursActiveAt(tc.start, tc.end, tc.when); got != tc.want {
t.Fatalf("quietHoursActiveAt(%d,%d,%v) = %v, want %v", tc.start, tc.end, tc.when, got, tc.want)
}
})
}
}
// TestBackthinkQuietOutcomeOf_ReadsLocalHour 钉住时区口径:库里存 UTC,但安静
// 时段是**本地钟点**窗口 ⇒ 推导必须先换回本地时区再取钟点(否则北京时间凌晨 2 点
// 会被当成 UTC 18 点,穿透标记整片错位)。
func TestBackthinkQuietOutcomeOf_ReadsLocalHour(t *testing.T) {
t.Parallel()
if _, offset := time.Now().In(time.Local).Zone(); offset == 0 {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
local2am := localAt(2)
utc := local2am.UTC()
if utc.Hour() == local2am.Hour() {
t.Skip("本机 UTC:时区换算在这种环境下不可观测")
}
if got := backthinkQuietOutcomeOf(backthink.Record{At: utc, ShouldPush: true}, 23, 7); !got.bypassed {
t.Fatalf("UTC 存储的本地凌晨 2 点必须判为穿透:%+v", got)
}
}
// TestBackthinkQuietOutcomeOf_ExactUrgencyWinsOverWindow 钉住取值优先级(X2b):
// 落库档位非空 ⇒ **精确值**(与 `At`、窗口都无关,也**不标**下限);空 ⇒ 窗口推导
// (一定**标**下限);窗口外且无档位 ⇒ 无事实(空,不标)。
func TestBackthinkQuietOutcomeOf_ExactUrgencyWinsOverWindow(t *testing.T) {
t.Parallel()
// 精确档位:零值时间、白天、窗口内一律逐字下发,且都不是下限。
for _, at := range []time.Time{{}, localAt(12), localAt(2)} {
got := backthinkQuietOutcomeOf(
backthink.Record{At: at, Urgency: urgency.LevelCritical.String(), ShouldPush: false}, 23, 7)
if got.urgency != urgency.LevelCritical.String() || got.urgencyLowerBound {
t.Fatalf("at=%v 的精确档位 = %+v, want critical 且无下限标记", at, got)
}
}
// 旧行(空档位)落在窗口内 ⇒ 值来自推导,必须标下限。
pushed := backthinkQuietOutcomeOf(backthink.Record{At: localAt(2), ShouldPush: true}, 23, 7)
if pushed.urgency != urgency.LevelWarning.String() || !pushed.urgencyLowerBound || !pushed.bypassed {
t.Fatalf("窗口内放行的旧行 = %+v, want warning + 下限标记 + 穿透", pushed)
}
held := backthinkQuietOutcomeOf(backthink.Record{At: localAt(2), ShouldPush: false}, 23, 7)
if held.urgency != urgency.LevelRoutine.String() || !held.urgencyLowerBound || held.bypassed {
t.Fatalf("窗口内扣下的旧行 = %+v, want routine + 下限标记 + 不穿透", held)
}
// 窗口外的旧行:没有事实,什么都不下发(也不标下限)。
day := backthinkQuietOutcomeOf(backthink.Record{At: localAt(12), ShouldPush: true}, 23, 7)
if day.urgency != "" || day.urgencyLowerBound || day.bypassed {
t.Fatalf("窗口外的旧行 = %+v, want 全空", day)
}
}
// TestAdminBackthink_ExactUrgencyIsNotLowerBound 是 X2b 的线上判据:带落库档位的新行
// ⇒ 精确档位且**不带**下限标记;空档位的旧行 ⇒ 窗口推导且**带**下限标记;窗口外且
// 无档位 ⇒ 键缺席(未知)。三态必须在线形状上可分辨 —— 「不许谎报」就是这条。
func TestAdminBackthink_ExactUrgencyIsNotLowerBound(t *testing.T) {
t.Parallel()
feed := &stubBackthinkFeed{records: []backthink.Record{
{ // ① 新行 + 安静时段内 + 放行 ⇒ 精确 critical(不是「≥ 高」的下限),仍算穿透。
ID: 40, Trigger: "iot.device.smoke", At: localAt(2), Conclusion: "烟雾告警", Urgency: "critical",
ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "delivered"},
},
{ // ② 新行 + 白天 ⇒ 精确 routine(窗口规则给不出任何档位,精确值照样可得)。
ID: 39, Trigger: "heartbeat", At: localAt(12), Conclusion: "白天的一轮", Urgency: "routine",
ShouldPush: false,
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "conclusion requested no push"},
},
{ // ③ 新行 + 时间戳坏掉 ⇒ 精确档位与窗口无关,如实下发;但不判穿透。
ID: 38, Trigger: "broken clock", Urgency: "warning", ShouldPush: true,
},
{ // ④ 旧行(空档位)+ 安静时段内 + 放行 ⇒ 下限 warning + 下限标记。
ID: 37, Trigger: "legacy.pushed", At: localAt(2), Conclusion: "旧行(窗口内放行)", ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteIMBridge, Detail: "delivered via IM bridge"},
},
{ // ⑤ 旧行(空档位)+ 安静时段内 + 扣下 ⇒ 下限 routine + 下限标记。
ID: 36, Trigger: "legacy.held", At: localAt(3), Conclusion: "旧行(窗口内扣下)", ShouldPush: false,
PushResult: backthink.PushResult{Route: backthink.RouteNone, Detail: "quiet hours; not interrupting"},
},
{ // ⑥ 旧行(空档位)+ 白天 ⇒ 键缺席(未知),绝不猜一个 routine。
ID: 35, Trigger: "legacy.day", At: localAt(12), Conclusion: "旧行(白天)", ShouldPush: true,
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Detail: "delivered"},
},
}}
_, ts, jwt := buildEnv(t, quietCfg(), Deps{Backthink: feed})
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
if status != http.StatusOK {
t.Fatalf("status = %d, body = %s", status, raw)
}
if len(items) != 6 {
t.Fatalf("items = %d, want 6", len(items))
}
byID := make(map[string]AdminBackthinkItem, len(items))
for _, it := range items {
byID[it.ID] = it
}
for _, want := range []struct {
id, urgency string
bypassed bool
}{
{"40", urgency.LevelCritical.String(), true},
{"39", urgency.LevelRoutine.String(), false},
{"38", urgency.LevelWarning.String(), false},
} {
got := byID[want.id]
if got.Urgency != want.urgency || got.UrgencyLowerBound || got.QuietHoursBypassed != want.bypassed {
t.Fatalf("精确行 %s = %+v, want urgency=%s + 无下限标记 + bypassed=%v", want.id, got, want.urgency, want.bypassed)
}
}
for _, want := range []struct {
id, urgency string
bypassed bool
}{
{"37", urgency.LevelWarning.String(), true},
{"36", urgency.LevelRoutine.String(), false},
} {
got := byID[want.id]
if got.Urgency != want.urgency || !got.UrgencyLowerBound || got.QuietHoursBypassed != want.bypassed {
t.Fatalf("旧行 %s = %+v, want urgency=%s + 下限标记 + bypassed=%v", want.id, got, want.urgency, want.bypassed)
}
}
if day := byID["35"]; day.Urgency != "" || day.UrgencyLowerBound || day.QuietHoursBypassed {
t.Fatalf("窗口外的旧行 = %+v, want 三个键都缺席", day)
}
// 线上形状(按 id 看**实际出现了哪些键**):下限键只许出现在旧行上,且恒为 true。
wire := wireKeysByID(t, raw)
for _, id := range []string{"40", "39", "38", "35"} {
if _, ok := wire[id]["urgencyLowerBound"]; ok {
t.Fatalf("id=%s 不是下限行,却带了 urgencyLowerBound:%v", id, wire[id])
}
}
for _, id := range []string{"37", "36"} {
if v, ok := wire[id]["urgencyLowerBound"]; !ok || v != true {
t.Fatalf("id=%s 是下限行,urgencyLowerBound 必须显式为 true:%v", id, wire[id])
}
}
if _, ok := wire["35"]["urgency"]; ok {
t.Fatalf("窗口外且无档位的旧行不得带 urgency:%v", wire["35"])
}
}
// wireKeysByID 按 id 给出每个条目在原始 JSON 里**实际出现了哪些键**——解码到结构体后
// 「键缺席」与「false」不可区分,而本切片的判据正是「键在不在」(对齐既有测试的口径)。
func wireKeysByID(t *testing.T, raw []byte) map[string]map[string]any {
t.Helper()
var list struct {
Data []map[string]any `json:"data"`
}
if err := json.Unmarshal(raw, &list); err != nil {
t.Fatalf("decode raw: %v (%s)", err, raw)
}
out := make(map[string]map[string]any, len(list.Data))
for _, m := range list.Data {
id, _ := m["id"].(string)
out[id] = m
}
return out
}