4607ce6db4
Adds ScheduleConfig + NextFire (per-mode logic for off/interval/ daily/weekly) and the Scheduler struct + loop goroutine. The loop reads scan_schedule on boot and on Refresh signals, computes next-fire, sleeps via time.Timer, and calls TryStartScan when the timer fires. Skip-on-conflict logging happens inside tryFire when TryStartScan reports started=false with a non-stale in-flight row. Boot wiring: cmd/minstrel/main.go constructs the scheduler after coverEnricher and calls Start with the server's long-lived ctx. The scheduler is not yet plumbed into the HTTP handlers (the next task does that — handlers gain a scheduler field, admin schedule endpoints land). Tests cover the NextFire truth table (off / interval / daily today + tomorrow / weekly today-before-time + today-after-time + upcoming day) plus invalid configs and parseHHMM edge cases. Refresh is verified non-blocking via the buffered-channel + default pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
130 lines
3.6 KiB
Go
130 lines
3.6 KiB
Go
package library
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestNextFire_Off(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "off"}
|
|
if got := c.NextFire(time.Now()); !got.IsZero() {
|
|
t.Errorf("NextFire = %v, want zero time", got)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Interval(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "interval", IntervalHours: 6}
|
|
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
|
want := time.Date(2026, 5, 6, 20, 0, 0, 0, time.UTC)
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Daily_Tomorrow(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
|
now := time.Date(2026, 5, 6, 14, 0, 0, 0, time.UTC)
|
|
want := time.Date(2026, 5, 7, 3, 0, 0, 0, time.UTC)
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Daily_Today(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "daily", TimeOfDay: "03:00"}
|
|
now := time.Date(2026, 5, 6, 2, 0, 0, 0, time.UTC)
|
|
want := time.Date(2026, 5, 6, 3, 0, 0, 0, time.UTC)
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Weekly_UpcomingDay(t *testing.T) {
|
|
// Tuesday 2pm, schedule = Sunday (ISO 7) 03:00 → upcoming Sunday at 03:00.
|
|
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 7, TimeOfDay: "03:00"}
|
|
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC) // 2026-05-05 = Tuesday
|
|
want := time.Date(2026, 5, 10, 3, 0, 0, 0, time.UTC) // 2026-05-10 = Sunday
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Weekly_TodayBeforeTime(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"} // ISO 2 = Tuesday
|
|
now := time.Date(2026, 5, 5, 2, 0, 0, 0, time.UTC)
|
|
want := time.Date(2026, 5, 5, 3, 0, 0, 0, time.UTC)
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_Weekly_TodayAfterTime(t *testing.T) {
|
|
c := ScheduleConfig{Mode: "weekly", WeeklyDay: 2, TimeOfDay: "03:00"}
|
|
now := time.Date(2026, 5, 5, 14, 0, 0, 0, time.UTC)
|
|
want := time.Date(2026, 5, 12, 3, 0, 0, 0, time.UTC)
|
|
if got := c.NextFire(now); !got.Equal(want) {
|
|
t.Errorf("NextFire = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestNextFire_InvalidConfigs(t *testing.T) {
|
|
cases := []ScheduleConfig{
|
|
{Mode: "interval", IntervalHours: 0},
|
|
{Mode: "interval", IntervalHours: -1},
|
|
{Mode: "daily", TimeOfDay: "bad"},
|
|
{Mode: "daily", TimeOfDay: "25:00"},
|
|
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 0},
|
|
{Mode: "weekly", TimeOfDay: "03:00", WeeklyDay: 8},
|
|
{Mode: "unknown"},
|
|
}
|
|
now := time.Now()
|
|
for _, c := range cases {
|
|
if got := c.NextFire(now); !got.IsZero() {
|
|
t.Errorf("NextFire(%+v) = %v, want zero", c, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseHHMM(t *testing.T) {
|
|
cases := []struct {
|
|
in string
|
|
h, m int
|
|
wantOK bool
|
|
}{
|
|
{"03:00", 3, 0, true},
|
|
{"23:59", 23, 59, true},
|
|
{"00:00", 0, 0, true},
|
|
{"24:00", 0, 0, false},
|
|
{"23:60", 0, 0, false},
|
|
{"3:00", 0, 0, false},
|
|
{"03-00", 0, 0, false},
|
|
{"ab:cd", 0, 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
h, m, ok := parseHHMM(c.in)
|
|
if ok != c.wantOK || (ok && (h != c.h || m != c.m)) {
|
|
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)",
|
|
c.in, h, m, ok, c.h, c.m, c.wantOK)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScheduler_Refresh_NonBlocking(t *testing.T) {
|
|
s := &Scheduler{
|
|
refresh: make(chan struct{}, 1),
|
|
stop: make(chan struct{}),
|
|
}
|
|
s.Refresh()
|
|
done := make(chan struct{})
|
|
go func() {
|
|
s.Refresh()
|
|
s.Refresh()
|
|
close(done)
|
|
}()
|
|
select {
|
|
case <-done:
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Errorf("Refresh blocked when channel was full")
|
|
}
|
|
}
|