- gateway:优先读落库的 rec.Urgency(精确档位,与安静时段窗口无关);仅空串才回落到既有下限推导,并打 urgencyLowerBound 标记(**只在推导值上为 true**)
- 面板三态:精确(例常/紧急(高)/紧急·安全域)|下限(一律加 ≥ 前缀)|未知;下限悬停说明写清「为什么旧行只能是下限」
- 实时帧零协议改动:Record 的 JSON 键就是 urgency ⇒ 帧自然带上;wsParse 早已防御性读取(只加一处 urgencyLowerBound 防御读)
- 契约:v0.69(directMsgPerMin 透传修复,逐字照抄附录 A)与 v0.70(精确化 + 更正 §4.10(b)「实时帧不带这两键」)一起登记
- 变异实测:旧行不标下限 ⇒ 3 条测试红(结构体对比);前端去掉 ≥ ⇒ 渲染测试红
- ⚠️ 诚实边界(§7.3 已登记):实时旧记录刷新前显示「档位未知」、刷新后显示「≥ 例常」——同一条记录两路下的已知差异
307 lines
14 KiB
Go
307 lines
14 KiB
Go
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
|
||
}
|