package stats_test import ( "testing" "time" "sourcecraft.dev/bigbes/lethe/internal/domain/stats" ) // utcMidnight returns the unix seconds at midnight UTC for the given reference // time. Mirrors the bucketing rule: time.Unix(now, 0).UTC().Truncate(24h). func utcMidnight(t time.Time) int64 { return t.UTC().Truncate(24 * time.Hour).Unix() } // ──────────────────────────────────────────────────────────── // DailyWindow // ──────────────────────────────────────────────────────────── func TestDailyWindow_Length(t *testing.T) { now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix() for _, days := range []int{0, 7, 30, 90} { got := stats.DailyWindow(now, days) if len(got) != days+1 { t.Errorf("DailyWindow(days=%d): len=%d; want %d", days, len(got), days+1) } } } func TestDailyWindow_OldestFirst(t *testing.T) { now := time.Date(2025, 6, 15, 14, 30, 0, 0, time.UTC).Unix() got := stats.DailyWindow(now, 7) for i := 1; i < len(got); i++ { if got[i] <= got[i-1] { t.Errorf("DailyWindow not strictly ascending at index %d: %d <= %d", i, got[i], got[i-1]) } } } func TestDailyWindow_LastEntryIsToday(t *testing.T) { ref := time.Date(2025, 6, 15, 23, 59, 59, 0, time.UTC) got := stats.DailyWindow(ref.Unix(), 7) wantToday := utcMidnight(ref) last := got[len(got)-1] if last != wantToday { t.Errorf("DailyWindow last entry: got %d; want %d (%v vs %v)", last, wantToday, time.Unix(last, 0).UTC(), time.Unix(wantToday, 0).UTC()) } } func TestDailyWindow_FirstEntryIsDaysMidnightsBack(t *testing.T) { ref := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) days := 7 got := stats.DailyWindow(ref.Unix(), days) wantFirst := utcMidnight(ref.AddDate(0, 0, -days)) if got[0] != wantFirst { t.Errorf("DailyWindow first entry: got %d; want %d (%v vs %v)", got[0], wantFirst, time.Unix(got[0], 0).UTC(), time.Unix(wantFirst, 0).UTC()) } } func TestDailyWindow_EachEntryAtMidnightUTC(t *testing.T) { ref := time.Date(2025, 3, 10, 18, 45, 0, 0, time.UTC) got := stats.DailyWindow(ref.Unix(), 30) // Each consecutive pair must be exactly 86400 seconds apart (one UTC day). for i := 1; i < len(got); i++ { diff := got[i] - got[i-1] if diff != 86400 { t.Errorf("DailyWindow entry spacing at index %d: got %d; want 86400", i, diff) } } } func TestDailyWindow_DeterministicAtMidnightBoundary(t *testing.T) { // Exactly at midnight UTC — today's slot should still be that midnight. ref := time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC) got := stats.DailyWindow(ref.Unix(), 3) last := got[len(got)-1] if last != ref.Unix() { t.Errorf("midnight boundary: last=%d; want %d", last, ref.Unix()) } } // ──────────────────────────────────────────────────────────── // FillDaily // ──────────────────────────────────────────────────────────── func TestFillDaily_MissingDaysGetEmptyMap(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() window := stats.DailyWindow(now, 3) // 4 entries got := stats.FillDaily(window, nil) if len(got) != 4 { t.Fatalf("FillDaily(nil rows): len=%d; want 4", len(got)) } for _, b := range got { if b.PerTool == nil { t.Errorf("missing slot has nil PerTool; want empty map (got DateUnix=%d)", b.DateUnix) } } } func TestFillDaily_RowsMatchedToCorrectSlot(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() window := stats.DailyWindow(now, 3) // Seed one row at the second slot (yesterday-2). target := window[1] rows := []stats.DailyBucket{ {DateUnix: target, PerTool: map[string]int64{"cc": 5}}, } got := stats.FillDaily(window, rows) if len(got) != 4 { t.Fatalf("len=%d; want 4", len(got)) } for _, b := range got { if b.DateUnix == target { if b.PerTool["cc"] != 5 { t.Errorf("slot %d: cc=%d; want 5", target, b.PerTool["cc"]) } } else { if len(b.PerTool) != 0 { t.Errorf("slot %d: PerTool=%v; want empty", b.DateUnix, b.PerTool) } } } } func TestFillDaily_OutsideWindowRowsDropped(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() window := stats.DailyWindow(now, 2) // Row 1000 days ago — outside the 3-slot window. outside := utcMidnight(time.Unix(now, 0).UTC().AddDate(0, 0, -1000)) rows := []stats.DailyBucket{ {DateUnix: outside, PerTool: map[string]int64{"cc": 99}}, } got := stats.FillDaily(window, rows) if len(got) != 3 { t.Fatalf("len=%d; want 3", len(got)) } for _, b := range got { if len(b.PerTool) != 0 { t.Errorf("slot %d should be empty; got %v", b.DateUnix, b.PerTool) } } } func TestFillDaily_PreservesWindowOrder(t *testing.T) { now := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC).Unix() window := stats.DailyWindow(now, 6) got := stats.FillDaily(window, nil) for i := 1; i < len(got); i++ { if got[i].DateUnix <= got[i-1].DateUnix { t.Errorf("FillDaily order broken at index %d: %d <= %d", i, got[i].DateUnix, got[i-1].DateUnix) } } } // ──────────────────────────────────────────────────────────── // HeatmapWindow // ──────────────────────────────────────────────────────────── func TestHeatmapWindow_Length(t *testing.T) { now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix() got := stats.HeatmapWindow(now) if len(got) != 84 { t.Errorf("HeatmapWindow: len=%d; want 84", len(got)) } } func TestHeatmapWindow_LastEntryIsToday(t *testing.T) { ref := time.Date(2025, 6, 15, 22, 0, 0, 0, time.UTC) got := stats.HeatmapWindow(ref.Unix()) wantToday := utcMidnight(ref) last := got[len(got)-1] if last != wantToday { t.Errorf("HeatmapWindow last: got %d; want %d", last, wantToday) } } func TestHeatmapWindow_OldestFirst(t *testing.T) { now := time.Date(2025, 6, 15, 10, 0, 0, 0, time.UTC).Unix() got := stats.HeatmapWindow(now) for i := 1; i < len(got); i++ { if got[i] <= got[i-1] { t.Errorf("HeatmapWindow not ascending at index %d: %d <= %d", i, got[i], got[i-1]) } } } func TestHeatmapWindow_FixedAt84Cells(t *testing.T) { // Different "now" values — always exactly 84 cells. nows := []time.Time{ time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), time.Date(2025, 12, 31, 23, 59, 59, 0, time.UTC), time.Date(2024, 2, 29, 12, 0, 0, 0, time.UTC), // leap year } for _, n := range nows { got := stats.HeatmapWindow(n.Unix()) if len(got) != 84 { t.Errorf("HeatmapWindow(%v): len=%d; want 84", n, len(got)) } } } // ──────────────────────────────────────────────────────────── // HourWindow // ──────────────────────────────────────────────────────────── func TestHourWindow_Length(t *testing.T) { got := stats.HourWindow() if len(got) != 24 { t.Errorf("HourWindow: len=%d; want 24", len(got)) } } func TestHourWindow_AllZero(t *testing.T) { got := stats.HourWindow() for _, b := range got { if b.Count != 0 { t.Errorf("HourWindow[%d].Count = %d; want 0", b.Hour, b.Count) } } } func TestHourWindow_HoursAreContiguous(t *testing.T) { got := stats.HourWindow() for i, b := range got { if b.Hour != i { t.Errorf("HourWindow[%d].Hour = %d; want %d", i, b.Hour, i) } } }