package runner import ( "os" "path/filepath" "strconv" "strings" "testing" "gitea.mixdep.ru/mix/gosentry/src/domain" ) // writeTestLog writes a minimal log file in the format writeRunLog produces. // Pass durationMS < 0 to omit the duration line (legacy log simulation). // When jobID > 0 a job_id header line is included. func writeTestLog(t *testing.T, dir, filename, state string, durationMS int64, jobID int) { t.Helper() var content strings.Builder if jobID > 0 { content.WriteString("job_id: ") content.WriteString(strconv.Itoa(jobID)) content.WriteString("\n") } if durationMS >= 0 { content.WriteString("state: " + state + "\nduration: " + strconv.FormatInt(durationMS, 10) + "\n\n") } else { content.WriteString("state: " + state + "\n\n") } if err := os.WriteFile(filepath.Join(dir, filename), []byte(content.String()), 0o644); err != nil { t.Fatal(err) } } func TestSeedStatsBasic(t *testing.T) { dir := t.TempDir() job := domain.Job{ID: 1, Name: "Build"} name := sanitizeFileName(job.Name) writeTestLog(t, dir, "20260601-100000_"+name+".log", "OK", 200, job.ID) writeTestLog(t, dir, "20260601-110000_"+name+".log", "Failed", 400, job.ID) writeTestLog(t, dir, "20260601-120000_"+name+".log", "OK", 600, job.ID) result := SeedStats(dir, []domain.Job{job}, 0) s, ok := result[job.ID] if !ok { t.Fatal("expected stats for job 1") } if s.RunCount != 3 { t.Errorf("RunCount = %d, want 3", s.RunCount) } if s.FailCount != 1 { t.Errorf("FailCount = %d, want 1", s.FailCount) } if s.LastDurationMS != 600 { t.Errorf("LastDurationMS = %d, want 600", s.LastDurationMS) } if s.MaxDurationMS != 600 { t.Errorf("MaxDurationMS = %d, want 600", s.MaxDurationMS) } // avg = (200+400+600)/3 = 400 if s.AvgDurationMS != 400 { t.Errorf("AvgDurationMS = %d, want 400", s.AvgDurationMS) } } // TestSeedStatsDurationLessLegacyLog verifies that a log without a duration // line still contributes to RunCount/FailCount but is excluded from duration // aggregates, so a missing duration cannot masquerade as a 0 ms run. func TestSeedStatsDurationLessLegacyLog(t *testing.T) { dir := t.TempDir() job := domain.Job{ID: 2, Name: "Deploy"} name := sanitizeFileName(job.Name) // Legacy log (no duration line). writeTestLog(t, dir, "20260601-080000_"+name+".log", "OK", -1, job.ID) // Modern log with duration. writeTestLog(t, dir, "20260601-090000_"+name+".log", "OK", 300, job.ID) result := SeedStats(dir, []domain.Job{job}, 0) s := result[job.ID] if s.RunCount != 2 { t.Errorf("RunCount = %d, want 2", s.RunCount) } if s.FailCount != 0 { t.Errorf("FailCount = %d, want 0", s.FailCount) } // Only the modern log has a duration — avg/last/max must reflect that single entry. if s.LastDurationMS != 300 { t.Errorf("LastDurationMS = %d, want 300", s.LastDurationMS) } if s.AvgDurationMS != 300 { t.Errorf("AvgDurationMS = %d, want 300", s.AvgDurationMS) } } // TestSeedStatsMaxFilesHonoured verifies that only the newest N logs are // parsed when maxFiles is positive. func TestSeedStatsMaxFilesHonoured(t *testing.T) { dir := t.TempDir() job := domain.Job{ID: 3, Name: "Cleanup"} name := sanitizeFileName(job.Name) // Write 3 logs; only the 2 newest should be counted (maxFiles=2). writeTestLog(t, dir, "20260601-060000_"+name+".log", "OK", 100, job.ID) writeTestLog(t, dir, "20260601-070000_"+name+".log", "OK", 200, job.ID) writeTestLog(t, dir, "20260601-080000_"+name+".log", "Failed", 300, job.ID) result := SeedStats(dir, []domain.Job{job}, 2) s := result[job.ID] if s.RunCount != 2 { t.Errorf("RunCount = %d, want 2 (maxFiles=2)", s.RunCount) } if s.FailCount != 1 { t.Errorf("FailCount = %d, want 1", s.FailCount) } } // TestSeedStatsMissingDir yields an empty map and does not panic. func TestSeedStatsMissingDir(t *testing.T) { result := SeedStats(filepath.Join(t.TempDir(), "no-such-dir"), []domain.Job{{ID: 1, Name: "J"}}, 0) if len(result) != 0 { t.Errorf("expected empty result for missing dir, got %v", result) } } // TestSeedStatsUnknownJobProducesNoEntry verifies that log files not matching // any known job are silently ignored. func TestSeedStatsUnknownJobProducesNoEntry(t *testing.T) { dir := t.TempDir() writeTestLog(t, dir, "20260601-100000_UnknownJob.log", "OK", 100, 0) result := SeedStats(dir, []domain.Job{{ID: 1, Name: "KnownJob"}}, 0) if _, ok := result[1]; ok { t.Error("expected no entry for a job with no matching log files") } } // TestSeedStatsMatchesByJobID verifies that logs are associated by job_id even // when sanitized job names would collide. func TestSeedStatsMatchesByJobID(t *testing.T) { dir := t.TempDir() jobA := domain.Job{ID: 1, Name: "foo@bar"} jobB := domain.Job{ID: 2, Name: "foo bar"} colliding := sanitizeFileName(jobA.Name) if colliding != sanitizeFileName(jobB.Name) { t.Fatalf("test setup: expected colliding sanitized names, got %q and %q", sanitizeFileName(jobA.Name), sanitizeFileName(jobB.Name)) } writeTestLog(t, dir, "20260601-100000_"+colliding+".log", "OK", 100, jobA.ID) writeTestLog(t, dir, "20260601-110000_"+colliding+".log", "Failed", 200, jobB.ID) result := SeedStats(dir, []domain.Job{jobA, jobB}, 0) if result[jobA.ID].RunCount != 1 || result[jobA.ID].LastDurationMS != 100 { t.Errorf("job A stats = %+v, want one OK run at 100 ms", result[jobA.ID]) } if result[jobB.ID].RunCount != 1 || result[jobB.ID].FailCount != 1 || result[jobB.ID].LastDurationMS != 200 { t.Errorf("job B stats = %+v, want one Failed run at 200 ms", result[jobB.ID]) } }