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)
}
}
}