Files
Cyrene_v2/internal/services/backthink/urgency_channel_test.go
T
AskaEth f91a83ff81
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
2026-10-05 13:11:04 +08:00

284 lines
12 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 backthink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件守住 design/12 §6 安全域豁免的**通用性**:紧急度不是 IoT 专有通道 ——
// 任何事件生产者(外围插件、系统 / 能力模块)都能用同一个 urgency.Declarer 声明
// 紧急度,她自己也能在结论里结构化地声明「这条要穿透安静时段」;摘掉通用通道或
// 那条自我声明,这些用例即变红。
// stubProducer 是一个**非 IoT** 的事件生产者替身:它只用通用通道声明紧急度,
// backthink 完全不认识它的类型(没有任何为它写下的分支)。它经**泛化系统事件主题**
// 接入——那是契约里给任意生产者的入口(design/12 §1)。
type stubProducer struct {
Summary string
Level urgency.Level
}
func (s stubProducer) Urgency() urgency.Level { return s.Level }
// stubProducerSubject 是替身生产者自己的总线主题。它刻意不是 IoT 的主题,用来证明
// 紧急度折算与主题无关;注意:真要在总线上收到一个**新主题**,仍需在 backthink 的
// triggers() 里登记一行订阅(那是订阅面,不是紧急度通道的形状)。
const stubProducerSubject = "plugin.widget.state"
// TestTriggerForEvent_GenericProducerChannel 断言任意生产者的紧急度声明都经同一条
// 通用通道折算到 Trigger.Import,且与主题无关:未声明 → Normal(默认保守),
// warning → High,critical → Critical;声明只能上抬,绝不压低主题自带的默认档
// (提醒仍是 High)。
func TestTriggerForEvent_GenericProducerChannel(t *testing.T) {
t.Parallel()
cases := []struct {
name string
subject string
level urgency.Level
want Importance
}{
{name: "未声明_普通", subject: stubProducerSubject, level: urgency.LevelRoutine, want: ImportanceNormal},
{name: "warning", subject: stubProducerSubject, level: urgency.LevelWarning, want: ImportanceHigh},
{name: "critical", subject: stubProducerSubject, level: urgency.LevelCritical, want: ImportanceCritical},
{name: "critical_经系统事件主题", subject: SubjectSystemEvent, level: urgency.LevelCritical, want: ImportanceCritical},
{name: "未声明_不压低提醒的默认档", subject: SubjectReminderTrigger, level: urgency.LevelRoutine, want: ImportanceHigh},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: tc.subject, Payload: stubProducer{Summary: "widget changed", Level: tc.level}})
if tr.Import != tc.want {
t.Fatalf("Import = %v, want %v", tr.Import, tc.want)
}
if tr.Kind != TriggerEvent {
t.Fatalf("Kind = %v, want %v", tr.Kind, TriggerEvent)
}
})
}
}
// TestDecide_GenericProducerQuietHours 断言非 IoT 生产者的声明在安静时段同样生效:
// critical 穿透,未声明照常扣下。判定侧不改,穿透靠的是事件真的走到了那一档。
func TestDecide_GenericProducerQuietHours(t *testing.T) {
t.Parallel()
cases := []struct {
name string
level urgency.Level
want bool
}{
{name: "critical_穿透", level: urgency.LevelCritical, want: true},
{name: "未声明_扣下", level: urgency.LevelRoutine, want: false},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tr := triggerForEvent(bus.Event{Subject: SubjectSystemEvent, Payload: stubProducer{Level: tc.level}})
got := testBackthink().Decide(DecisionInput{
Trigger: tr.Source, Import: tr.Import, At: nightAt(),
UserState: UserState{}, ConclusionPush: true,
})
if got.ShouldPush != tc.want {
t.Fatalf("Decide = %+v, want ShouldPush=%v", got, tc.want)
}
})
}
}
// newStubWiredBackthink 把替身生产者接到同一条进程内 bus 与 backthink 上,用例因此
// 走生产路径(publish → onEvent → think → Decide → push)。
func newStubWiredBackthink(t *testing.T, thinker Thinker, pusher Pusher) (*Backthink, bus.Bus) {
t.Helper()
bs := bus.New()
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, bs
}
// recordFor 等指定触发源的一轮思考落库并返回那条记录(最新在前)。
func recordFor(t *testing.T, b *Backthink, source string) Record {
t.Helper()
var rec Record
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
for _, r := range recs {
if r.Trigger == source {
rec = r
return true
}
}
return false
})
return rec
}
// TestBackthink_ThirdPartyCriticalNightPush 是「非 IoT 生产者也能穿透安静时段」的
// 端到端断言:替身生产者经泛化系统事件主题声明 critical → 主动推送发出。
func TestBackthink_ThirdPartyCriticalNightPush(t *testing.T) {
t.Parallel()
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, bs := newStubWiredBackthink(t, &mockThinker{text: "[我决定:push]\n厨房的燃气报警响了,我去看看"}, pusher)
if err := bs.Publish(context.Background(), SubjectSystemEvent, stubProducer{Summary: "gas alarm", Level: urgency.LevelCritical}); err != nil {
t.Fatalf("Publish: %v", err)
}
rec := recordFor(t, b, SubjectSystemEvent)
if !rec.ShouldPush || !rec.PushResult.Pushed {
t.Fatalf("record = %+v, want a delivered push for the third-party critical event", rec)
}
}
// TestBackthink_ThirdPartyRoutineNightHeld 是对照:同一条通用通道上未声明紧急度的
// 第三方事件在凌晨既不穿透(层 2 的判定),也因 design/12 §6 策略一被降级为仅登记
// ——通用化没有把所有第三方事件都变成可穿透。
func TestBackthink_ThirdPartyRoutineNightHeld(t *testing.T) {
t.Parallel()
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, bs := newStubWiredBackthink(t, &mockThinker{text: "[我决定:push]\n窗边的小灯闪了一下"}, pusher)
if err := bs.Publish(context.Background(), SubjectSystemEvent, stubProducer{Summary: "widget tick"}); err != nil {
t.Fatalf("Publish: %v", err)
}
rec := recordFor(t, b, SubjectSystemEvent)
if rec.ShouldPush || rec.PushResult.Route != RouteNone {
t.Fatalf("record = %+v, want a held record", rec)
}
if !strings.Contains(rec.PushResult.Detail, "downgraded") {
t.Fatalf("PushResult.Detail = %q, want the low-value downgrade to be named", rec.PushResult.Detail)
}
if pusher.count() != 0 {
t.Fatalf("pusher calls = %d, want 0", pusher.count())
}
}
// TestParseProactiveUrgency_StructuredOnly 钉住她自己那条声明是**结构化**通道:
// 只认固定枚举的带括号标记,散文里的「紧急」不触发(项目红线:绝不从自由文本
// 解读意图),枚举外的档位一律不上抬(保守)。
func TestParseProactiveUrgency_StructuredOnly(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want urgency.Level
}{
{name: "critical", in: "[我决定:push]\n[紧急:critical]\n院里有人影", want: urgency.LevelCritical},
{name: "warning_全角冒号大小写混写", in: "[紧急:Warning]\n设备离线了", want: urgency.LevelWarning},
{name: "routine", in: "[紧急:routine]\n例行汇报", want: urgency.LevelRoutine},
{name: "未声明", in: "[我决定:push]\n晚安", want: urgency.LevelRoutine},
{name: "散文里的紧急不算", in: "情况有点紧急,我得马上告诉你", want: urgency.LevelRoutine},
{name: "裸写不算", in: "紧急:critical\n有点事", want: urgency.LevelRoutine},
{name: "枚举外的档位不上抬", in: "[紧急:urgent]\n有点事", want: urgency.LevelRoutine},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := parseProactiveUrgency(tc.in); got != tc.want {
t.Fatalf("parseProactiveUrgency(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
// 该标记是内部决策标记:绝不能存活到面向用户的正文里。
msg, _, _ := parseProactive("[我决定:push]\n[紧急:critical]\n院里有人影")
if strings.Contains(msg, "紧急") {
t.Fatalf("visible message = %q, want the internal urgency marker stripped", msg)
}
}
// TestEscalateImport_OnlyRaises 钉住声明只能上抬:安全域事件既得的档位不会被
// 一句 routine 抹掉,未声明时基础档位逐字不变。
func TestEscalateImport_OnlyRaises(t *testing.T) {
t.Parallel()
cases := []struct {
name string
base Importance
declared urgency.Level
want Importance
}{
{name: "未声明_不变", base: ImportanceNormal, declared: urgency.LevelRoutine, want: ImportanceNormal},
{name: "心跳声明critical_上抬", base: ImportanceLow, declared: urgency.LevelCritical, want: ImportanceCritical},
{name: "声明warning_上抬", base: ImportanceNormal, declared: urgency.LevelWarning, want: ImportanceHigh},
{name: "声明routine_压不低critical", base: ImportanceCritical, declared: urgency.LevelRoutine, want: ImportanceCritical},
{name: "声明warning_不压低high", base: ImportanceHigh, declared: urgency.LevelWarning, want: ImportanceHigh},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := escalateImport(tc.base, tc.declared); got != tc.want {
t.Fatalf("escalateImport(%v, %v) = %v, want %v", tc.base, tc.declared, got, tc.want)
}
})
}
}
// TestBackthink_SelfDeclaredUrgencyBypassesQuietHoursButStaysGated 覆盖她那条通道的
// 两半:心跳轮里声明 critical 能穿透安静时段(design/12 §6 安全域豁免),但推送仍受
// 频率门(PushGate)兜底 —— 自我声明不是无限通行证;不声明则连安静时段都过不去。
func TestBackthink_SelfDeclaredUrgencyBypassesQuietHoursButStaysGated(t *testing.T) {
t.Parallel()
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n[紧急:critical]\n院里的感应灯莫名亮了好几次"},
State: &mockState{state: UserState{}},
IM: im,
Now: func() time.Time { return nightAt() },
})
b.think(context.Background(), heartbeatTrigger())
b.think(context.Background(), heartbeatTrigger())
if got := im.count(); got != 1 {
t.Fatalf("IM pushes = %d, want 1 (first bypasses quiet hours, second held by the push gate)", got)
}
recs, err := b.List(context.Background(), 5)
if err != nil {
t.Fatalf("List: %v", err)
}
if !recs[0].ShouldPush || recs[0].PushResult.Route != RouteRateLimited {
t.Fatalf("second round = %+v, want a decision to push blocked by the rate gate", recs[0])
}
}
// TestBackthink_UndeclaredHeartbeatNightHeld 是对照:同一时段、同一心跳轮,
// 没有声明紧急度时照旧被安静时段扣下 —— 默认行为逐字不变。
func TestBackthink_UndeclaredHeartbeatNightHeld(t *testing.T) {
t.Parallel()
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n我有点想她了"},
State: &mockState{state: UserState{}},
IM: im,
Now: func() time.Time { return nightAt() },
})
b.think(context.Background(), heartbeatTrigger())
recs, err := b.List(context.Background(), 5)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].ShouldPush || im.count() != 0 {
t.Fatalf("record = %+v, pushes = %d, want the undeclared heartbeat held in quiet hours", recs[0], im.count())
}
if !strings.Contains(recs[0].PushResult.Detail, "quiet hours") {
t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", recs[0].PushResult.Detail)
}
}