main stats_test.go
Eric Bower  ·  2026-09-15
  1package main
  2
  3import (
  4	"path/filepath"
  5	"testing"
  6	"time"
  7)
  8
  9func TestStats_EMACalculation(t *testing.T) {
 10	// Formula: EMA_new = 0.3 * actual + 0.7 * EMA_prev
 11	// If EMA_prev == 0, EMA_new = actual
 12	stats := &RepoStats{
 13		Repo: "testrepo",
 14		Tasks: map[string]*TaskStats{
 15			"fmt": {
 16				AvgDurationMs:  1000,
 17				LastDurationMs: 1000,
 18				LastStatus:     "success",
 19				LastExitCode:   0,
 20				SeenCount:      1,
 21			},
 22		},
 23	}
 24
 25	// Update with a new run of 2000ms
 26	updateTaskStats(stats, "fmt", 2000*time.Millisecond, 0)
 27	task := stats.Tasks["fmt"]
 28	if task.SeenCount != 2 {
 29		t.Errorf("expected seen count 2, got %d", task.SeenCount)
 30	}
 31	// 0.3 * 2000 + 0.7 * 1000 = 600 + 700 = 1300
 32	if task.AvgDurationMs != 1300 {
 33		t.Errorf("expected AvgDurationMs 1300, got %d", task.AvgDurationMs)
 34	}
 35	if task.LastDurationMs != 2000 {
 36		t.Errorf("expected LastDurationMs 2000, got %d", task.LastDurationMs)
 37	}
 38	if task.LastStatus != "success" {
 39		t.Errorf("expected LastStatus success, got %s", task.LastStatus)
 40	}
 41	if task.FailureStreak != 0 {
 42		t.Errorf("expected FailureStreak 0, got %d", task.FailureStreak)
 43	}
 44
 45	// Update with new task (not seen before)
 46	updateTaskStats(stats, "lint", 4000*time.Millisecond, 1)
 47	lintTask := stats.Tasks["lint"]
 48	if lintTask.AvgDurationMs != 4000 {
 49		t.Errorf("expected initial AvgDurationMs 4000, got %d", lintTask.AvgDurationMs)
 50	}
 51	if lintTask.LastStatus != "failed" {
 52		t.Errorf("expected LastStatus failed, got %s", lintTask.LastStatus)
 53	}
 54	if lintTask.FailureStreak != 1 {
 55		t.Errorf("expected FailureStreak 1, got %d", lintTask.FailureStreak)
 56	}
 57
 58	// Fail again with startedAt -> streak increments and LastStartedAt updated
 59	updateTaskStats(stats, "lint", 4100*time.Millisecond, 1, 1700000500)
 60	lintTask = stats.Tasks["lint"]
 61	if lintTask.FailureStreak != 2 {
 62		t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak)
 63	}
 64	if lintTask.LastStartedAt != 1700000500 {
 65		t.Errorf("expected LastStartedAt 1700000500, got %d", lintTask.LastStartedAt)
 66	}
 67
 68	// Succeed -> streak resets to 0
 69	updateTaskStats(stats, "lint", 3900*time.Millisecond, 0, 1700000600)
 70	lintTask = stats.Tasks["lint"]
 71	if lintTask.FailureStreak != 0 {
 72		t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak)
 73	}
 74	if lintTask.LastStatus != "success" {
 75		t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus)
 76	}
 77	if lintTask.LastStartedAt != 1700000600 {
 78		t.Errorf("expected LastStartedAt 1700000600, got %d", lintTask.LastStartedAt)
 79	}
 80}
 81
 82func TestStats_StorageAndPath(t *testing.T) {
 83	tempCache := t.TempDir()
 84	t.Setenv("XDG_CACHE_HOME", tempCache)
 85
 86	repo := "my-project"
 87	expectedLocalPath := filepath.Join(tempCache, "pici", repo, "local", "stats.json")
 88	pathLocal := getStatsPath(repo, "local")
 89	if pathLocal != expectedLocalPath {
 90		t.Errorf("expected stats path %s, got %s", expectedLocalPath, pathLocal)
 91	}
 92
 93	// Default event should be "local"
 94	pathDefault := getStatsPath(repo, "")
 95	if pathDefault != expectedLocalPath {
 96		t.Errorf("expected default stats path %s, got %s", expectedLocalPath, pathDefault)
 97	}
 98
 99	expectedTagPath := filepath.Join(tempCache, "pici", repo, "git.tag", "stats.json")
100	pathTag := getStatsPath(repo, "git.tag")
101	if pathTag != expectedTagPath {
102		t.Errorf("expected stats path %s, got %s", expectedTagPath, pathTag)
103	}
104
105	// Test Save & Load for "local" event
106	statsLocal := &RepoStats{
107		Repo:              repo,
108		Event:             "local",
109		LastRunAt:         time.Now().UTC().Format(time.RFC3339),
110		LastOverallStatus: "success",
111		RunsCount:         5,
112		AvgWallDurationMs: 12000,
113		Tasks: map[string]*TaskStats{
114			"build": {
115				AvgDurationMs:  6000,
116				LastDurationMs: 6100,
117				LastStatus:     "success",
118				LastExitCode:   0,
119				SeenCount:      5,
120				LastStartedAt:  1700000000,
121			},
122		},
123	}
124
125	if err := saveRepoStats(statsLocal); err != nil {
126		t.Fatalf("failed to save local stats: %v", err)
127	}
128
129	// Test Save & Load for "git.tag" event
130	statsTag := &RepoStats{
131		Repo:              repo,
132		Event:             "git.tag",
133		LastRunAt:         time.Now().UTC().Format(time.RFC3339),
134		LastOverallStatus: "failed",
135		RunsCount:         1,
136		AvgWallDurationMs: 60000,
137		Tasks: map[string]*TaskStats{
138			"deploy": {
139				AvgDurationMs:  30000,
140				LastDurationMs: 30000,
141				LastStatus:     "failed",
142				LastExitCode:   1,
143				SeenCount:      1,
144				LastStartedAt:  1700000100,
145			},
146		},
147	}
148
149	if err := saveRepoStats(statsTag); err != nil {
150		t.Fatalf("failed to save tag stats: %v", err)
151	}
152
153	// Verify local stats
154	loadedLocal, err := loadRepoStats(repo, "local")
155	if err != nil {
156		t.Fatalf("failed to load local stats: %v", err)
157	}
158	if loadedLocal.Repo != repo || loadedLocal.Event != "local" || loadedLocal.RunsCount != 5 || loadedLocal.AvgWallDurationMs != 12000 {
159		t.Errorf("loaded local stats mismatch: %+v", loadedLocal)
160	}
161	if task, ok := loadedLocal.Tasks["build"]; !ok || task.AvgDurationMs != 6000 || task.LastStartedAt != 1700000000 {
162		t.Errorf("loaded local task stats mismatch: %+v", task)
163	}
164	if _, ok := loadedLocal.Tasks["deploy"]; ok {
165		t.Errorf("expected deploy task NOT to be in local stats")
166	}
167
168	// Verify tag stats
169	loadedTag, err := loadRepoStats(repo, "git.tag")
170	if err != nil {
171		t.Fatalf("failed to load tag stats: %v", err)
172	}
173	if loadedTag.Repo != repo || loadedTag.Event != "git.tag" || loadedTag.RunsCount != 1 || loadedTag.AvgWallDurationMs != 60000 {
174		t.Errorf("loaded tag stats mismatch: %+v", loadedTag)
175	}
176	if task, ok := loadedTag.Tasks["deploy"]; !ok || task.AvgDurationMs != 30000 || task.LastStartedAt != 1700000100 {
177		t.Errorf("loaded tag task stats mismatch: %+v", task)
178	}
179	if _, ok := loadedTag.Tasks["build"]; ok {
180		t.Errorf("expected build task NOT to be in tag stats")
181	}
182}
183
184func TestStats_OutcomeTransitions(t *testing.T) {
185	// 1. Fixed transition (failed in prev run, succeeded in current run)
186	t1 := &TaskStats{
187		LastStatus:   "failed",
188		LastExitCode: 1,
189	}
190	if trans := computeOutcomeTransition(t1, 0); trans != "fixed" {
191		t.Errorf("expected 'fixed', got '%s'", trans)
192	}
193
194	// 2. Still failing transition (failed in prev run, failed in current run)
195	if trans := computeOutcomeTransition(t1, 1); trans != "still_failing" {
196		t.Errorf("expected 'still_failing', got '%s'", trans)
197	}
198
199	// 3. New failure transition (succeeded in prev run, failed in current run)
200	t2 := &TaskStats{
201		LastStatus:   "success",
202		LastExitCode: 0,
203	}
204	if trans := computeOutcomeTransition(t2, 2); trans != "new_failure" {
205		t.Errorf("expected 'new_failure', got '%s'", trans)
206	}
207
208	// 4. Clean success (succeeded in prev run, succeeded in current run)
209	if trans := computeOutcomeTransition(t2, 0); trans != "" {
210		t.Errorf("expected '', got '%s'", trans)
211	}
212
213	// 5. First time seen (nil prev)
214	if trans := computeOutcomeTransition(nil, 0); trans != "" {
215		t.Errorf("expected '', got '%s'", trans)
216	}
217	if trans := computeOutcomeTransition(nil, 1); trans != "new_failure" {
218		t.Errorf("expected 'new_failure', got '%s'", trans)
219	}
220}
221
222func TestStats_PruneStaleTasks(t *testing.T) {
223	stats := &RepoStats{
224		Repo: "testrepo",
225		Tasks: map[string]*TaskStats{
226			"-d":   {AvgDurationMs: 100},
227			"fmt":  {AvgDurationMs: 500},
228			"lint": {AvgDurationMs: 1200},
229			"test": {AvgDurationMs: 3000},
230		},
231	}
232
233	// Current run only ran fmt, lint, test (task "-d" was removed)
234	currentTasks := map[string]bool{
235		"fmt":  true,
236		"lint": true,
237		"test": true,
238	}
239
240	pruneStaleTasks(stats, currentTasks)
241
242	if _, exists := stats.Tasks["-d"]; exists {
243		t.Errorf("expected '-d' to be pruned from stats.Tasks")
244	}
245	if len(stats.Tasks) != 3 {
246		t.Errorf("expected 3 tasks remaining, got %d", len(stats.Tasks))
247	}
248	for _, name := range []string{"fmt", "lint", "test"} {
249		if _, exists := stats.Tasks[name]; !exists {
250			t.Errorf("expected task %s to remain in stats.Tasks", name)
251		}
252	}
253}