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) } }