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()) }() // 启动轮(07.4.4)会先落一条 startup 记录,这里等真正的周期心跳记录。 eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == "heartbeat" { return true } } return false }) if got := thinker.count(); got < 1 { t.Fatalf("thinker calls = %d, want at least 1", got) } found := false recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } for _, r := range recs { if r.Trigger == "heartbeat" { found = true } } if !found { t.Fatalf("no heartbeat record in %d records", len(recs)) } } 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) } // 启动轮(07.4.4)同样会思考并推送,因此按触发源过滤出事件记录再断言。 var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == SubjectReminderTrigger { rec = r return true } } return false }) if got := thinker.count(); got < 1 { t.Fatalf("thinker calls = %d, want at least 1", got) } if got := pusher.count(); got < 1 { t.Fatalf("pusher calls = %d, want at least 1 (important + daytime)", got) } if !rec.ShouldPush { t.Fatalf("ShouldPush = false, want true") } if !rec.PushResult.Pushed || rec.PushResult.Route != RouteActiveClient { t.Fatalf("PushResult = %+v, want pushed active_client", rec.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) } var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == "iot.device_update" { rec = r return true } } return false }) if got := pusher.count(); got != 0 { t.Fatalf("pusher calls = %d, want 0 (asleep + night)", got) } if rec.ShouldPush { t.Fatalf("ShouldPush = true, want false") } if rec.PushResult.Route != RouteNone { t.Fatalf("PushResult.Route = %q, want %q", rec.PushResult.Route, RouteNone) } } 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()) }() // 先等启动轮(07.4.4)落库并记下基线,再发布低价值系统事件。 eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) return len(recs) >= 1 && recs[0].Trigger == "startup" }) before := thinker.count() if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "member joined"}); err != nil { t.Fatalf("Publish: %v", err) } var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == SubjectSystemEvent { rec = r return true } } return false }) if got := thinker.count(); got != before { t.Fatalf("thinker calls = %d, want %d (low-value system event downgraded)", got, before) } if rec.ShouldPush { t.Fatalf("ShouldPush = true for a downgraded system event") } if rec.PushResult.Route != RouteNone { t.Fatalf("PushResult.Route = %q, want %q", rec.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()) }() eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) return len(recs) >= 1 && recs[0].Trigger == "startup" }) before := thinker.count() 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() >= before+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 (identified by its // persisted record, so the 07.4.4 startup round and heartbeat ticks with their // own trigger strings cannot mask or overwrite it). if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil { t.Fatalf("Publish: %v", err) } eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) for _, r := range recs { if r.Trigger == SubjectReminderTrigger { return true } } return false }) 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) } }