main stats_test.go
Eric Bower  ·  2026-08-16
  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 -> streak increments
 59	updateTaskStats(stats, "lint", 4100*time.Millisecond, 1)
 60	lintTask = stats.Tasks["lint"]
 61	if lintTask.FailureStreak != 2 {
 62		t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak)
 63	}
 64
 65	// Succeed -> streak resets to 0
 66	updateTaskStats(stats, "lint", 3900*time.Millisecond, 0)
 67	lintTask = stats.Tasks["lint"]
 68	if lintTask.FailureStreak != 0 {
 69		t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak)
 70	}
 71	if lintTask.LastStatus != "success" {
 72		t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus)
 73	}
 74}
 75
 76func TestStats_StorageAndPath(t *testing.T) {
 77	tempCache := t.TempDir()
 78	t.Setenv("XDG_CACHE_HOME", tempCache)
 79
 80	repo := "my-project"
 81	expectedPath := filepath.Join(tempCache, "pici", repo, "stats.json")
 82	path := getStatsPath(repo)
 83	if path != expectedPath {
 84		t.Errorf("expected stats path %s, got %s", expectedPath, path)
 85	}
 86
 87	// Test Save & Load
 88	stats := &RepoStats{
 89		Repo:              repo,
 90		LastRunAt:         time.Now().UTC().Format(time.RFC3339),
 91		LastOverallStatus: "success",
 92		RunsCount:         5,
 93		AvgWallDurationMs: 12000,
 94		Tasks: map[string]*TaskStats{
 95			"build": {
 96				AvgDurationMs:  6000,
 97				LastDurationMs: 6100,
 98				LastStatus:     "success",
 99				LastExitCode:   0,
100				SeenCount:      5,
101			},
102		},
103	}
104
105	if err := saveRepoStats(stats); err != nil {
106		t.Fatalf("failed to save stats: %v", err)
107	}
108
109	loaded, err := loadRepoStats(repo)
110	if err != nil {
111		t.Fatalf("failed to load stats: %v", err)
112	}
113	if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 {
114		t.Errorf("loaded stats mismatch: %+v", loaded)
115	}
116	if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 {
117		t.Errorf("loaded task stats mismatch: %+v", task)
118	}
119}
120
121func TestStats_OutcomeTransitions(t *testing.T) {
122	// 1. Fixed transition (failed in prev run, succeeded in current run)
123	t1 := &TaskStats{
124		LastStatus:   "failed",
125		LastExitCode: 1,
126	}
127	if trans := computeOutcomeTransition(t1, 0); trans != "fixed" {
128		t.Errorf("expected 'fixed', got '%s'", trans)
129	}
130
131	// 2. Still failing transition (failed in prev run, failed in current run)
132	if trans := computeOutcomeTransition(t1, 1); trans != "still_failing" {
133		t.Errorf("expected 'still_failing', got '%s'", trans)
134	}
135
136	// 3. New failure transition (succeeded in prev run, failed in current run)
137	t2 := &TaskStats{
138		LastStatus:   "success",
139		LastExitCode: 0,
140	}
141	if trans := computeOutcomeTransition(t2, 2); trans != "new_failure" {
142		t.Errorf("expected 'new_failure', got '%s'", trans)
143	}
144
145	// 4. Clean success (succeeded in prev run, succeeded in current run)
146	if trans := computeOutcomeTransition(t2, 0); trans != "" {
147		t.Errorf("expected '', got '%s'", trans)
148	}
149
150	// 5. First time seen (nil prev)
151	if trans := computeOutcomeTransition(nil, 0); trans != "" {
152		t.Errorf("expected '', got '%s'", trans)
153	}
154	if trans := computeOutcomeTransition(nil, 1); trans != "new_failure" {
155		t.Errorf("expected 'new_failure', got '%s'", trans)
156	}
157}