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 用一份新的内存 DB 与给定 deps 构建 Backthink, // 并返回填好 DB 句柄的 deps,好让测试能检查 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 验证收紧后的主动推送默认值:15 分钟 // 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、 // 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。 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 策略一:公开平台上的低价值系统事件不会 // 唤醒一整轮思考——它被降级为仅登记一条记录。 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() // 高重要度(与主人相关)的系统事件仍会唤醒一轮思考—— // 降噪门只降级低价值噪声(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 }) } // TestBackthink_List_NewestFirst 断言 List 严格由新到旧,且单行列表的头部与完整 // 列表的头部是同一条记录。记录全部由测试同步写入,因此两次读取之间不存在并发 // 写入者(见下方注释)。 func TestBackthink_List_NewestFirst(t *testing.T) { t.Parallel() thinker := &mockThinker{text: "nothing to report"} b, _ := newBackthink(t, Config{}, Deps{Thinker: thinker}) // 三条可区分的记录,写入顺序即 sources 的顺序。 sources := []string{"first", "second", "third"} // 记录由测试同步写入(直接调用 think),刻意不启动心跳:后台 ticker 会在 // 两次 List 之间插入一条新记录,使「单行列表的头部 == 完整列表的头部」这个 // 断言变成跟调度赛跑。此前偶红时读到的正是「刚插入的更新的 id」。 for _, src := range sources { b.think(context.Background(), Trigger{Source: src, Summary: src, Import: ImportanceLow, Kind: TriggerHeartbeat}) } 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) } // 单行列表里的那一条必须与完整列表的头部是同一条最新 // 记录,且完整列表必须是严格的由新到旧。 if top[0].ID != all[0].ID { t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID) } if len(all) != len(sources) { t.Fatalf("len(all) = %d, want %d (no writer ran between the two reads)", len(all), len(sources)) } 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) } } // 最新在前不仅体现在 id 上,也体现在内容上:列表是写入顺序的逆序。 for i := range sources { want := sources[len(sources)-1-i] if all[i].Trigger != want { t.Fatalf("all[%d].Trigger = %q, want %q (newest first)", i, all[i].Trigger, want) } } } 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) } // 幂等。 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()) // 通过触发一次思考来确认订阅是活的(以它持久化的 // 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的 // 触发字符串不会掩盖或覆盖它)。 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 验证同一事件去重门: // 重复的相同事件(同源 + 同摘要)在首次推送后 // 被抑制(防刷),而真正不同的事件仍会发出。重复的那条 // 被记为已抑制(route none),而不是投递出去。 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) // 第一次:推送 b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制 b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制 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)) } // 最新的在前:最近的这两条就是被抑制的重复。 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) } } // 第三新的那条是首次(已投递的)推送。 if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient { t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult) } // 真正不同的事件会重置该链并发出。 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) } // 在出现不同事件之后,原先那个相同事件不再是 // 连续重复,因此它会再次推送——该门只去重连续重复。 b.think(context.Background(), ev) if got := pusher.count(); got != 3 { t.Fatalf("pusher calls after an intervening different event = %d, want 3", got) } }