package engine import ( "sync/atomic" "testing" "time" ) func TestActivityTracker_StartStop(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) defer tracker.Close() if tracker.IsBusy() { t.Error("should not be busy initially") } if tracker.Refcount() != 0 { t.Error("refcount should be 0") } tracker.Start(ActivityAPICall) if !tracker.IsBusy() { t.Error("should be busy after Start") } if tracker.Refcount() != 1 { t.Errorf("refcount = %d, want 1", tracker.Refcount()) } tracker.Stop(ActivityAPICall) if tracker.IsBusy() { t.Error("should not be busy after Stop") } } func TestActivityTracker_MultipleReasons(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) tracker.Start(ActivityToolExec) tracker.Start(ActivityToolExec) if tracker.Refcount() != 3 { t.Errorf("refcount = %d, want 3", tracker.Refcount()) } reasons := tracker.ActiveReasons() if reasons[ActivityAPICall] != 1 { t.Errorf("api_call count = %d, want 1", reasons[ActivityAPICall]) } if reasons[ActivityToolExec] != 2 { t.Errorf("tool_exec count = %d, want 2", reasons[ActivityToolExec]) } tracker.Stop(ActivityToolExec) reasons = tracker.ActiveReasons() if reasons[ActivityToolExec] != 1 { t.Errorf("tool_exec count after stop = %d, want 1", reasons[ActivityToolExec]) } tracker.Stop(ActivityToolExec) tracker.Stop(ActivityAPICall) if tracker.IsBusy() { t.Error("should not be busy after all stops") } } func TestActivityTracker_OnBusy(t *testing.T) { var busyCalled int32 cfg := &ActivityTrackerConfig{ HeartbeatInterval: 1 * time.Second, IdleDelay: 1 * time.Second, OnBusy: func() { atomic.AddInt32(&busyCalled, 1) }, } tracker := NewActivityTracker(cfg, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) time.Sleep(50 * time.Millisecond) // OnBusy 在 goroutine 中 if atomic.LoadInt32(&busyCalled) != 1 { t.Error("OnBusy should be called on 0→1 transition") } // 第二次 Start 不应再触发 OnBusy tracker.Start(ActivityToolExec) time.Sleep(50 * time.Millisecond) if atomic.LoadInt32(&busyCalled) != 1 { t.Error("OnBusy should only fire on 0→1, not 1→2") } } func TestActivityTracker_OnIdle(t *testing.T) { var idleCalled int32 cfg := &ActivityTrackerConfig{ HeartbeatInterval: 1 * time.Second, IdleDelay: 100 * time.Millisecond, // 短延迟便于测试 OnIdle: func(d time.Duration) { atomic.AddInt32(&idleCalled, 1) }, } tracker := NewActivityTracker(cfg, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) tracker.Stop(ActivityAPICall) // 等待 idle 延迟触发 time.Sleep(200 * time.Millisecond) if atomic.LoadInt32(&idleCalled) != 1 { t.Error("OnIdle should be called after idle delay") } } func TestActivityTracker_OnIdle_CancelledByNewActivity(t *testing.T) { var idleCalled int32 cfg := &ActivityTrackerConfig{ HeartbeatInterval: 1 * time.Second, IdleDelay: 200 * time.Millisecond, OnIdle: func(d time.Duration) { atomic.AddInt32(&idleCalled, 1) }, } tracker := NewActivityTracker(cfg, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) tracker.Stop(ActivityAPICall) // 在 idle 延迟内又开始新活动 time.Sleep(50 * time.Millisecond) tracker.Start(ActivityToolExec) // 等过原来的 idle 延迟 time.Sleep(250 * time.Millisecond) if atomic.LoadInt32(&idleCalled) != 0 { t.Error("OnIdle should NOT fire when new activity starts before delay") } tracker.Stop(ActivityToolExec) } func TestActivityTracker_OnHeartbeat(t *testing.T) { var heartbeatCount int32 cfg := &ActivityTrackerConfig{ HeartbeatInterval: 50 * time.Millisecond, IdleDelay: 1 * time.Second, OnHeartbeat: func(reasons map[ActivityReason]int, duration time.Duration) { atomic.AddInt32(&heartbeatCount, 1) }, } tracker := NewActivityTracker(cfg, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) // 等待至少 2 次心跳 time.Sleep(150 * time.Millisecond) tracker.Stop(ActivityAPICall) count := atomic.LoadInt32(&heartbeatCount) if count < 2 { t.Errorf("expected >=2 heartbeats, got %d", count) } } func TestActivityTracker_HeartbeatStopsOnIdle(t *testing.T) { var heartbeatCount int32 cfg := &ActivityTrackerConfig{ HeartbeatInterval: 50 * time.Millisecond, IdleDelay: 1 * time.Second, OnHeartbeat: func(reasons map[ActivityReason]int, duration time.Duration) { atomic.AddInt32(&heartbeatCount, 1) }, } tracker := NewActivityTracker(cfg, &NoopObserver{}) defer tracker.Close() tracker.Start(ActivityAPICall) time.Sleep(80 * time.Millisecond) countBefore := atomic.LoadInt32(&heartbeatCount) tracker.Stop(ActivityAPICall) // 等待但不应有新心跳 time.Sleep(150 * time.Millisecond) countAfter := atomic.LoadInt32(&heartbeatCount) // 可能还有一个正在 flight 的心跳,允许 +1 if countAfter > countBefore+1 { t.Errorf("heartbeat should stop: before=%d after=%d", countBefore, countAfter) } } func TestActivityTracker_BusySince(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) defer tracker.Close() // Not busy → zero value if !tracker.BusySince().IsZero() { t.Error("BusySince should be zero when not busy") } before := time.Now() tracker.Start(ActivityAPICall) after := time.Now() bs := tracker.BusySince() if bs.Before(before) || bs.After(after) { t.Error("BusySince should be between before and after Start") } } func TestActivityTracker_Close(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) tracker.Start(ActivityAPICall) tracker.Close() // Close 后 Start/Stop 不 panic tracker.Start(ActivityToolExec) tracker.Stop(ActivityToolExec) // 双重 Close 不 panic tracker.Close() } func TestActivityTracker_LastActivity(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) defer tracker.Close() before := time.Now() tracker.Start(ActivityAPICall) after := time.Now() la := tracker.LastActivity() if la.Before(before) || la.After(after) { t.Error("LastActivity should be updated on Start") } time.Sleep(10 * time.Millisecond) before2 := time.Now() tracker.Stop(ActivityAPICall) after2 := time.Now() la2 := tracker.LastActivity() if la2.Before(before2) || la2.After(after2) { t.Error("LastActivity should be updated on Stop") } } func TestActivityTracker_ObserverEvents(t *testing.T) { obs := &eventCollector{} cfg := &ActivityTrackerConfig{ HeartbeatInterval: 1 * time.Second, IdleDelay: 50 * time.Millisecond, } tracker := NewActivityTracker(cfg, obs) tracker.Start(ActivityAPICall) tracker.Stop(ActivityAPICall) time.Sleep(100 * time.Millisecond) // idle delay tracker.Close() // 应该有 engine_busy, engine_idle, activity_tracker_closed hasBusy := false hasIdle := false hasClosed := false for _, name := range obs.EventNames() { switch name { case "engine_busy": hasBusy = true case "engine_idle": hasIdle = true case "activity_tracker_closed": hasClosed = true } } if !hasBusy { t.Error("should emit engine_busy") } if !hasIdle { t.Error("should emit engine_idle") } if !hasClosed { t.Error("should emit activity_tracker_closed") } } func TestActivityTracker_StopBelowZero(t *testing.T) { tracker := NewActivityTracker(nil, &NoopObserver{}) defer tracker.Close() // Stop without Start should not panic or go negative tracker.Stop(ActivityAPICall) if tracker.Refcount() != 0 { t.Error("refcount should not go negative") } }