package backthink import ( "context" "errors" "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) } } 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) } }