Files
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

150 lines
6.4 KiB
Go
Raw Permalink 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/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 本文件守住 design/12 §N C 的**判定侧**(切片 C1-1):自我唤醒是追加的第三个
// TriggerKind,它的档位折算与总线事件共用同一条通道(foldDeclared → escalateImport),
// 判定侧只多认一个 kind、不加第二条档位通道。对应 C.8 判据 1 / 4 / 6 / 9。
//
// ⚠️ 本片**不产生**自排轮:WakeNow / worker / 工具面分别属 C1-2 / C1-3,因此这里的
// 「生产路径」用例直接构造 selfWakeTrigger —— 它证明的是链路已就绪,不是能力已交付。
// TestTriggerKind_SelfWakeAppendedWithStableLabels 钉住标签与编号口径(C.6 落地清单):
// 落库值(Record.Trigger)、面板值与审计前缀共用同一个真源;新 kind 是**追加**的,
// 既有两个值的编号与标签逐字不变(改编号会静默改写已落库记录的含义)。
func TestTriggerKind_SelfWakeAppendedWithStableLabels(t *testing.T) {
t.Parallel()
cases := []struct {
kind TriggerKind
want string
}{
{TriggerHeartbeat, "heartbeat"},
{TriggerEvent, "event"},
{TriggerSelfWake, SelfWakeSource},
}
for _, tc := range cases {
if got := tc.kind.String(); got != tc.want {
t.Fatalf("TriggerKind(%d).String() = %q, want %q", int(tc.kind), got, tc.want)
}
}
if !(TriggerHeartbeat < TriggerEvent && TriggerEvent < TriggerSelfWake) {
t.Fatalf("kind 编号不再是追加的:heartbeat=%d event=%d self_wake=%d",
int(TriggerHeartbeat), int(TriggerEvent), int(TriggerSelfWake))
}
if SelfWakeSource != "self_wake" {
t.Fatalf("SelfWakeSource = %q, want %q(面板与文档按此值登记)", SelfWakeSource, "self_wake")
}
if SelfWakeSummaryPrefix != SelfWakeSource+": " {
t.Fatalf("SelfWakeSummaryPrefix = %q, want %q", SelfWakeSummaryPrefix, SelfWakeSource+": ")
}
}
// TestSelfWakeTrigger_ShapeAndUndeclaredDefault 钉住自排轮的形状(C.1/C.2):来源与
// 摘要用稳定标签、主题级默认是 Normal(不是 Low —— 不新造一档「比例行还低」的语义)、
// Kind 是 TriggerSelfWake;零值声明不上抬。
func TestSelfWakeTrigger_ShapeAndUndeclaredDefault(t *testing.T) {
t.Parallel()
tr := selfWakeTrigger(SelfWakeRequest{Reason: "想看看她今天的安排"})
if tr.Source != SelfWakeSource {
t.Fatalf("Source = %q, want %q", tr.Source, SelfWakeSource)
}
if tr.Kind != TriggerSelfWake {
t.Fatalf("Kind = %v, want %v", tr.Kind, TriggerSelfWake)
}
if tr.Import != ImportanceNormal {
t.Fatalf("Import = %v, want %v(未声明档位时不上抬)", tr.Import, ImportanceNormal)
}
if tr.Summary != SelfWakeSummaryPrefix+"想看看她今天的安排" {
t.Fatalf("Summary = %q, want the stable prefix + 她写的理由", tr.Summary)
}
// 理由为空也要带前缀:context.go 靠前缀认轮次,不靠理由内容。
if empty := selfWakeTrigger(SelfWakeRequest{}); !strings.HasPrefix(empty.Summary, SelfWakeSummaryPrefix) {
t.Fatalf("empty-reason Summary = %q, want the stable prefix", empty.Summary)
}
}
// TestSelfWakeRequest_DeclarerChannel 钉住自排**没有**第二条档位通道:她的声明经
// urgency.Declarer 被 foldDeclared 读出(与 IoT 设备跃迁同一个接口),因此
// SelfWakeRequest 必须满足该接口。
func TestSelfWakeRequest_DeclarerChannel(t *testing.T) {
t.Parallel()
var d urgency.Declarer = SelfWakeRequest{Reason: "r", Declared: urgency.LevelWarning}
if got := d.Urgency(); got != urgency.LevelWarning {
t.Fatalf("Urgency() = %v, want %v", got, urgency.LevelWarning)
}
if got := (SelfWakeRequest{}).Urgency(); got != urgency.LevelRoutine {
t.Fatalf("zero-value Urgency() = %v, want %v(未声明 = routine)", got, urgency.LevelRoutine)
}
}
// TestBackthink_SelfWakeRoundProductionPath 走生产路径 think():一轮自排思考落库为
// trigger="self_wake",长期静默段里例常自排被扣下(C.4),而声明 warning 后放行且
// 落库档位是**折算后**的 warning(C.6)。对应 C.8 判据 6 / 9。
//
// 变异:把 backthink.go 的 `Kind: tr.Kind` 改回传入常量(如 TriggerHeartbeat)⇒ 第一段
// 的「例常自排被扣下」变红;把 SelfWakeSource 改回心跳值 ⇒ 落库断言变红。
func TestBackthink_SelfWakeRoundProductionPath(t *testing.T) {
t.Parallel()
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: &mockThinker{text: "[我决定:push]\n我想再看看她今天的安排"},
State: &mockState{state: UserState{}},
Pusher: pusher,
Recent: &fakeRecent{msgs: []history.Message{{
Role: history.RoleUser,
Content: "我先去忙了",
CreatedAt: dayAt().Add(-3 * time.Hour).UnixMilli(),
}}},
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), selfWakeTrigger(SelfWakeRequest{Reason: "复盘一下今天的对话"}))
if got := pusher.count(); got != 0 {
t.Fatalf("pusher calls = %d, want 0(长期静默段里例常自排被扣下)", got)
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(recs) != 1 {
t.Fatalf("records = %+v, want exactly one self-wake record", recs)
}
if recs[0].Trigger != SelfWakeSource {
t.Fatalf("Record.Trigger = %q, want %q", recs[0].Trigger, SelfWakeSource)
}
if recs[0].ShouldPush {
t.Fatalf("record = %+v, want the routine self wake held", recs[0])
}
if !strings.Contains(recs[0].PushResult.Detail, "self-initiated wake") {
t.Fatalf("PushResult.Detail = %q, want it to name the self-initiated wake hold", recs[0].PushResult.Detail)
}
// 同一轮只换声明:warning ⇒ 折算 High ⇒ 长期静默段放行,且落库档位是折算后的值。
b.think(context.Background(), selfWakeTrigger(SelfWakeRequest{
Reason: "她可能正需要人陪", Declared: urgency.LevelWarning,
}))
recs, err = b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if !recs[0].ShouldPush || !recs[0].PushResult.Pushed {
t.Fatalf("declared self wake = %+v, want a delivered push", recs[0])
}
if recs[0].Urgency != urgency.LevelWarning.String() {
t.Fatalf("Record.Urgency = %q, want %q(落库的是该轮实际生效的档位)", recs[0].Urgency, urgency.LevelWarning.String())
}
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1", got)
}
}