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}