c28c4f1419
Extend schedule tasks to dispatch KOL subscription prompts in addition to prompt rules, add daily/weekly scheduling with fixed or random time windows, and track dispatch outcomes (last run, status, consecutive failures). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
5.0 KiB
Go
165 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|