- 追加 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
332 lines
14 KiB
Go
332 lines
14 KiB
Go
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)
|
||
}
|
||
}
|