package schedule import ( "testing" "time" ) func TestComputeNextRandomRunStaysInsideWindowAndIsStable(t *testing.T) { loc := time.Local now := time.Date(2026, 5, 18, 0, 30, 0, 0, loc) // Monday policy := RandomPolicy{ Days: []time.Weekday{time.Monday}, TimeMode: TimeModeRandomWindow, WindowStart: time.Hour, WindowEnd: 5 * time.Hour, Seed: 42, } first, err := ComputeNextRandomRun(policy, nil, nil, now) if err != nil { t.Fatalf("ComputeNextRandomRun returned error: %v", err) } second, err := ComputeNextRandomRun(policy, nil, nil, now) if err != nil { t.Fatalf("ComputeNextRandomRun returned error on second call: %v", err) } if first == nil || second == nil { t.Fatal("expected next run") } if !first.Equal(*second) { t.Fatalf("expected stable run for same seed/day, got %v and %v", first, second) } if first.Weekday() != time.Monday { t.Fatalf("expected Monday run, got %s", first.Weekday()) } clock := time.Duration(first.Hour())*time.Hour + time.Duration(first.Minute())*time.Minute + time.Duration(first.Second())*time.Second if clock < policy.WindowStart || clock >= policy.WindowEnd { t.Fatalf("run time %s outside window [%s, %s)", clock, policy.WindowStart, policy.WindowEnd) } } func TestComputeNextRandomRunSkipsEqualNotBeforeBoundary(t *testing.T) { loc := time.Local day := time.Date(2026, 5, 18, 0, 0, 0, 0, loc) // Monday offset := stableWindowOffset(7, day, time.Hour) runAt := day.Add(time.Hour + offset) policy := RandomPolicy{ Days: []time.Weekday{time.Monday}, TimeMode: TimeModeRandomWindow, WindowStart: time.Hour, WindowEnd: 2 * time.Hour, Seed: 7, } next, err := ComputeNextRandomRun(policy, nil, nil, runAt) if err != nil { t.Fatalf("ComputeNextRandomRun returned error: %v", err) } if next == nil { t.Fatal("expected next weekly run") } if !next.After(runAt) { t.Fatalf("expected equal boundary to roll forward after %v, got %v", runAt, next) } if next.Weekday() != time.Monday { t.Fatalf("expected next allowed weekday Monday, got %s", next.Weekday()) } if next.Year() == runAt.Year() && next.YearDay() == runAt.YearDay() { t.Fatalf("expected equal boundary to skip same day, got %v", next) } } func TestComputeNextRandomRunHonorsStartAtAndEndAt(t *testing.T) { loc := time.Local now := time.Date(2026, 5, 18, 0, 0, 0, 0, loc) // Monday startAt := time.Date(2026, 5, 20, 0, 0, 0, 0, loc) // Wednesday endAt := time.Date(2026, 5, 20, 5, 0, 0, 0, loc) policy := RandomPolicy{ Days: []time.Weekday{time.Monday, time.Wednesday}, TimeMode: TimeModeRandomWindow, WindowStart: time.Hour, WindowEnd: 2 * time.Hour, Seed: 99, } next, err := ComputeNextRandomRun(policy, &startAt, &endAt, now) if err != nil { t.Fatalf("ComputeNextRandomRun returned error: %v", err) } if next == nil { t.Fatal("expected run inside endAt") } if next.Weekday() != time.Wednesday { t.Fatalf("expected Wednesday after startAt, got %s", next.Weekday()) } if next.Before(startAt) || next.After(endAt) { t.Fatalf("run %v outside [%v, %v]", next, startAt, endAt) } endBeforeNextWindow := time.Date(2026, 5, 20, 0, 30, 0, 0, loc) next, err = ComputeNextRandomRun(policy, &startAt, &endBeforeNextWindow, now) if err != nil { t.Fatalf("ComputeNextRandomRun returned error with endAt: %v", err) } if next != nil { t.Fatalf("expected nil when next run exceeds endAt, got %v", next) } } func TestComputeNextRandomRunRejectsInvalidPolicy(t *testing.T) { _, err := ComputeNextRandomRun(RandomPolicy{}, nil, nil, time.Now()) if err == nil { t.Fatal("expected missing days error") } _, err = ComputeNextRandomRun(RandomPolicy{ Days: []time.Weekday{time.Monday}, TimeMode: TimeModeRandomWindow, WindowStart: 2 * time.Hour, WindowEnd: time.Hour, }, nil, nil, time.Now()) if err == nil { t.Fatal("expected invalid window error") } } func TestComputeNextRandomRunReturnsNilAfterSearchHorizon(t *testing.T) { loc := time.Local now := time.Date(2026, 5, 18, 23, 0, 0, 0, loc) // Monday after the random window policy := RandomPolicy{ Days: []time.Weekday{time.Monday}, TimeMode: TimeModeRandomWindow, WindowStart: time.Hour, WindowEnd: 2 * time.Hour, Seed: 1, } next, err := computeNextRandomRun(policy, nil, nil, now, 0) if err != nil { t.Fatalf("computeNextRandomRun returned error: %v", err) } if next != nil { t.Fatalf("expected nil after exhausting search horizon, got %v", next) } } func TestStableWindowOffsetIsDeterministicAndBounded(t *testing.T) { day := time.Date(2026, 5, 18, 0, 0, 0, 0, time.Local) width := 4 * time.Hour first := stableWindowOffset(123, day, width) second := stableWindowOffset(123, day, width) if first != second { t.Fatalf("expected deterministic offset, got %s and %s", first, second) } if first < 0 || first >= width { t.Fatalf("offset %s outside width %s", first, width) } if got := stableWindowOffset(123, day, 0); got != 0 { t.Fatalf("expected zero width offset to be 0, got %s", got) } }