Files
Cyrene_v2/internal/services/backthink/backthink_test.go
T
Aska d09b25071d
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 事件驱动主动消息绕过PushGate——IoT等事件该发就发(heartbeat仍受60min节流)
- trigger.go: 新增 TriggerKind(TriggerHeartbeat/TriggerEvent) + Trigger.Kind 标记触发来源
- push.go: PushRequest.TriggerKind 分流——event 跳过 gate.Allow/RecordPush(该发就发),heartbeat 仍受 60min≤1/冷却/分类限频;PushResult.Bypassed 标记 + detail=event_bypass 区分
- backthink.go: think() 传 TriggerKind + 过同事件去重门
- event_dedup.go(新): 同事件(Source+Summary)去重防刷,无 60min 节流;不同事件/心跳后重置链
- 测试: event 连续 3 次全推 bypass / heartbeat 第2次 rate_limited / event 不消耗心跳限 / 同事件去重
- build/vet/test 全绿 + windows/darwin 交叉编译通过
2026-09-08 13:46:43 +08:00

375 lines
12 KiB
Go

package backthink
import (
"context"
"errors"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
)
// newBackthink builds a Backthink with a fresh in-memory DB and the given deps,
// returning the zeroed deps filled with the DB handle so tests can inspect deps.
func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) {
t.Helper()
deps.DB = newTestDB(t)
b, err := New(cfg, deps)
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(func() { _ = b.Stop(context.Background()) })
return b, deps
}
func TestNew_Validation(t *testing.T) {
t.Parallel()
if _, err := New(Config{}, Deps{DB: newTestDB(t)}); !errors.Is(err, ErrNoThinker) {
t.Fatalf("missing thinker: err = %v, want ErrNoThinker", err)
}
if _, err := New(Config{}, Deps{Thinker: &mockThinker{}}); !errors.Is(err, ErrNilDB) {
t.Fatalf("missing db: err = %v, want ErrNilDB", err)
}
}
// TestConfig_Defaults verifies the tightened proactive-push defaults: a 15-minute
// heartbeat, at most 1 push per 60 minutes, a 30-minute post-push cooldown, a
// 10-minute recent-conversation cooldown, and a 2-hour per-flavor window.
func TestConfig_Defaults(t *testing.T) {
t.Parallel()
cfg := normalize(Config{})
if cfg.IntervalSec != 900 {
t.Fatalf("IntervalSec = %d, want 900", cfg.IntervalSec)
}
if cfg.Interval != 900*time.Second {
t.Fatalf("Interval = %v, want 900s", cfg.Interval)
}
if cfg.PushLimit != 1 {
t.Fatalf("PushLimit = %d, want 1", cfg.PushLimit)
}
if cfg.PushWindow != 60*time.Minute {
t.Fatalf("PushWindow = %v, want 60m", cfg.PushWindow)
}
if cfg.PushCooldown != 30*time.Minute {
t.Fatalf("PushCooldown = %v, want 30m", cfg.PushCooldown)
}
if cfg.RecentChatCooldown != 10*time.Minute {
t.Fatalf("RecentChatCooldown = %v, want 10m", cfg.RecentChatCooldown)
}
if cfg.KindWindow != 2*time.Hour {
t.Fatalf("KindWindow = %v, want 2h", cfg.KindWindow)
}
}
func TestBackthink_Heartbeat_TriggersThink(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: 20 * time.Millisecond}, Deps{Thinker: thinker})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1
})
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].Trigger != "heartbeat" {
t.Fatalf("Trigger = %q, want heartbeat", recs[0].Trigger)
}
}
func TestBackthink_EventTrigger_Push(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
State: &mockState{state: UserState{}},
Pusher: pusher,
Now: func() time.Time { return dayAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1
})
if got := thinker.count(); got != 1 {
t.Fatalf("thinker calls = %d, want 1", got)
}
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (important + daytime)", got)
}
recs, _ := b.List(context.Background(), 10)
if !recs[0].ShouldPush {
t.Fatalf("ShouldPush = false, want true")
}
if !recs[0].PushResult.Pushed || recs[0].PushResult.Route != RouteActiveClient {
t.Fatalf("PushResult = %+v, want pushed active_client", recs[0].PushResult)
}
}
func TestBackthink_EventTrigger_NoPush(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
State: &mockState{state: UserState{Sleeping: true}},
Pusher: pusher,
Now: func() time.Time { return nightAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), iot.DeviceUpdateSubject, iot.DeviceUpdate{
Device: iot.Device{ID: "washer", Type: "sensor"},
Action: "finished",
}); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1
})
if got := pusher.count(); got != 0 {
t.Fatalf("pusher calls = %d, want 0 (asleep + night)", got)
}
recs, _ := b.List(context.Background(), 10)
if recs[0].ShouldPush {
t.Fatalf("ShouldPush = true, want false")
}
if recs[0].PushResult.Route != RouteNone {
t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
}
if recs[0].Trigger != "iot.device_update" {
t.Fatalf("Trigger = %q, want iot.device_update", recs[0].Trigger)
}
}
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
t.Parallel()
// design/12 §6 策略一: a low-value system event on a public platform does not
// wake a full thinking pass — it is downgraded to a registration-only record.
thinker := &mockThinker{text: "should not be called"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return dayAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "member joined"}); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 1 && recs[0].Trigger == SubjectSystemEvent
})
if got := thinker.count(); got != 0 {
t.Fatalf("thinker calls = %d, want 0 (low-value system event downgraded)", got)
}
recs, _ := b.List(context.Background(), 10)
if recs[0].ShouldPush {
t.Fatalf("ShouldPush = true for a downgraded system event")
}
if recs[0].PushResult.Route != RouteNone {
t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
}
}
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
t.Parallel()
// A high-importance (owner-relevant) system event still wakes a thinking pass —
// the denoise gate only downgrades low-value noise (design/12 §6 策略一).
thinker := &mockThinker{text: "important notice"}
bs := bus.New()
b, _ := newBackthink(t, Config{}, Deps{
Bus: bs,
Thinker: thinker,
Now: func() time.Time { return dayAt() },
})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "owner tagged", Import: ImportanceHigh}); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
}
func TestBackthink_List_NewestFirst(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: 15 * time.Millisecond}, Deps{Thinker: thinker})
b.Start(context.Background())
defer func() { _ = b.Stop(context.Background()) }()
eventually(t, 2*time.Second, func() bool {
recs, _ := b.List(context.Background(), 10)
return len(recs) >= 2
})
top, err := b.List(context.Background(), 1)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(top) != 1 {
t.Fatalf("len = %d, want 1", len(top))
}
all, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
// The single-row listing must be the same newest record as the head of the
// full listing, and the full listing must be strictly newest-first.
if top[0].ID != all[0].ID {
t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID)
}
for i := 1; i < len(all); i++ {
if all[i].ID >= all[i-1].ID {
t.Fatalf("records not newest-first: %d before %d", all[i-1].ID, all[i].ID)
}
}
}
func TestBackthink_Stop_NoLeak(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "nothing to report"}
b, _ := newBackthink(t, Config{Interval: 10 * time.Millisecond}, Deps{Thinker: thinker})
b.Start(context.Background())
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
// Idempotent.
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("second Stop: %v", err)
}
before := thinker.count()
time.Sleep(40 * time.Millisecond)
if got := thinker.count(); got != before {
t.Fatalf("thinker calls grew after stop: %d -> %d", before, got)
}
}
func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "nothing to report"}
bs := bus.New()
b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker})
b.Start(context.Background())
// Confirm the subscription is live by triggering one think.
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil {
t.Fatalf("Publish: %v", err)
}
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
if err := b.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
before := thinker.count()
if err := bs.Publish(context.Background(), iot.DeviceUpdateSubject, iot.DeviceUpdate{
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}); err != nil {
t.Fatalf("Publish after stop: %v", err)
}
time.Sleep(40 * time.Millisecond)
if got := thinker.count(); got != before {
t.Fatalf("thinker calls grew after stop (bus not unsubscribed): %d -> %d", before, got)
}
}
// TestBackthink_EventPush_DedupSameEvent verifies the same-event dedup guard: a
// repeating identical event (same source + summary) is suppressed after the first
// push (防刷), while a genuinely different event still goes out. The duplicate is
// recorded as suppressed (route none), not delivered.
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
t.Parallel()
thinker := &mockThinker{text: "washing finished, clothes ready"}
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
Pusher: pusher,
Now: func() time.Time { return dayAt() },
})
ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev) // first: pushes
b.think(context.Background(), ev) // second: same event -> suppressed
b.think(context.Background(), ev) // third: same event -> suppressed
if got := pusher.count(); got != 1 {
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(recs) < 3 {
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
}
// Newest first: the two most recent records are the suppressed duplicates.
for i := 0; i < 2; i++ {
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
}
if !strings.Contains(recs[i].PushResult.Detail, "duplicate") {
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
}
}
// The third-most-recent record is the first (delivered) push.
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
}
// A genuinely different event resets the chain and goes out.
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
}})
b.think(context.Background(), ev2)
if got := pusher.count(); got != 2 {
t.Fatalf("pusher calls after a different event = %d, want 2", got)
}
// After the different event, the original identical event is no longer a
// consecutive duplicate, so it pushes again — the guard only dedups consecutively.
b.think(context.Background(), ev)
if got := pusher.count(); got != 3 {
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
}
}