Files
Cyrene_v2/internal/services/backthink/urgency_channel_test.go
T
AskaEth 94da6bcd6d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 静默期自排 C 的判定侧(C1-1,切片三之一)
- 追加 TriggerSelfWake(不动既有编号)+ SelfWakeRequest(走既有 urgency 声明通道,不新造第二条)
- escalateImport 折算收成唯一 foldDeclared(事件轮与自排轮共用、只上抬取较高档)
- DecisionInput.Heartbeat bool → Kind TriggerKind;心跳仍免检、**自排不享心跳豁免**(要求声明 ≥ warning)
- push 与档位分离:push.go 零改动即自动正确(eventDriven 对 self_wake 为 false ⇒ 照旧过 PushGate)
- 4 个变异自检(折算空操作/删自排分支/常量传心跳/非事件一律豁免)
⚠️ 本片只是判定侧:WakeNow/worker/闸属 C1-2,工具面与提示词属 C1-3 ⇒ **不得写『C 已完成』**
⚠️ 遗留 G-A(建议登记进 design/12 C.4):TriggerKind 零值=TriggerHeartbeat ⇒ 漏填=最宽豁免(fail-open);
   本片已在字段注释与单测里显式化,但规格侧应补一句或引入 TriggerUnknown
2026-10-06 18:46:27 +08:00

332 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 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)
}
})
}
}
// TestFoldDeclared_SharesOneChannelWithSelfWake 是 design/12 §C.2 的结构断言:这条
// 通用通道**只有一处**折算点(foldDeclared),总线事件载荷与她自排的一轮都经它叠档
// ——同一个声明档位必须给出同一个 Import。它同时钉住「声明只上抬」(routine 不压低
// 主题自带档)与自排轮的形状(Source / Summary 前缀 / Kind)。
//
// 变异:把 foldDeclared 改成空操作(直接 return tr)、或让自排另写一条「档位 → 重要度」
// switch ⇒ warning / critical 两条用例变红。
func TestFoldDeclared_SharesOneChannelWithSelfWake(t *testing.T) {
t.Parallel()
cases := []struct {
name string
declared urgency.Level
want Importance
}{
{name: "未声明_不上抬", declared: urgency.LevelRoutine, want: ImportanceNormal},
{name: "warning_抬到High", declared: urgency.LevelWarning, want: ImportanceHigh},
{name: "critical_抬到Critical", declared: urgency.LevelCritical, want: ImportanceCritical},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ev := triggerForEvent(bus.Event{Subject: SubjectSystemEvent, Payload: stubProducer{Level: tc.declared}})
if ev.Import != tc.want {
t.Fatalf("event Import = %v, want %v", ev.Import, tc.want)
}
sw := selfWakeTrigger(SelfWakeRequest{Reason: "复盘一下今天的对话", Declared: tc.declared})
if sw.Import != tc.want {
t.Fatalf("self wake Import = %v, want %v (与事件同一条通道)", sw.Import, tc.want)
}
if sw.Kind != TriggerSelfWake {
t.Fatalf("self wake Kind = %v, want %v", sw.Kind, TriggerSelfWake)
}
if sw.Source != SelfWakeSource {
t.Fatalf("self wake Source = %q, want %q", sw.Source, SelfWakeSource)
}
if !strings.HasPrefix(sw.Summary, SelfWakeSummaryPrefix) {
t.Fatalf("self wake Summary = %q, want the stable prefix %q", sw.Summary, SelfWakeSummaryPrefix)
}
})
}
// 声明只上抬,绝不压低主题自带的档:提醒的 High 不会被一句 routine 抹掉。
reminder := triggerForEvent(bus.Event{Subject: SubjectReminderTrigger, Payload: stubProducer{Level: urgency.LevelRoutine}})
if reminder.Import != ImportanceHigh {
t.Fatalf("reminder Import = %v, want %v (routine 不得压低主题档位)", reminder.Import, ImportanceHigh)
}
}
// 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)
}
}