package scheduler_test import ( "context" "errors" "io" "log/slog" "slices" "sync" "sync/atomic" "testing" "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) const zone = "America/Chicago" // chicago is the household zone of every test. var chicago = mustZone(zone) func mustZone(name string) *time.Location { loc, err := household.LoadZone(name) if err != nil { panic(err) } return loc } // at returns the instant clocks in Chicago read the given time. func at(year int, month time.Month, day, hour, minute int) time.Time { return time.Date(year, month, day, hour, minute, 0, 0, chicago) } // fakeClock is a clock whose time moves only when the test moves it. type fakeClock struct { mu sync.Mutex now time.Time timers []fakeTimer // waits receives one value per After call, so the test knows the // scheduler finished a pass and is waiting. waits chan struct{} } type fakeTimer struct { deadline time.Time ch chan time.Time } func newFakeClock(now time.Time) *fakeClock { return &fakeClock{now: now, waits: make(chan struct{}, 100)} } func (c *fakeClock) Now() time.Time { c.mu.Lock() defer c.mu.Unlock() return c.now } // After returns a timer that fires d from now. The scheduler waits on only // its latest timer, so this one replaces any still pending: a timer the // scheduler gave up on when a wake came first never fires. func (c *fakeClock) After(d time.Duration) <-chan time.Time { c.mu.Lock() ch := make(chan time.Time, 1) c.timers = []fakeTimer{{deadline: c.now.Add(d), ch: ch}} c.mu.Unlock() c.waits <- struct{}{} return ch } // set moves the clock to t and fires the timers due by then, reporting // whether any fired. func (c *fakeClock) set(t time.Time) bool { c.mu.Lock() defer c.mu.Unlock() c.now = t fired := false kept := c.timers[:0] for _, tm := range c.timers { if tm.deadline.After(t) { kept = append(kept, tm) continue } tm.ch <- t fired = true } c.timers = kept return fired } // nextDeadline returns the earliest pending timer deadline. func (c *fakeClock) nextDeadline() (time.Time, bool) { c.mu.Lock() defer c.mu.Unlock() var next time.Time for _, tm := range c.timers { if next.IsZero() || tm.deadline.Before(next) { next = tm.deadline } } return next, !next.IsZero() } // recorder records the periods a schedule ran. type recorder struct { mu sync.Mutex runs []scheduler.Period err error } func (r *recorder) run(_ context.Context, p scheduler.Period) error { r.mu.Lock() defer r.mu.Unlock() r.runs = append(r.runs, p) return r.err } func (r *recorder) periods() []scheduler.Period { r.mu.Lock() defer r.mu.Unlock() return slices.Clone(r.runs) } func (r *recorder) keys() []string { var keys []string for _, p := range r.periods() { keys = append(keys, p.Key) } return keys } // env is a migrated database with household settings in Chicago. type env struct { t *testing.T st *store.Store settings *household.SettingsService // sched is the scheduler start last started, which wake wakes. sched atomic.Pointer[scheduler.Scheduler] } // wake wakes the scheduler start last started, as the services' wake does // in the server; before the first start it does nothing. func (e *env) wake() { if s := e.sched.Load(); s != nil { s.Wake() } } func newEnv(t *testing.T) *env { t.Helper() ctx := context.Background() st, err := store.Open(ctx, t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { if err := st.Close(); err != nil { t.Error(err) } }) if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { t.Fatal(err) } if err := household.SeedSettings(ctx, st, zone); err != nil { t.Fatal(err) } return &env{t: t, st: st, settings: household.NewSettingsService(st)} } // update changes the household settings. func (e *env) update(patch household.SettingsPatch) { e.t.Helper() if _, err := e.settings.Update(context.Background(), patch, "member"); err != nil { e.t.Fatal(err) } } // claimed returns the period keys schedule_runs holds for name. func (e *env) claimed(name string) []string { e.t.Helper() rows, err := e.st.Reader.Query(`SELECT period_key FROM schedule_runs WHERE schedule = ? ORDER BY period_key`, name) if err != nil { e.t.Fatal(err) } defer rows.Close() var keys []string for rows.Next() { var k string if err := rows.Scan(&k); err != nil { e.t.Fatal(err) } keys = append(keys, k) } if err := rows.Err(); err != nil { e.t.Fatal(err) } return keys } // running is a started scheduler on a fake clock. type running struct { t *testing.T clock *fakeClock stop func() } // start starts a scheduler on e at now with the schedules, and stops it // when the test ends. It returns once the first pass, the catch-up, is done. func (e *env) start(now time.Time, schedules ...scheduler.Schedule) *running { e.t.Helper() s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { e.t.Fatal(err) } clock := newFakeClock(now) scheduler.SetClock(s, clock.Now, clock.After) for _, sch := range schedules { if err := s.Register(sch); err != nil { e.t.Fatal(err) } } ctx, cancel := context.WithCancel(context.Background()) if err := s.Start(ctx); err != nil { cancel() e.t.Fatal(err) } e.sched.Store(s) var once sync.Once stop := func() { once.Do(func() { cancel() s.Wait() }) } e.t.Cleanup(stop) r := &running{t: e.t, clock: clock, stop: stop} r.idle() return r } // idle waits until the scheduler finished a pass and is waiting. func (r *running) idle() { r.t.Helper() select { case <-r.clock.waits: case <-time.After(10 * time.Second): r.t.Fatal("scheduler did not finish its pass") } } // runUntil moves the clock to to, stopping at every instant the scheduler // asked to wake at on the way, as a real clock would. func (r *running) runUntil(to time.Time) { r.t.Helper() for { next, ok := r.clock.nextDeadline() if !ok || next.After(to) { if r.clock.set(to) { r.idle() } return } if r.clock.set(next) { r.idle() } } } // changed waits for the scheduler to apply a settings change, or to look // again after a wake. func (r *running) changed() { r.t.Helper() r.idle() } func reminder(rec *recorder) scheduler.Schedule { return scheduler.Schedule{ Name: scheduler.DayOfReminder, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReminderTime) }, Run: rec.run, } } func suggestions(rec *recorder) scheduler.Schedule { return scheduler.Schedule{ Name: scheduler.SuggestionsWeekly, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Weekly(time.Weekday(s.SuggestionsDow), s.SuggestionsTime) }, Run: rec.run, } } func planDraft(rec *recorder) scheduler.Schedule { return scheduler.Schedule{ Name: scheduler.PlanDraft, Rule: func(s household.Settings) scheduler.Rule { return scheduler.WeeklyForNextWeek(time.Weekday(s.PlanDraftDow), s.PlanDraftTime) }, Run: rec.run, } } func ptr[T any](v T) *T { return &v } func wantKeys(t *testing.T, what string, got, want []string) { t.Helper() if !slices.Equal(got, want) { t.Fatalf("%s = %q, want %q", what, got, want) } } func TestFiresOncePerPeriod(t *testing.T) { e := newEnv(t) var rec recorder // Seeded reminder time is 16:30; boot just after it on Oct 6 runs it. r := e.start(at(2026, 10, 6, 16, 31), reminder(&rec)) wantKeys(t, "after boot", rec.keys(), []string{"2026-10-06"}) r.runUntil(at(2026, 10, 7, 16, 29)) wantKeys(t, "before the fire time", rec.keys(), []string{"2026-10-06"}) r.runUntil(at(2026, 10, 9, 23, 59)) wantKeys(t, "after three days", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-08", "2026-10-09"}) for i, p := range rec.periods()[1:] { want := at(2026, 10, 7+i, 16, 30) if !p.FireAt.Equal(want) { t.Errorf("run %s fired at %s, want %s", p.Key, p.FireAt, want) } if p.FireAt.Location().String() != zone { t.Errorf("run %s fired in zone %s, want %s", p.Key, p.FireAt.Location(), zone) } } wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys()) } func TestRestartMidPeriodDoesNotRerun(t *testing.T) { e := newEnv(t) var first recorder r := e.start(at(2026, 10, 7, 12, 0), suggestions(&first)) // Wednesday // The seeded Sunday 08:00 of Oct 4 is days ago: missed, not run. wantKeys(t, "first boot", first.keys(), nil) r.runUntil(at(2026, 10, 11, 9, 0)) wantKeys(t, "first process", first.keys(), []string{"2026-W41"}) r.stop() // The restart is an hour after the Sunday 08:00 fire, mid-period. var second recorder r2 := e.start(at(2026, 10, 11, 9, 0), suggestions(&second)) wantKeys(t, "after restart", second.keys(), nil) r2.runUntil(at(2026, 10, 18, 7, 59)) wantKeys(t, "rest of the period", second.keys(), nil) r2.runUntil(at(2026, 10, 18, 8, 0)) wantKeys(t, "next period", second.keys(), []string{"2026-W42"}) wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"}) } func TestCatchUpWithin24Hours(t *testing.T) { e := newEnv(t) var rec recorder // Sunday Oct 11 08:00 passed 23h59m before boot. e.start(at(2026, 10, 12, 7, 59), suggestions(&rec)) wantKeys(t, "catch-up", rec.keys(), []string{"2026-W41"}) if p := rec.periods()[0]; !p.FireAt.Equal(at(2026, 10, 11, 8, 0)) { t.Errorf("caught-up period fired at %s, want its fire time", p.FireAt) } } func TestNoCatchUpBeyond24Hours(t *testing.T) { e := newEnv(t) var rec recorder // Sunday Oct 11 08:00 passed 24h01m before boot. r := e.start(at(2026, 10, 12, 8, 1), suggestions(&rec)) wantKeys(t, "boot", rec.keys(), nil) // The missed period is recorded, so it never runs later. wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W41"}) // Moving the fire time to Sunday 23:00 puts that week's fire time // within 24 h of now. The change itself never fires a passed time... e.update(household.SettingsPatch{SuggestionsTime: ptr("23:00")}) r.changed() wantKeys(t, "after a settings change", rec.keys(), nil) r.stop() // ...and the restart's catch-up finds the period already recorded. var again recorder r2 := e.start(at(2026, 10, 12, 8, 2), suggestions(&again)) wantKeys(t, "after restart", again.keys(), nil) r2.runUntil(at(2026, 10, 18, 23, 0)) wantKeys(t, "next period", again.keys(), []string{"2026-W42"}) } func TestCatchUpOnlyMostRecentPeriod(t *testing.T) { e := newEnv(t) var rec recorder // Days of downtime: only Oct 9 16:30, 30 minutes ago, runs. e.start(at(2026, 10, 9, 17, 0), reminder(&rec)) wantKeys(t, "catch-up", rec.keys(), []string{"2026-10-09"}) wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-09"}) } func TestCatchUpEveryPeriodWithin24HoursAcrossSpringForward(t *testing.T) { // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08, // so 01:30 on Mar 8 and 01:30 on Mar 9 are only 23 hours apart. e := newEnv(t) e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) var rec recorder review := scheduler.Schedule{ Name: scheduler.RotationCheck, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, Run: rec.run, } // Boot at Mar 9 01:35: Mar 9 01:30 passed 5 minutes ago and Mar 8 // 01:30 23h05m ago, so both run, oldest first; Mar 7 01:30, 47h05m // ago, is not looked at. r := e.start(at(2026, 3, 9, 1, 35), review) wantKeys(t, "catch-up", rec.keys(), []string{"2026-03-08", "2026-03-09"}) wantFired(t, rec.periods(), time.Date(2026, 3, 8, 7, 30, 0, 0, time.UTC), // 01:30 CST time.Date(2026, 3, 9, 6, 30, 0, 0, time.UTC), // 01:30 CDT ) wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"2026-03-08", "2026-03-09"}) r.stop() // A restart within the window finds both periods claimed. var again recorder review.Run = again.run r2 := e.start(at(2026, 3, 9, 1, 40), review) r2.runUntil(at(2026, 3, 9, 23, 0)) wantKeys(t, "after restart", again.keys(), nil) } func TestSpringForward(t *testing.T) { // Clocks in Chicago jump 02:00 CST → 03:00 CDT on Sunday 2026-03-08. e := newEnv(t) e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("02:30")}) var daily, skipped recorder review := scheduler.Schedule{ Name: scheduler.RotationCheck, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, Run: skipped.run, } r := e.start(at(2026, 3, 7, 9, 0), reminder(&daily), review) r.runUntil(at(2026, 3, 9, 12, 0)) wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) wantFired(t, daily.periods(), time.Date(2026, 3, 7, 14, 0, 0, 0, time.UTC), // 08:00 CST time.Date(2026, 3, 8, 13, 0, 0, 0, time.UTC), // 08:00 CDT time.Date(2026, 3, 9, 13, 0, 0, 0, time.UTC), ) // 02:30 does not exist on Mar 8: it fires once, an hour later at // 03:30 CDT, keyed by Mar 8. wantKeys(t, "02:30 runs", skipped.keys(), []string{"2026-03-07", "2026-03-08", "2026-03-09"}) wantFired(t, skipped.periods(), time.Date(2026, 3, 7, 8, 30, 0, 0, time.UTC), // 02:30 CST time.Date(2026, 3, 8, 8, 30, 0, 0, time.UTC), // 03:30 CDT time.Date(2026, 3, 9, 7, 30, 0, 0, time.UTC), // 02:30 CDT ) if got := skipped.periods()[1].FireAt.Format("15:04 MST"); got != "03:30 CDT" { t.Errorf("skipped 02:30 fired at %s local, want 03:30 CDT", got) } } func TestFallBack(t *testing.T) { // Clocks in Chicago go back 02:00 CDT → 01:00 CST on Sunday 2026-11-01. e := newEnv(t) e.update(household.SettingsPatch{ReminderTime: ptr("08:00"), ReviewTime: ptr("01:30")}) var daily, repeated recorder review := scheduler.Schedule{ Name: scheduler.RotationCheck, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, Run: repeated.run, } r := e.start(at(2026, 10, 31, 9, 0), reminder(&daily), review) r.runUntil(at(2026, 11, 2, 12, 0)) wantKeys(t, "08:00 runs", daily.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) wantFired(t, daily.periods(), time.Date(2026, 10, 31, 13, 0, 0, 0, time.UTC), // 08:00 CDT time.Date(2026, 11, 1, 14, 0, 0, 0, time.UTC), // 08:00 CST time.Date(2026, 11, 2, 14, 0, 0, 0, time.UTC), ) // 01:30 happens twice on Nov 1: it fires once, at the first (CDT). wantKeys(t, "01:30 runs", repeated.keys(), []string{"2026-10-31", "2026-11-01", "2026-11-02"}) wantFired(t, repeated.periods(), time.Date(2026, 10, 31, 6, 30, 0, 0, time.UTC), // 01:30 CDT time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC), // first 01:30, CDT time.Date(2026, 11, 2, 7, 30, 0, 0, time.UTC), // 01:30 CST ) } func TestFallBackSecondOccurrenceDoesNotRerun(t *testing.T) { e := newEnv(t) e.update(household.SettingsPatch{ReviewTime: ptr("01:30")}) var rec recorder review := scheduler.Schedule{ Name: scheduler.RotationCheck, Rule: func(s household.Settings) scheduler.Rule { return scheduler.Daily(s.ReviewTime) }, Run: rec.run, } // Boot between the two 01:30s of Nov 1: the first ran in an earlier // process, so the second is not a new period. first := time.Date(2026, 11, 1, 6, 30, 0, 0, time.UTC) r := e.start(first.Add(-time.Minute), review) r.runUntil(first) wantKeys(t, "first 01:30", rec.keys(), []string{"2026-10-31", "2026-11-01"}) r.stop() var again recorder review.Run = again.run r2 := e.start(first.Add(30*time.Minute), review) r2.runUntil(first.Add(2 * time.Hour)) // past the second 01:30, CST wantKeys(t, "second 01:30", again.keys(), nil) } func wantFired(t *testing.T, got []scheduler.Period, want ...time.Time) { t.Helper() if len(got) != len(want) { t.Fatalf("%d runs, want %d", len(got), len(want)) } for i := range want { if !got[i].FireAt.Equal(want[i]) { t.Errorf("run %s fired at %s (%s), want %s", got[i].Key, got[i].FireAt.UTC(), got[i].FireAt, want[i]) } } } func TestISOWeek53Boundary(t *testing.T) { e := newEnv(t) var sugg, draft recorder // Suggestions Sunday 08:00 keys the ISO week ending that Sunday; the // plan draft Friday 09:00 keys the week after. r := e.start(at(2026, 12, 20, 9, 0), suggestions(&sugg), planDraft(&draft)) r.runUntil(at(2027, 1, 11, 0, 0)) wantKeys(t, "suggestions", sugg.keys(), []string{"2026-W51", "2026-W52", "2026-W53", "2027-W01"}) // Sunday 2027-01-03 is the last day of 2026-W53. if p := sugg.periods()[2]; p.Date.String() != "2027-01-03" || p.Week.String() != "2026-12-28" { t.Errorf("2026-W53 suggestions ran on %s for week %s, want 2027-01-03 for 2026-12-28", p.Date, p.Week) } // Friday Dec 18, two days before boot, was missed. wantKeys(t, "plan drafts", draft.keys(), []string{"2026-W53", "2027-W01", "2027-W02"}) // Friday 2026-12-25 drafts 2026-W53; Friday 2027-01-01 drafts 2027-W01. if p := draft.periods()[0]; p.Date.String() != "2026-12-25" || p.Week.String() != "2026-12-28" { t.Errorf("2026-W53 draft ran on %s for week %s, want 2026-12-25 for 2026-12-28", p.Date, p.Week) } if p := draft.periods()[1]; p.Date.String() != "2027-01-01" || p.Week.String() != "2027-01-04" { t.Errorf("2027-W01 draft ran on %s for week %s, want 2027-01-01 for 2027-01-04", p.Date, p.Week) } } func TestPeriodKey(t *testing.T) { date := func(s string) store.LocalDate { d, err := store.ParseLocalDate(s) if err != nil { t.Fatal(err) } return d } tests := []struct { rule scheduler.Rule date string want string }{ {scheduler.Daily("16:30"), "2026-12-31", "2026-12-31"}, {scheduler.Weekly(time.Monday, "00:00"), "2026-12-28", "2026-W53"}, {scheduler.Weekly(time.Sunday, "08:00"), "2027-01-03", "2026-W53"}, {scheduler.Weekly(time.Monday, "00:00"), "2027-01-04", "2027-W01"}, {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2026-12-25", "2026-W53"}, {scheduler.WeeklyForNextWeek(time.Friday, "09:00"), "2027-01-01", "2027-W01"}, } for _, tt := range tests { if got := tt.rule.PeriodKey(date(tt.date)); got != tt.want { t.Errorf("%+v.PeriodKey(%s) = %s, want %s", tt.rule, tt.date, got, tt.want) } } } func TestSettingsChangeMovesNextFire(t *testing.T) { e := newEnv(t) var rec recorder r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) // Earlier but still ahead: fires at the new time, not the old. e.update(household.SettingsPatch{ReminderTime: ptr("13:00")}) r.changed() if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 13, 0)) { t.Errorf("next wake %s, want the new 13:00", next) } r.runUntil(at(2026, 10, 7, 13, 0)) wantKeys(t, "at the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) r.runUntil(at(2026, 10, 7, 23, 0)) wantKeys(t, "at the old time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) // Moved to a time already passed today: nothing fires now, and // tomorrow fires at the new time. r.runUntil(at(2026, 10, 8, 12, 0)) e.update(household.SettingsPatch{ReminderTime: ptr("11:00")}) r.changed() wantKeys(t, "moved into the past", rec.keys(), []string{"2026-10-06", "2026-10-07"}) r.runUntil(at(2026, 10, 9, 10, 59)) wantKeys(t, "before the new time", rec.keys(), []string{"2026-10-06", "2026-10-07"}) r.runUntil(at(2026, 10, 9, 11, 0)) wantKeys(t, "the new time", rec.keys(), []string{"2026-10-06", "2026-10-07", "2026-10-09"}) } // A change saved at 12:00 moving the 16:30 reminder to 12:01 must fire it // even when the scheduler only gets to the change at 12:02, after the new // fire time has passed. func TestSettingsChangeAppliedLateStillFires(t *testing.T) { e := newEnv(t) var rec recorder // blocker holds the scheduler in a Run at 12:00 on 2026-10-07 while // the settings change and the clock moves on. started := make(chan struct{}) release := make(chan struct{}) blocker := scheduler.Schedule{ Name: scheduler.RotationCheck, Rule: func(household.Settings) scheduler.Rule { return scheduler.Daily("12:00") }, Run: func(_ context.Context, p scheduler.Period) error { if p.Key == "2026-10-07" { close(started) <-release } return nil }, } r := e.start(at(2026, 10, 7, 11, 0), reminder(&rec), blocker) wantKeys(t, "boot", rec.keys(), []string{"2026-10-06"}) r.clock.set(at(2026, 10, 7, 12, 0)) <-started e.update(household.SettingsPatch{ReminderTime: ptr("12:01")}) r.clock.set(at(2026, 10, 7, 12, 2)) close(release) r.idle() // the pass the blocker held r.idle() // the pass that applies the change wantKeys(t, "after the late change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) r.runUntil(at(2026, 10, 7, 23, 0)) wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) } // failClaims makes every claim fail until recover is called. func (e *env) failClaims() (recover func()) { e.t.Helper() for _, q := range []string{ `CREATE TABLE fail_claims (on_ INTEGER NOT NULL)`, `INSERT INTO fail_claims VALUES (1)`, `CREATE TRIGGER fail_claim BEFORE INSERT ON schedule_runs WHEN (SELECT on_ FROM fail_claims) = 1 BEGIN SELECT RAISE(ABORT, 'injected claim failure'); END`, } { if _, err := e.st.Writer.Exec(q); err != nil { e.t.Fatal(err) } } return func() { e.t.Helper() if _, err := e.st.Writer.Exec(`UPDATE fail_claims SET on_ = 0`); err != nil { e.t.Fatal(err) } } } // A claim that fails is retried, not skipped, and the period then runs once. func TestClaimFailureIsRetried(t *testing.T) { e := newEnv(t) recoverClaims := e.failClaims() var rec recorder r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) wantKeys(t, "boot with failing claims", rec.keys(), nil) wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) { t.Errorf("next wake %s, want a retry after %s", next, scheduler.ClaimRetry) } recoverClaims() r.runUntil(at(2026, 10, 7, 12, 0).Add(scheduler.ClaimRetry)) wantKeys(t, "after the retry", rec.keys(), []string{"2026-10-06"}) r.runUntil(at(2026, 10, 7, 23, 0)) wantKeys(t, "end of day", rec.keys(), []string{"2026-10-06", "2026-10-07"}) wantKeys(t, "claims", e.claimed(scheduler.DayOfReminder), []string{"2026-10-06", "2026-10-07"}) } // A claim that keeps failing past the next daily fire is retried for its // own period, not the most recent one: that period, now more than // CatchUpWindow old, is recorded as missed, and the later period runs once. func TestClaimFailureRecoversAfterNextFire(t *testing.T) { e := newEnv(t) recoverClaims := e.failClaims() var rec recorder // The 7 October 16:30 reminder is due and its claim fails, past the // 8 October 16:30 fire. r := e.start(at(2026, 10, 7, 16, 45), reminder(&rec)) r.runUntil(at(2026, 10, 8, 17, 0)) wantKeys(t, "runs while failing", rec.keys(), nil) wantKeys(t, "claims while failing", e.claimed(scheduler.DayOfReminder), nil) // Claims recover 24h31m after the 7 October fire. recoverClaims() r.runUntil(at(2026, 10, 8, 17, 1)) wantKeys(t, "after recovery", rec.keys(), []string{"2026-10-08"}) wantKeys(t, "claims after recovery", e.claimed(scheduler.DayOfReminder), []string{"2026-10-07", "2026-10-08"}) r.runUntil(at(2026, 10, 9, 16, 0)) wantKeys(t, "next day", rec.keys(), []string{"2026-10-08"}) } func TestSettingsChangeTimezone(t *testing.T) { e := newEnv(t) var rec recorder r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) e.update(household.SettingsPatch{Timezone: ptr("America/New_York")}) r.changed() // 16:30 in New York is 15:30 in Chicago. r.runUntil(at(2026, 10, 7, 15, 30)) wantKeys(t, "after the zone change", rec.keys(), []string{"2026-10-06", "2026-10-07"}) if got := rec.periods()[1].FireAt.Location().String(); got != "America/New_York" { t.Errorf("fired in zone %s, want America/New_York", got) } } func TestRunErrorKeepsPeriodClaimed(t *testing.T) { e := newEnv(t) rec := recorder{err: errors.New("boom")} r := e.start(at(2026, 10, 7, 16, 0), reminder(&rec)) r.runUntil(at(2026, 10, 7, 23, 0)) wantKeys(t, "runs", rec.keys(), []string{"2026-10-06", "2026-10-07"}) r.stop() var again recorder e.start(at(2026, 10, 7, 23, 0), reminder(&again)) wantKeys(t, "after restart", again.keys(), nil) } func TestRegister(t *testing.T) { e := newEnv(t) s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } var rec recorder bad := []scheduler.Schedule{ {Name: "hourly", Rule: reminder(&rec).Rule, Run: rec.run}, {Name: scheduler.DayOfReminder, Run: rec.run}, {Name: scheduler.DayOfReminder, Rule: reminder(&rec).Rule}, {Name: scheduler.DayOfReminder, Key: "start", PeriodKey: func(p scheduler.Period) string { return p.Key }, Rule: reminder(&rec).Rule, Run: rec.run}, } for _, sch := range bad { if err := s.Register(sch); err == nil { t.Errorf("Register(%q) accepted an invalid schedule", sch.Name) } } if err := s.Register(reminder(&rec)); err != nil { t.Fatal(err) } if err := s.Register(reminder(&rec)); err == nil { t.Error("Register accepted a second schedule with the same name") } } func TestStartRejectsInvalidRule(t *testing.T) { e := newEnv(t) s, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(io.Discard, nil))) if err != nil { t.Fatal(err) } var rec recorder if err := s.Register(scheduler.Schedule{ Name: scheduler.WeekRollover, Rule: func(household.Settings) scheduler.Rule { return scheduler.Weekly(7, "00:00") }, Run: rec.run, }); err != nil { t.Fatal(err) } if err := s.Start(context.Background()); err == nil { s.Wait() t.Fatal("Start accepted weekday 7") } } func TestStopEndsTheLoop(t *testing.T) { e := newEnv(t) var rec recorder r := e.start(at(2026, 10, 7, 12, 0), reminder(&rec)) done := make(chan struct{}) go func() { r.stop() close(done) }() select { case <-done: case <-time.After(10 * time.Second): t.Fatal("scheduler did not stop") } }