Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #182.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's day_of_reminder schedules on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames Monday to Sunday: Beef
|
||||
// Chili on Monday, Pot Roast on Tuesday. The household's dinner time is the
|
||||
// seeded 17:30 and its reminder time the seeded 16:30, in Chicago.
|
||||
|
||||
// w42 is the Monday of 2026-W42.
|
||||
var w42 = mustLocalDate("2026-10-12")
|
||||
|
||||
// planWeek drafts the plan of the week starting weekStart and waits for it.
|
||||
func (p *planEnv) planWeek(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
p.reply(weekStart)
|
||||
if _, err := p.svc.StartDraft(context.Background(), weekStart, false); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
if jobs := p.e.drafts(); jobs[len(jobs)-1] != succeeded {
|
||||
p.e.t.Fatalf("draft of %s = %+v, want succeeded", weekStart, jobs)
|
||||
}
|
||||
}
|
||||
|
||||
// crockPot makes the dinner name a crock-pot dinner of minutes.
|
||||
func (p *planEnv) crockPot(name string, minutes int) {
|
||||
p.e.t.Helper()
|
||||
if _, err := p.e.st.Writer.Exec(`UPDATE dinners SET method = 'crockpot', minutes_total = ? WHERE id = ?`, minutes, p.ids[name]); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// reminders returns the day_of_reminder notifications stored, oldest
|
||||
// first. The seeded preference sends day_of_reminder to push only, so there
|
||||
// is one row per event.
|
||||
func (e *env) reminders() []notifytest.Row {
|
||||
e.t.Helper()
|
||||
rows := notifytest.Rows(e.t, e.st, notify.DayOfReminder)
|
||||
for _, r := range rows {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("day_of_reminder row %+v, want push only", r)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// reminderKeys returns the keys of e.reminders().
|
||||
func (e *env) reminderKeys() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range e.reminders() {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// wantReminder fails unless the reminder keyed key was stored with title,
|
||||
// body and the link to the dinner name.
|
||||
func (p *planEnv) wantReminder(key, title, body, name string) {
|
||||
p.e.t.Helper()
|
||||
for _, r := range p.e.reminders() {
|
||||
if r.Key != key {
|
||||
continue
|
||||
}
|
||||
week := household.WeekStart(mustLocalDate(key[len(key)-len("YYYY-MM-DD"):]))
|
||||
want := notify.Payload{Event: notify.DayOfReminder, Title: title, Body: body, URL: "/dinner/" + p.ids[name], Tag: key, Week: week.String()}
|
||||
if r.Payload != want {
|
||||
p.e.t.Errorf("reminder %s = %+v, want %+v", key, r.Payload, want)
|
||||
}
|
||||
return
|
||||
}
|
||||
p.e.t.Errorf("no reminder %s", key)
|
||||
}
|
||||
|
||||
// TestDayOfReminderNormalNight: a night whose dinner is not a crock-pot
|
||||
// dinner gets exactly the daily reminder, at reminder_time 16:30, keyed
|
||||
// day_of_reminder:YYYY-MM-DD and linking to the dinner.
|
||||
func TestDayOfReminderNormalNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
// Monday 08:00: the catch-up of Sunday 16:30 finds no plan for W41.
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-13", "Tonight: Pot Roast · Recipe →", "", "Pot Roast")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderCrockPotNight: a crock-pot night whose start_by is
|
||||
// earlier than reminder_time gets the start reminder at start_by minus 15
|
||||
// minutes, keyed day_of_reminder:start:YYYY-MM-DD, and the daily reminder
|
||||
// at reminder_time. A crock-pot night whose start_by is not earlier than
|
||||
// reminder_time gets only the daily one.
|
||||
func TestDayOfReminderCrockPotNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 480) // Monday: start by 09:30
|
||||
p.crockPot("Pot Roast", 30) // Tuesday: start by 17:00, after 16:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 16, 29))
|
||||
wantKeys(t, "before 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 16, 30))
|
||||
wantKeys(t, "at 16:30", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Beef Chili · Recipe →", "", "Beef Chili")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderEmptyNight: with no plan for tonight's week, here none
|
||||
// at all and then only next week's, nothing is emitted and nothing fails;
|
||||
// next week's crock-pot Monday is reminded on its own day.
|
||||
func TestDayOfReminderEmptyNight(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "with no plan", e.reminderKeys(), nil)
|
||||
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w43)
|
||||
r.runUntil(at(2026, 10, 18, 23, 59))
|
||||
wantKeys(t, "with only next week's plan", e.reminderKeys(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder),
|
||||
[]string{"2026-10-11", "2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"})
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "day-of reminder not sent: the week has no plan") || strings.Contains(logs, "level=ERROR") {
|
||||
t.Errorf("logs = %s, want the empty nights logged and no error", logs)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 9, 15))
|
||||
wantKeys(t, "next Monday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-19"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-19", "Start the crock pot by 9:30 am", "Beef Chili", "Beef Chili")
|
||||
}
|
||||
|
||||
// TestDayOfReminderFollowsReminderTimeWithoutRestart: a reminder_time
|
||||
// change takes effect in the running scheduler. Moved from 16:30 to 18:00,
|
||||
// the daily reminder fires at 18:00, not 16:30, and a crock-pot start_by
|
||||
// of 17:00, now earlier than reminder_time, gets its start reminder at
|
||||
// 16:45.
|
||||
func TestDayOfReminderFollowsReminderTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.crockPot("Beef Chili", 30) // start by 17:00
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
e.update(household.SettingsPatch{ReminderTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 16, 44))
|
||||
wantKeys(t, "before 16:45", e.reminderKeys(), nil)
|
||||
r.runUntil(at(2026, 10, 12, 16, 45))
|
||||
wantKeys(t, "at 16:45", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 5:00 pm", "Beef Chili", "Beef Chili")
|
||||
r.runUntil(at(2026, 10, 12, 17, 59))
|
||||
wantKeys(t, "before 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r.runUntil(at(2026, 10, 12, 18, 0))
|
||||
wantKeys(t, "at 18:00", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDayOfReminderNoDuplicateAfterRestart: restarts after each reminder
|
||||
// of a crock-pot night, within the catch-up window, emit neither again:
|
||||
// each period is already claimed.
|
||||
func TestDayOfReminderNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.crockPot("Beef Chili", 480)
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 12, 9, 20), p2.svc.ReminderSchedules()...)
|
||||
wantKeys(t, "after the first restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
r2.runUntil(at(2026, 10, 12, 16, 30))
|
||||
r2.stop()
|
||||
p2.stop()
|
||||
|
||||
p3 := e.plans(ids)
|
||||
r3 := e.start(at(2026, 10, 12, 16, 40), p3.svc.ReminderSchedules()...)
|
||||
r3.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "after the second restart", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "start:2026-10-12"})
|
||||
}
|
||||
|
||||
// TestDailyAtLookupFailureIsRetried: a DailyAt rule fires at the instant
|
||||
// its lookup gives, its period keyed by the schedule's Key and the date;
|
||||
// a lookup that fails fires nothing and is looked up again after
|
||||
// ClaimRetry, then fires its period once.
|
||||
func TestDailyAtLookupFailureIsRetried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var rec recorder
|
||||
var mu sync.Mutex
|
||||
failing := false
|
||||
at915 := func(ctx context.Context, d store.LocalDate) (time.Time, bool, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if failing {
|
||||
return time.Time{}, false, errors.New("database down")
|
||||
}
|
||||
y, m, day := d.Date()
|
||||
return at(y, m, day, 9, 15), true, nil
|
||||
}
|
||||
sch := scheduler.Schedule{
|
||||
Name: scheduler.DayOfReminder,
|
||||
Key: "start",
|
||||
Rule: func(household.Settings) scheduler.Rule { return scheduler.DailyAt(at915) },
|
||||
Run: rec.run,
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), sch)
|
||||
wantKeys(t, "catch-up", rec.keys(), []string{"start:2026-10-11"})
|
||||
mu.Lock()
|
||||
failing = true
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 16))
|
||||
wantKeys(t, "while the lookup fails", rec.keys(), []string{"start:2026-10-11"})
|
||||
|
||||
mu.Lock()
|
||||
failing = false
|
||||
mu.Unlock()
|
||||
r.runUntil(at(2026, 10, 12, 9, 17))
|
||||
wantKeys(t, "after the lookup recovers", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12"})
|
||||
if p := rec.periods()[1]; !p.FireAt.Equal(at(2026, 10, 12, 9, 15)) || p.Date.String() != "2026-10-12" {
|
||||
t.Errorf("period = %+v, want fired at 09:15 for 2026-10-12", p)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 12, 0))
|
||||
wantKeys(t, "next day", rec.keys(), []string{"start:2026-10-11", "start:2026-10-12", "start:2026-10-13"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), rec.keys())
|
||||
}
|
||||
|
||||
// questLog stores name as a Quest-log dinner, a crock-pot dinner of
|
||||
// minutes, and returns its id. It is not a Regular.
|
||||
func (p *planEnv) questLog(name string, minutes int) string {
|
||||
p.e.t.Helper()
|
||||
now := store.Now().String()
|
||||
d, err := db.New(p.e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "quest_log",
|
||||
Origin: "side_quest",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
p.ids[name] = d.ID
|
||||
p.crockPot(name, minutes)
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// TestStartReminderAfterSwapFiresOnTime: swapping a crock-pot dinner in
|
||||
// for tonight wakes the scheduler, so its start reminder fires at start_by
|
||||
// minus 15 minutes, 09:15, and not at the next hourly check, 10:00. At
|
||||
// 09:00 tonight is Beef Chili, not a crock-pot dinner, and the scheduler's
|
||||
// next timer is 10:00; at 09:01 the household swaps in an eight-hour
|
||||
// crock-pot Quest-log dinner, start by 09:30. Each reminder fires once.
|
||||
func TestStartReminderAfterSwapFiresOnTime(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.wakingPlans(e.pool())
|
||||
stew := p.questLog("Crock Pot Stew", 480) // start by 09:30
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 8, 0), p.svc.ReminderSchedules()...)
|
||||
r.runUntil(at(2026, 10, 12, 9, 0))
|
||||
if next, _ := r.clock.nextDeadline(); !next.Equal(at(2026, 10, 12, 10, 0)) {
|
||||
t.Fatalf("next wake at 09:00 = %s, want the hourly 10:00", next)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 1))
|
||||
if _, err := p.svc.Swap(context.Background(), w42, "2026-10-12", stew); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 12, 9, 14))
|
||||
wantKeys(t, "before 09:15", e.reminderKeys(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 9, 15))
|
||||
wantKeys(t, "at 09:15", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12"})
|
||||
p.wantReminder("day_of_reminder:start:2026-10-12", "Start the crock pot by 9:30 am", "Crock Pot Stew", "Crock Pot Stew")
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 23, 59))
|
||||
wantKeys(t, "Tuesday", e.reminderKeys(), []string{"day_of_reminder:start:2026-10-12", "day_of_reminder:2026-10-12", "day_of_reminder:2026-10-13"})
|
||||
p.wantReminder("day_of_reminder:2026-10-12", "Tonight: Crock Pot Stew · Recipe →", "", "Crock Pot Stew")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.DayOfReminder), []string{"2026-10-11", "2026-10-12", "2026-10-13", "start:2026-10-12"})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package scheduler
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock and after the timer s fires by.
|
||||
func SetClock(s *Scheduler, now func() time.Time, after func(time.Duration) <-chan time.Time) {
|
||||
s.now = now
|
||||
s.after = after
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's plan_draft schedule on the fake clock
|
||||
// against the real plan service, job runner, harness, draft_week and
|
||||
// notification outbox, with the AI faked.
|
||||
|
||||
// planEnv is a plan service over a scheduler env's store, the "process" a
|
||||
// scheduler runs it in. model answers draft_week.
|
||||
type planEnv struct {
|
||||
e *env
|
||||
svc *plan.Service
|
||||
model *llm.Fake
|
||||
ids map[string]string
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// poolNames are the seven Regulars pool stores, enough for a week.
|
||||
var poolNames = []string{"Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Tacos", "Pancakes"}
|
||||
|
||||
// pool stores poolNames as Regulars and returns their ids by name.
|
||||
func (e *env) pool() map[string]string {
|
||||
e.t.Helper()
|
||||
ids := map[string]string{}
|
||||
for _, name := range poolNames {
|
||||
now := store.Now().String()
|
||||
d, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids[name] = d.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// publicURL is MEALQUEST_PUBLIC_URL in these tests.
|
||||
var publicURL = &url.URL{Scheme: "https", Host: "meals.example.com"}
|
||||
|
||||
// noCalendar is a Calendar the schedule must never push to.
|
||||
type noCalendar struct{ t *testing.T }
|
||||
|
||||
func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) error {
|
||||
c.t.Error("plan_draft pushed to Calendar")
|
||||
return errors.New("no calendar")
|
||||
}
|
||||
|
||||
// plans builds a plan service drafting from the dinners ids over e with a
|
||||
// started job runner, which stop stops (and the test's end, if not before).
|
||||
// Its changes do not wake the scheduler: a draft job's wake would add a
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
// A test using it changes plans only from its own goroutine and waits for
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return p.model, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.DraftWeek(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(p.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
p.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(p.stop)
|
||||
return p
|
||||
}
|
||||
|
||||
// reply scripts the next draft_week answer: poolNames on the days of the
|
||||
// week starting weekStart, Monday to Sunday.
|
||||
func (p *planEnv) reply(weekStart store.LocalDate) {
|
||||
p.e.t.Helper()
|
||||
dates, err := household.WeekDays(weekStart)
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
var days []string
|
||||
for i, name := range poolNames {
|
||||
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], p.ids[name]))
|
||||
}
|
||||
js := fmt.Sprintf(`{"days":[%s],"note":""}`, strings.Join(days, ","))
|
||||
p.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// draftJob is a draft_week jobs row.
|
||||
type draftJob struct {
|
||||
Status, Error string
|
||||
}
|
||||
|
||||
// drafts waits until no draft_week job is queued or running and returns
|
||||
// them all, oldest first.
|
||||
func (e *env) drafts() []draftJob {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
rows, err := e.st.Reader.Query(`SELECT status, COALESCE(error, '') FROM jobs WHERE kind = ? ORDER BY created_at, id`, plan.KindDraftWeek)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var out []draftJob
|
||||
busy := false
|
||||
for rows.Next() {
|
||||
var j draftJob
|
||||
if err := rows.Scan(&j.Status, &j.Error); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
busy = busy || j.Status == string(jobs.StatusQueued) || j.Status == string(jobs.StatusRunning)
|
||||
out = append(out, j)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
rows.Close()
|
||||
if !busy {
|
||||
return out
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("draft jobs still under way: %+v", out)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantDrafts fails unless the draft_week jobs are got.
|
||||
func wantDrafts(t *testing.T, what string, got []draftJob, want ...draftJob) {
|
||||
t.Helper()
|
||||
if fmt.Sprint(got) != fmt.Sprint(want) {
|
||||
t.Fatalf("%s: draft jobs = %+v, want %+v", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// weekPlans returns the week_start of every plan, oldest first.
|
||||
func (e *env) weekPlans() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT week_start FROM week_plans ORDER BY week_start`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var w string
|
||||
if err := rows.Scan(&w); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planReady returns the distinct plan_ready keys stored, oldest first.
|
||||
func (e *env) planReady() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
if len(keys) == 0 || keys[len(keys)-1] != r.Key {
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
// w43 is the Monday of 2026-W43, the week after Friday Oct 16.
|
||||
w43 = mustLocalDate("2026-10-19")
|
||||
w44 = mustLocalDate("2026-10-26")
|
||||
)
|
||||
|
||||
func mustLocalDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// TestPlanDraftDraftsNextWeek: at the seeded Friday 09:00 the schedule
|
||||
// drafts the next ISO week through the plan service's draft job, period
|
||||
// plan_draft:YYYY-Www of that week, and the draft emits plan_ready keyed
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
// Wednesday Oct 14: the Friday Oct 9 09:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 14, 12, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 8, 59))
|
||||
wantDrafts(t, "before Friday 09:00", e.drafts())
|
||||
wantKeys(t, "plans before Friday 09:00", e.weekPlans(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Days) != 7 || got.Days[0].Dinner.Name != "Beef Chili" || got.Status != plan.StatusDraft {
|
||||
t.Errorf("plan of W43 = %+v, want the drafted week", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 23, 9, 0))
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
// TestPlanDraftLeavesAnExistingPlan: when next week already has a plan,
|
||||
// here one the household drafted by hand, the period drafts nothing,
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(after) != fmt.Sprint(before) {
|
||||
t.Errorf("plan of W43 = %+v, want it unchanged from %+v", after, before)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "the week already has a plan") || !strings.Contains(logs, "period=2026-W43") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanDraftNoDuplicateAfterRestart: a restart after the week was
|
||||
// drafted, still within the catch-up window, drafts nothing more and sends
|
||||
// no second plan_ready: the period is already claimed.
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
// TestPlanDraftAIFailureEmitsNothing: when draft_week fails the draft job
|
||||
// fails as ai_failed with the failure recorded in ai_runs, no plan is made
|
||||
// up and no one is notified; a restart within the catch-up window does not
|
||||
// draft the claimed period again.
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'draft_week'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of draft_week: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs draft_week = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := p.logs.String(); !strings.Contains(logs, "week draft failed") || !strings.Contains(logs, "reason="+plan.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the draft failure with reason %s", logs, plan.ReasonAIFailed)
|
||||
}
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.plans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
// plan_draft_time change takes effect in the running scheduler; the old
|
||||
// Friday 09:00 no longer fires and the new Wednesday 18:30 drafts the next
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
e.update(household.SettingsPatch{PlanDraftDow: ptr(3), PlanDraftTime: ptr("18:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantDrafts(t, "before Wednesday 18:30", e.drafts())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// The tests in this file run plan's rotation_check schedule on the fake
|
||||
// clock against the real plan service and notification outbox. The plan of
|
||||
// the week of Monday Oct 12 (w42) has poolNames, all Regulars, Monday to
|
||||
// Sunday; a test that needs a Quest-log dinner swaps Lemon Orzo in for
|
||||
// Monday. The household's review time is the seeded 09:00, in Chicago.
|
||||
|
||||
// orzoDay is the Monday Lemon Orzo is planned on.
|
||||
var orzoDay = mustLocalDate("2026-10-12")
|
||||
|
||||
// orzoWeek plans w42 with the Quest-log dinner Lemon Orzo on Monday in
|
||||
// place of Beef Chili, and returns its id.
|
||||
func (p *planEnv) orzoWeek() string {
|
||||
p.e.t.Helper()
|
||||
id := p.questLog("Lemon Orzo", 30)
|
||||
p.planWeek(w42)
|
||||
if _, err := p.svc.Swap(context.Background(), w42, orzoDay.String(), id); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// rotationReviews returns the keys of the rotation_review notifications
|
||||
// stored, oldest first. The seeded preference sends rotation_review to push
|
||||
// only, so there is one row per event.
|
||||
func (e *env) rotationReviews() []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.RotationReview) {
|
||||
if r.Channel != notify.ChannelPush {
|
||||
e.t.Fatalf("rotation_review row %+v, want push only", r)
|
||||
}
|
||||
keys = append(keys, r.Key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// status returns the status of the dinner name.
|
||||
func (p *planEnv) status(name string) string {
|
||||
p.e.t.Helper()
|
||||
var s string
|
||||
if err := p.e.st.Reader.QueryRow(`SELECT status FROM dinners WHERE id = ?`, p.ids[name]).Scan(&s); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// pendingReviews returns the plan dates of the pending Rotation checks of
|
||||
// the dinner name.
|
||||
func (p *planEnv) pendingReviews(name string) []string {
|
||||
p.e.t.Helper()
|
||||
rows, err := p.e.st.Reader.Query(`SELECT plan_date FROM rotation_reviews WHERE dinner_id = ? AND status = 'pending' ORDER BY plan_date`, p.ids[name])
|
||||
if err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var d string
|
||||
if err := rows.Scan(&d); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
p.e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wantOrzoTried fails unless Lemon Orzo, id, is tried with one pending
|
||||
// Rotation check for orzoDay and exactly one rotation_review asking about
|
||||
// it, which opens Home.
|
||||
func (p *planEnv) wantOrzoTried(id string) {
|
||||
p.e.t.Helper()
|
||||
if got := p.status("Lemon Orzo"); got != "tried" {
|
||||
p.e.t.Errorf("Lemon Orzo status = %s, want tried", got)
|
||||
}
|
||||
wantKeys(p.e.t, "Lemon Orzo pending Rotation checks", p.pendingReviews("Lemon Orzo"), []string{orzoDay.String()})
|
||||
key := "rotation_review:" + id + ":" + orzoDay.String()
|
||||
wantKeys(p.e.t, "rotation_review", p.e.rotationReviews(), []string{key})
|
||||
got := notifytest.Rows(p.e.t, p.e.st, notify.RotationReview)[0].Payload
|
||||
want := notify.Payload{Event: notify.RotationReview, Title: "How was Lemon Orzo? Keep it in rotation?", URL: "/", Tag: key, Week: w42.String()}
|
||||
if got != want {
|
||||
p.e.t.Errorf("rotation_review = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotationCheckMarksQuestLogDinnerTried: at review_time 09:00 the day
|
||||
// after a Quest-log dinner was planned, the schedule, period
|
||||
// rotation_check:YYYY-MM-DD, marks it tried, opens its Rotation check and
|
||||
// emits one rotation_review. It never makes the dinner a Regular.
|
||||
func TestRotationCheckMarksQuestLogDinnerTried(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
// Tuesday 08:00: the catch-up of Monday 09:00 checks Sunday, unplanned.
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 8, 59))
|
||||
wantKeys(t, "before 09:00", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 09:00 = %s, want quest_log", got)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
|
||||
// Days later, still tried: only the household's keep makes a Regular.
|
||||
r.runUntil(at(2026, 10, 16, 12, 0))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{
|
||||
"rotation_check:2026-10-12", "rotation_check:2026-10-13", "rotation_check:2026-10-14",
|
||||
"rotation_check:2026-10-15", "rotation_check:2026-10-16",
|
||||
})
|
||||
}
|
||||
|
||||
// TestRotationCheckLeavesRegulars: a day planning only Regulars marks
|
||||
// nothing tried, opens no Rotation check and emits nothing.
|
||||
func TestRotationCheckLeavesRegulars(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p.planWeek(w42)
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
wantKeys(t, "rotation_review", e.rotationReviews(), nil)
|
||||
if got := p.status("Beef Chili"); got != "regular" {
|
||||
t.Errorf("Beef Chili status = %s, want regular", got)
|
||||
}
|
||||
wantKeys(t, "Beef Chili pending Rotation checks", p.pendingReviews("Beef Chili"), nil)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckNoDuplicateSameDay: a restart the same day finds the
|
||||
// period claimed, and MarkTried run again for the same day finds the
|
||||
// Rotation check open, so neither opens a second check or notification.
|
||||
func TestRotationCheckNoDuplicateSameDay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
r.runUntil(at(2026, 10, 13, 9, 0))
|
||||
p.wantOrzoTried(id)
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
p2 := e.plans(ids)
|
||||
r2 := e.start(at(2026, 10, 13, 9, 30), p2.svc.RotationCheckSchedule())
|
||||
r2.runUntil(at(2026, 10, 13, 23, 59))
|
||||
p2.wantOrzoTried(id)
|
||||
|
||||
if n, err := p2.svc.MarkTried(context.Background(), orzoDay); err != nil || n != 0 {
|
||||
t.Fatalf("MarkTried(%s) again = %d, %v, want 0", orzoDay, n, err)
|
||||
}
|
||||
p2.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckCatchUpAtBoot: started 23 hours after a missed 09:00,
|
||||
// the scheduler runs that day's Rotation check once at boot.
|
||||
func TestRotationCheckCatchUpAtBoot(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
e.start(at(2026, 10, 14, 8, 0), p.svc.RotationCheckSchedule())
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-13"})
|
||||
}
|
||||
|
||||
// TestRotationCheckFollowsReviewTimeWithoutRestart: a review_time change
|
||||
// takes effect in the running scheduler. Moved from 09:00 to 10:30, the
|
||||
// Rotation check does not run at 09:00 and runs at 10:30.
|
||||
func TestRotationCheckFollowsReviewTimeWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
id := p.orzoWeek()
|
||||
|
||||
r := e.start(at(2026, 10, 13, 8, 0), p.svc.RotationCheckSchedule())
|
||||
e.update(household.SettingsPatch{ReviewTime: ptr("10:30")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 13, 10, 29))
|
||||
wantKeys(t, "before 10:30", e.rotationReviews(), nil)
|
||||
if got := p.status("Lemon Orzo"); got != "quest_log" {
|
||||
t.Fatalf("Lemon Orzo status before 10:30 = %s, want quest_log", got)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 13, 10, 30))
|
||||
p.wantOrzoTried(id)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.RotationCheck), []string{"rotation_check:2026-10-12", "rotation_check:2026-10-13"})
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
// Package scheduler runs the household's timed work: each registered
|
||||
// schedule fires once per period at a household-local time read from the
|
||||
// household settings.
|
||||
//
|
||||
// # Periods and the claim
|
||||
//
|
||||
// Every fire time belongs to one period, named by a period key derived in
|
||||
// the household zone from the schedule's cadence (see Rule.PeriodKey): the
|
||||
// local date "2026-10-09" for a daily rule, and the ISO week "2026-W41" of
|
||||
// the fire date, or of the week after it, for a weekly rule. Before running,
|
||||
// the scheduler inserts schedule_runs(schedule, period_key); the insert is
|
||||
// the claim, and a period already claimed, by an earlier run, an earlier
|
||||
// process or another instance, is skipped. A period therefore runs at most
|
||||
// once: a Run that fails is logged and its period is not retried.
|
||||
//
|
||||
// # Catch-up
|
||||
//
|
||||
// When the scheduler starts, and whenever it wakes late, it handles, oldest
|
||||
// first, every fire time of each schedule that passed within CatchUpWindow,
|
||||
// and the most recent fire time even when it passed longer ago. Each
|
||||
// unclaimed period within CatchUpWindow runs once. A most recent one that
|
||||
// passed longer ago is claimed without running and logged as missed, so it
|
||||
// can never run later, not after a restart and not after a settings change
|
||||
// moves the fire time. Other fire times older than CatchUpWindow are never
|
||||
// looked at.
|
||||
//
|
||||
// # Claim failures
|
||||
//
|
||||
// A claim that fails (the database refused the insert) is never skipped:
|
||||
// the scheduler logs it and keeps that period's fire time, and every
|
||||
// ClaimRetry claims that same period again until the claim succeeds. Only
|
||||
// then does it apply the catch-up rule to it, running it if its fire time
|
||||
// is within CatchUpWindow of the successful claim and recording it as
|
||||
// missed otherwise, and only then does it go on to later fire times, even
|
||||
// when one has passed meanwhile. Each period is claimed by its own fire
|
||||
// time, so the later period runs once as well.
|
||||
//
|
||||
// # Time zone and DST
|
||||
//
|
||||
// Fire times are wall-clock times in the household zone, never the process
|
||||
// zone, turned into instants by household.LocalTime. Its rule is the
|
||||
// scheduler's DST rule: a time that does not exist on a spring-forward day
|
||||
// fires later by the length of the gap (02:30 fires at 03:30 local), and a
|
||||
// time that occurs twice on a fall-back day fires once, at its first
|
||||
// occurrence. Either way the period key is that local date's, so the
|
||||
// period still runs exactly once.
|
||||
//
|
||||
// # Fire times from data
|
||||
//
|
||||
// A DailyAt rule fires at a time read from the household's data rather
|
||||
// than the settings: its function gives, for each household-local date,
|
||||
// the instant the date's period fires, or none. The period is that date
|
||||
// even when the instant falls on another day. The scheduler asks again on
|
||||
// every pass, and whoever changes that data calls Wake once the change is
|
||||
// committed, so the next pass comes at once and a fire time the change
|
||||
// moved fires on time rather than up to maxWait late; the claim still runs
|
||||
// a period at most once. A lookup that fails is logged and repeated every
|
||||
// ClaimRetry, as a failed claim is.
|
||||
//
|
||||
// # Settings changes
|
||||
//
|
||||
// The scheduler subscribes to the household settings and records each change
|
||||
// with the instant it was saved. A change takes effect at once without a
|
||||
// restart: fire times up to that instant follow the old settings and later
|
||||
// ones the new, even when the scheduler gets to the change late. So moving
|
||||
// a fire time to earlier the same day does not fire the passed time, and a
|
||||
// new fire time that was still ahead when the change was saved fires.
|
||||
package scheduler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Schedule names, as the schedule_runs.schedule CHECK constraint allows.
|
||||
const (
|
||||
SuggestionsWeekly = "suggestions_weekly"
|
||||
PlanDraft = "plan_draft"
|
||||
ShoppingChatAutosend = "shopping_chat_autosend"
|
||||
DayOfReminder = "day_of_reminder"
|
||||
RotationCheck = "rotation_check"
|
||||
WeekRollover = "week_rollover"
|
||||
AIRunsPurge = "ai_runs_purge"
|
||||
)
|
||||
|
||||
// names are the schedule names Register accepts.
|
||||
var names = map[string]bool{
|
||||
SuggestionsWeekly: true,
|
||||
PlanDraft: true,
|
||||
ShoppingChatAutosend: true,
|
||||
DayOfReminder: true,
|
||||
RotationCheck: true,
|
||||
WeekRollover: true,
|
||||
AIRunsPurge: true,
|
||||
}
|
||||
|
||||
// CatchUpWindow is how long after its fire time a period that has not run
|
||||
// still runs. A period found later than that is recorded as missed.
|
||||
const CatchUpWindow = 24 * time.Hour
|
||||
|
||||
// ClaimRetry is how long the scheduler waits before repeating a pass in
|
||||
// which a claim failed.
|
||||
const ClaimRetry = time.Minute
|
||||
|
||||
// maxWait bounds one sleep, so the scheduler checks the wall clock at least
|
||||
// this often even if the host was suspended or its clock stepped.
|
||||
const maxWait = time.Hour
|
||||
|
||||
// Rule is when a schedule fires: at household-local Time ("HH:MM") every
|
||||
// day, or on Weekday only when Weekly is set, or every day at the instant
|
||||
// At gives. Build one with Daily, Weekly, WeeklyForNextWeek or DailyAt.
|
||||
type Rule struct {
|
||||
Weekly bool
|
||||
Weekday time.Weekday
|
||||
Time string
|
||||
// NextWeek keys a weekly period by the ISO week after the fire date,
|
||||
// for work done ahead for the coming week.
|
||||
NextWeek bool
|
||||
// At, instead of Time, gives the instant the period of a
|
||||
// household-local date fires.
|
||||
At FireAt
|
||||
}
|
||||
|
||||
// FireAt gives the instant the period of household-local date fires, or
|
||||
// false when that date's period does not fire.
|
||||
type FireAt func(ctx context.Context, date store.LocalDate) (time.Time, bool, error)
|
||||
|
||||
// Daily fires every day at hhmm; its period is the local date.
|
||||
func Daily(hhmm string) Rule {
|
||||
return Rule{Time: hhmm}
|
||||
}
|
||||
|
||||
// Weekly fires on day at hhmm; its period is the ISO week of the fire date.
|
||||
func Weekly(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm}
|
||||
}
|
||||
|
||||
// WeeklyForNextWeek fires on day at hhmm; its period is the ISO week after
|
||||
// the fire date's.
|
||||
func WeeklyForNextWeek(day time.Weekday, hhmm string) Rule {
|
||||
return Rule{Weekly: true, Weekday: day, Time: hhmm, NextWeek: true}
|
||||
}
|
||||
|
||||
// DailyAt fires at most once a day, at the instant at gives for the date;
|
||||
// its period is that date, wherever the instant falls.
|
||||
func DailyAt(at FireAt) Rule {
|
||||
return Rule{At: at}
|
||||
}
|
||||
|
||||
// PeriodKey returns the period key of a fire on household-local date
|
||||
// fireDate: the date ("2026-10-09") for a daily rule, the ISO week
|
||||
// ("2026-W53") for a weekly one, and the ISO week after that ("2027-W01")
|
||||
// when NextWeek is set. It panics if fireDate is the zero LocalDate.
|
||||
func (r Rule) PeriodKey(fireDate store.LocalDate) string {
|
||||
if !r.Weekly {
|
||||
return fireDate.String()
|
||||
}
|
||||
if r.NextWeek {
|
||||
return household.PeriodKey(addDays(fireDate, 7))
|
||||
}
|
||||
return household.PeriodKey(fireDate)
|
||||
}
|
||||
|
||||
// validate reports whether r can fire.
|
||||
func (r Rule) validate() error {
|
||||
if r.Weekly && (r.Weekday < time.Sunday || r.Weekday > time.Saturday) {
|
||||
return fmt.Errorf("invalid weekday %d (want 0-6, Sunday = 0)", r.Weekday)
|
||||
}
|
||||
if !r.Weekly && (r.Weekday != time.Sunday || r.NextWeek) {
|
||||
return errors.New("a daily rule has no weekday or next week")
|
||||
}
|
||||
if r.At != nil {
|
||||
if r.Weekly || r.Time != "" {
|
||||
return errors.New("a DailyAt rule has no weekday or time")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Any date checks the time's form.
|
||||
_, err := household.LocalTime(time.UTC, store.LocalDateOf(time.Unix(0, 0).UTC()), r.Time)
|
||||
return err
|
||||
}
|
||||
|
||||
// Period is one period of a schedule, as given to its Run.
|
||||
type Period struct {
|
||||
// Key is the period key claimed in schedule_runs.
|
||||
Key string
|
||||
// FireAt is the instant the period fired, in the household zone.
|
||||
FireAt time.Time
|
||||
// Date is the household-local date of FireAt.
|
||||
Date store.LocalDate
|
||||
// Week is the ISO Monday of the week Key names; for a daily rule, of
|
||||
// the week containing Date.
|
||||
Week store.LocalDate
|
||||
}
|
||||
|
||||
// Schedule is registered work that runs once per period.
|
||||
type Schedule struct {
|
||||
// Name is one of the schedule name constants.
|
||||
Name string
|
||||
// Key, when set, prefixes the schedule's period keys with Key and a
|
||||
// colon ("start:2026-10-14"), so two schedules of one Name claim
|
||||
// different periods. Name and Key together are unique.
|
||||
Key string
|
||||
// PeriodKey, when set, gives the period key claimed for p in place of
|
||||
// the rule's, such as "list_ready:chat:2026-10-19" for the week p.Week;
|
||||
// p.Key is the rule's key. It must give each period its own key. A
|
||||
// schedule sets Key or PeriodKey, not both.
|
||||
PeriodKey func(p Period) string
|
||||
// Rule returns when the schedule fires under settings. It is called
|
||||
// again after every settings change.
|
||||
Rule func(settings household.Settings) Rule
|
||||
// Run does the period's work. It runs on the scheduler's goroutine,
|
||||
// one schedule at a time, so it must return promptly: long work is
|
||||
// enqueued as a job. ctx is cancelled when the scheduler stops. An
|
||||
// error is logged; the period stays claimed and is not retried.
|
||||
Run func(ctx context.Context, p Period) error
|
||||
}
|
||||
|
||||
// label names sch in errors and logs: its Name, and its Key if set.
|
||||
func (sch Schedule) label() string {
|
||||
if sch.Key == "" {
|
||||
return sch.Name
|
||||
}
|
||||
return sch.Name + ":" + sch.Key
|
||||
}
|
||||
|
||||
// Settings is the household settings source: *household.SettingsService.
|
||||
type Settings interface {
|
||||
Get(ctx context.Context) (household.Settings, error)
|
||||
Subscribe(fn func(household.Settings)) (unsubscribe func())
|
||||
}
|
||||
|
||||
// Scheduler runs the registered schedules.
|
||||
type Scheduler struct {
|
||||
st *store.Store
|
||||
settings Settings
|
||||
logger *slog.Logger
|
||||
|
||||
// now is the clock and after the timer; tests replace both.
|
||||
now func() time.Time
|
||||
after func(d time.Duration) <-chan time.Time
|
||||
|
||||
// wake holds one token when the settings or the data a DailyAt rule
|
||||
// reads changed since the loop last looked; pending is the settings
|
||||
// changes to apply, oldest first.
|
||||
wake chan struct{}
|
||||
pendingMu sync.Mutex
|
||||
pending []change
|
||||
|
||||
mu sync.Mutex
|
||||
schedules []Schedule
|
||||
started bool
|
||||
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// New returns a scheduler over st reading the household settings from
|
||||
// settings.
|
||||
func New(st *store.Store, settings Settings, logger *slog.Logger) (*Scheduler, error) {
|
||||
if st == nil {
|
||||
return nil, errors.New("scheduler: nil store")
|
||||
}
|
||||
if settings == nil {
|
||||
return nil, errors.New("scheduler: nil settings")
|
||||
}
|
||||
if logger == nil {
|
||||
return nil, errors.New("scheduler: nil logger")
|
||||
}
|
||||
return &Scheduler{
|
||||
st: st,
|
||||
settings: settings,
|
||||
logger: logger,
|
||||
now: time.Now,
|
||||
after: time.After,
|
||||
wake: make(chan struct{}, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register adds sch. It must be called before Start, once per name.
|
||||
func (s *Scheduler) Register(sch Schedule) error {
|
||||
if !names[sch.Name] {
|
||||
return fmt.Errorf("scheduler: register: unknown schedule %q", sch.Name)
|
||||
}
|
||||
if sch.Rule == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil rule", sch.label())
|
||||
}
|
||||
if sch.Run == nil {
|
||||
return fmt.Errorf("scheduler: register %s: nil run", sch.label())
|
||||
}
|
||||
if strings.Contains(sch.Key, ":") {
|
||||
return fmt.Errorf("scheduler: register %s: key has a colon", sch.label())
|
||||
}
|
||||
if sch.Key != "" && sch.PeriodKey != nil {
|
||||
return fmt.Errorf("scheduler: register %s: both key and period key", sch.label())
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return fmt.Errorf("scheduler: register %s: already started", sch.label())
|
||||
}
|
||||
for _, have := range s.schedules {
|
||||
if have.Name == sch.Name && have.Key == sch.Key {
|
||||
return fmt.Errorf("scheduler: register %s: already registered", sch.label())
|
||||
}
|
||||
}
|
||||
s.schedules = append(s.schedules, sch)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Start reads the settings and starts the scheduler, which first catches
|
||||
// up on each schedule's recent periods and then fires schedules as
|
||||
// their times come. Settings whose zone or rules are invalid are an error
|
||||
// and nothing starts. The scheduler stops when ctx is cancelled; Wait waits
|
||||
// for it.
|
||||
func (s *Scheduler) Start(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.started {
|
||||
return errors.New("scheduler: already started")
|
||||
}
|
||||
// Subscribing first means a change saved after the read below still
|
||||
// wakes the loop.
|
||||
unsubscribe := s.settings.Subscribe(s.changed)
|
||||
settings, err := s.settings.Get(ctx)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return fmt.Errorf("scheduler: %w", err)
|
||||
}
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
unsubscribe()
|
||||
return err
|
||||
}
|
||||
s.started = true
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
defer unsubscribe()
|
||||
s.loop(ctx, p)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait blocks until the scheduler has stopped.
|
||||
func (s *Scheduler) Wait() {
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// change is a settings change and the instant it was saved.
|
||||
type change struct {
|
||||
at time.Time
|
||||
settings household.Settings
|
||||
}
|
||||
|
||||
// changed is the settings subscriber. It runs on the updating goroutine, so
|
||||
// it only records the change and wakes the loop.
|
||||
func (s *Scheduler) changed(settings household.Settings) {
|
||||
s.pendingMu.Lock()
|
||||
s.pending = append(s.pending, change{at: s.now(), settings: settings})
|
||||
s.pendingMu.Unlock()
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Wake makes the scheduler look up its fire times again now rather than at
|
||||
// its next timer. Call it after committing a change to the data a DailyAt
|
||||
// rule reads, such as tonight's planned dinner. It never blocks.
|
||||
func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
case s.wake <- struct{}{}:
|
||||
default: // the loop already has a token and looks again then
|
||||
}
|
||||
}
|
||||
|
||||
// takeChanges returns and clears the pending changes.
|
||||
func (s *Scheduler) takeChanges() []change {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
out := s.pending
|
||||
s.pending = nil
|
||||
return out
|
||||
}
|
||||
|
||||
// plan is the schedules compiled against one version of the settings.
|
||||
type plan struct {
|
||||
loc *time.Location
|
||||
items []item
|
||||
}
|
||||
|
||||
type item struct {
|
||||
sch Schedule
|
||||
rule Rule
|
||||
}
|
||||
|
||||
// compile builds the plan for settings.
|
||||
func compile(schedules []Schedule, settings household.Settings) (*plan, error) {
|
||||
loc, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scheduler: household zone: %w", err)
|
||||
}
|
||||
p := &plan{loc: loc, items: make([]item, len(schedules))}
|
||||
for i, sch := range schedules {
|
||||
rule := sch.Rule(settings)
|
||||
if err := rule.validate(); err != nil {
|
||||
return nil, fmt.Errorf("scheduler: %s: %w", sch.label(), err)
|
||||
}
|
||||
p.items[i] = item{sch: sch, rule: rule}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// loop fires schedules until ctx is cancelled. done is the instant up to
|
||||
// which fire times have been handled; it starts at the zero time so the
|
||||
// first pass catches up. retry is the periods whose claim failed, each
|
||||
// with its own fire time. Each pass first claims retry again; while any
|
||||
// claim there still fails, the pass stops and is repeated after
|
||||
// ClaimRetry. Otherwise it applies the pending settings changes in order,
|
||||
// handling fire times up to a change under the plan it replaces, then
|
||||
// handles fire times up to now under the current plan, stopping after the
|
||||
// first stretch in which a claim failed.
|
||||
func (s *Scheduler) loop(ctx context.Context, p *plan) {
|
||||
var done time.Time
|
||||
var pending []change
|
||||
var retry []target
|
||||
for {
|
||||
now := s.now()
|
||||
pending = append(pending, s.takeChanges()...)
|
||||
retry = s.retry(ctx, retry, now)
|
||||
for len(retry) == 0 && len(pending) > 0 {
|
||||
c := pending[0]
|
||||
if c.at.After(done) {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, c.at, now)
|
||||
}
|
||||
done = c.at
|
||||
}
|
||||
p = s.compileChange(ctx, c.settings)
|
||||
pending = pending[1:]
|
||||
}
|
||||
if len(retry) == 0 {
|
||||
if p != nil {
|
||||
retry = s.fire(ctx, p, done, now, now)
|
||||
}
|
||||
done = now
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var timeout <-chan time.Time
|
||||
switch {
|
||||
case len(retry) > 0:
|
||||
timeout = s.after(ClaimRetry)
|
||||
case p != nil:
|
||||
next, ok, err := p.next(ctx, now)
|
||||
switch {
|
||||
case err != nil:
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up next fire time failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
timeout = s.after(ClaimRetry)
|
||||
case ok:
|
||||
timeout = s.after(min(next.Sub(now), maxWait))
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timeout:
|
||||
case <-s.wake:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compileChange returns the plan for changed settings, or nil when they
|
||||
// are unusable.
|
||||
func (s *Scheduler) compileChange(ctx context.Context, settings household.Settings) *plan {
|
||||
p, err := compile(s.schedules, settings)
|
||||
if err != nil {
|
||||
// Settings are validated before they are saved, so this is a
|
||||
// bug; firing at a guessed time would be worse than not firing
|
||||
// until the settings are fixed.
|
||||
s.logger.ErrorContext(ctx, "scheduler: settings unusable; no schedule fires until they change", "error", err)
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// target is one schedule's periods still to handle, oldest first, each
|
||||
// named by its fire time and the household-local date of its period. A
|
||||
// target whose fire times could not be looked up has lookup set instead,
|
||||
// with the stretch (done, until] to look them up in again.
|
||||
type target struct {
|
||||
it item
|
||||
loc *time.Location
|
||||
fires []fireTime
|
||||
|
||||
lookup bool
|
||||
done, until time.Time
|
||||
}
|
||||
|
||||
// fire handles, for every schedule, its catch-up fire times after done and
|
||||
// at or before until; now is the current instant. It returns the
|
||||
// targets whose lookup or claim failed, to be handled again.
|
||||
func (s *Scheduler) fire(ctx context.Context, p *plan, done, until, now time.Time) []target {
|
||||
var failed []target
|
||||
for _, it := range p.items {
|
||||
if ctx.Err() != nil {
|
||||
return failed
|
||||
}
|
||||
if t, ok := s.fireItem(ctx, it, p.loc, done, until, now); !ok {
|
||||
failed = append(failed, t)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// fireItem handles, oldest first, it's fire times after done and at or
|
||||
// before until that passed within CatchUpWindow of now, and the most recent
|
||||
// of them even when it passed longer ago. It reports false, with the target
|
||||
// to handle again, when the fire times could not be looked up or a period's
|
||||
// claim failed.
|
||||
func (s *Scheduler) fireItem(ctx context.Context, it item, loc *time.Location, done, until, now time.Time) (target, bool) {
|
||||
fs, err := fireBetween(ctx, it.rule, loc, done, until)
|
||||
if err != nil {
|
||||
s.logger.ErrorContext(ctx, "scheduler: look up fire time failed; retrying",
|
||||
"schedule", it.sch.label(), "error", err, "retry_in", ClaimRetry.String())
|
||||
return target{it: it, loc: loc, lookup: true, done: done, until: until}, false
|
||||
}
|
||||
i := 0
|
||||
for i < len(fs)-1 && now.Sub(fs[i].at) > CatchUpWindow {
|
||||
i++
|
||||
}
|
||||
return s.handleAll(ctx, target{it: it, loc: loc, fires: fs[i:]}, now)
|
||||
}
|
||||
|
||||
// handleAll handles the periods of t in order, stopping at the first whose
|
||||
// claim failed. It reports false, with that period and those after it,
|
||||
// when one did.
|
||||
func (s *Scheduler) handleAll(ctx context.Context, t target, now time.Time) (target, bool) {
|
||||
for i, f := range t.fires {
|
||||
if !s.handle(ctx, t.it, f, now) {
|
||||
t.fires = t.fires[i:]
|
||||
return t, false
|
||||
}
|
||||
}
|
||||
return target{}, true
|
||||
}
|
||||
|
||||
// retry handles targets again at now and returns those whose lookup or
|
||||
// claim still failed.
|
||||
func (s *Scheduler) retry(ctx context.Context, targets []target, now time.Time) []target {
|
||||
var failed []target
|
||||
for i, t := range targets {
|
||||
if ctx.Err() != nil {
|
||||
return append(failed, targets[i:]...)
|
||||
}
|
||||
if t.lookup {
|
||||
if again, ok := s.fireItem(ctx, t.it, t.loc, t.done, t.until, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if again, ok := s.handleAll(ctx, t, now); !ok {
|
||||
failed = append(failed, again)
|
||||
}
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// handle claims the period of it's fire time f and runs it, or records it
|
||||
// as missed when f is more than CatchUpWindow before now. It reports false
|
||||
// when the claim failed and the period is still unclaimed.
|
||||
func (s *Scheduler) handle(ctx context.Context, it item, f fireTime, now time.Time) bool {
|
||||
at, date := f.at, f.date
|
||||
period := Period{Key: it.rule.PeriodKey(date), FireAt: at, Date: date}
|
||||
if it.rule.NextWeek {
|
||||
period.Week = household.WeekStart(addDays(date, 7))
|
||||
} else {
|
||||
period.Week = household.WeekStart(date)
|
||||
}
|
||||
switch {
|
||||
case it.sch.Key != "":
|
||||
period.Key = it.sch.Key + ":" + period.Key
|
||||
case it.sch.PeriodKey != nil:
|
||||
period.Key = it.sch.PeriodKey(period)
|
||||
}
|
||||
log := s.logger.With("schedule", it.sch.Name, "period", period.Key, "fire_at", at.Format(time.RFC3339))
|
||||
|
||||
claimed, err := s.claim(ctx, it.sch.Name, period.Key, now)
|
||||
if err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: claim period failed; retrying", "error", err, "retry_in", ClaimRetry.String())
|
||||
return false
|
||||
}
|
||||
if !claimed {
|
||||
return true
|
||||
}
|
||||
if late := now.Sub(at); late > CatchUpWindow {
|
||||
log.WarnContext(ctx, "scheduler: period missed; recorded without running", "late", late.String())
|
||||
return true
|
||||
}
|
||||
if err := it.sch.Run(ctx, period); err != nil {
|
||||
log.ErrorContext(ctx, "scheduler: run failed", "error", err)
|
||||
return true
|
||||
}
|
||||
log.InfoContext(ctx, "scheduler: ran")
|
||||
return true
|
||||
}
|
||||
|
||||
// claim inserts schedule_runs(name, key) and reports whether this call
|
||||
// inserted it.
|
||||
func (s *Scheduler) claim(ctx context.Context, name, key string, now time.Time) (bool, error) {
|
||||
n, err := db.New(s.st.Writer).ClaimScheduleRun(ctx, db.ClaimScheduleRunParams{
|
||||
ID: store.NewID(),
|
||||
Schedule: name,
|
||||
PeriodKey: key,
|
||||
CreatedAt: store.Timestamp{Time: now.UTC().Truncate(time.Millisecond)}.String(),
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
// next returns the earliest fire time of any schedule after now, or false
|
||||
// when there is none.
|
||||
func (p *plan) next(ctx context.Context, now time.Time) (time.Time, bool, error) {
|
||||
var next time.Time
|
||||
for _, it := range p.items {
|
||||
f, ok, err := fireAfter(ctx, it.rule, p.loc, now)
|
||||
if err != nil {
|
||||
return time.Time{}, false, fmt.Errorf("%s: %w", it.sch.label(), err)
|
||||
}
|
||||
if ok && (next.IsZero() || f.at.Before(next)) {
|
||||
next = f.at
|
||||
}
|
||||
}
|
||||
return next, !next.IsZero(), nil
|
||||
}
|
||||
|
||||
// window is how many days either side of a date fireBetween and fireAfter
|
||||
// search: more than a week, so a weekly rule always has a fire time on
|
||||
// each side.
|
||||
const window = 8
|
||||
|
||||
// atWindow is how many days either side of a date fireBetween and fireAfter
|
||||
// look up a DailyAt rule's fire times. A period firing further off is
|
||||
// looked up on a later pass, which comes within maxWait.
|
||||
const atWindow = 2
|
||||
|
||||
// fireTime is a fire time and the household-local date of its period.
|
||||
type fireTime struct {
|
||||
at time.Time
|
||||
date store.LocalDate
|
||||
}
|
||||
|
||||
// fireBetween returns the fire times of r in zone loc after from and at or
|
||||
// before t, oldest first.
|
||||
func fireBetween(ctx context.Context, r Rule, loc *time.Location, from, t time.Time) ([]fireTime, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(from) && !f.at.After(t) {
|
||||
out = append(out, f)
|
||||
}
|
||||
}
|
||||
slices.SortFunc(out, func(a, b fireTime) int { return a.at.Compare(b.at) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fireAfter returns the earliest fire time of r in zone loc after t.
|
||||
func fireAfter(ctx context.Context, r Rule, loc *time.Location, t time.Time) (fireTime, bool, error) {
|
||||
fs, err := fireTimes(ctx, r, loc, t)
|
||||
if err != nil {
|
||||
return fireTime{}, false, err
|
||||
}
|
||||
var best fireTime
|
||||
for _, f := range fs {
|
||||
if f.at.After(t) && (best.at.IsZero() || f.at.Before(best.at)) {
|
||||
best = f
|
||||
}
|
||||
}
|
||||
return best, !best.at.IsZero(), nil
|
||||
}
|
||||
|
||||
// fireTimes returns r's fire times for the local dates within window days
|
||||
// of t's, or atWindow days for a DailyAt rule. A Time that fails to
|
||||
// resolve was validated at compile, so it cannot; a DailyAt lookup that
|
||||
// fails is an error.
|
||||
func fireTimes(ctx context.Context, r Rule, loc *time.Location, t time.Time) ([]fireTime, error) {
|
||||
today := household.Today(t, loc)
|
||||
span := window
|
||||
if r.At != nil {
|
||||
span = atWindow
|
||||
}
|
||||
var out []fireTime
|
||||
for i := -span; i <= span; i++ {
|
||||
d := addDays(today, i)
|
||||
if r.Weekly && weekday(d) != r.Weekday {
|
||||
continue
|
||||
}
|
||||
if r.At != nil {
|
||||
at, ok, err := r.At(ctx, d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fire time of %s: %w", d, err)
|
||||
}
|
||||
if ok {
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
continue
|
||||
}
|
||||
at, err := household.LocalTime(loc, d, r.Time)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("scheduler: validated rule %+v: %v", r, err))
|
||||
}
|
||||
out = append(out, fireTime{at: at, date: d})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// addDays returns d plus n calendar days.
|
||||
func addDays(d store.LocalDate, n int) store.LocalDate {
|
||||
y, m, day := d.Date()
|
||||
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
|
||||
}
|
||||
|
||||
// weekday returns the day of the week of d.
|
||||
func weekday(d store.LocalDate) time.Weekday {
|
||||
y, m, day := d.Date()
|
||||
return time.Date(y, m, day, 0, 0, 0, 0, time.UTC).Weekday()
|
||||
}
|
||||
@@ -0,0 +1,805 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"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/internal/suggest"
|
||||
)
|
||||
|
||||
// The tests in this file run suggest's suggestions_weekly schedule on the
|
||||
// fake clock against the real suggestion service, job runner, harness and
|
||||
// notification outbox, with the AI and recipe sources faked.
|
||||
|
||||
// suggestEnv is a suggestion service over a scheduler env's store, the
|
||||
// "process" a scheduler runs it in. side answers suggest_side_quests and
|
||||
// model normalize_recipe while grounding.
|
||||
type suggestEnv struct {
|
||||
e *env
|
||||
svc *suggest.Service
|
||||
side *llm.Fake
|
||||
model *llm.Fake
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
logs *syncBuffer
|
||||
stop func()
|
||||
}
|
||||
|
||||
// syncBuffer collects log output written by concurrent goroutines.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
}
|
||||
|
||||
func (b *syncBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.Write(p)
|
||||
}
|
||||
|
||||
func (b *syncBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.buf.String()
|
||||
}
|
||||
|
||||
// regulars stores the Regulars Beef Chili and Chicken Fajitas, so a batch
|
||||
// has Regulars to suggest from.
|
||||
func (e *env) regulars() {
|
||||
e.t.Helper()
|
||||
for _, name := range []string{"Beef Chili", "Chicken Fajitas"} {
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(e.st.Writer).CreateDinner(context.Background(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
GroundingStatus: "manual",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// suggestions builds a suggestion service over e with a started job
|
||||
// runner, which stop stops (and the test's end, if not before).
|
||||
func (e *env) suggestions() *suggestEnv {
|
||||
e.t.Helper()
|
||||
s := &suggestEnv{
|
||||
e: e,
|
||||
side: &llm.Fake{},
|
||||
model: &llm.Fake{},
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
logs: &syncBuffer{},
|
||||
}
|
||||
g := recipestest.NewGrounder(e.t, e.st, e.t.TempDir(), s.search, s.pages, s.images, s.model)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return s.side, nil },
|
||||
Store: e.st,
|
||||
Guard: guard,
|
||||
}
|
||||
suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) {
|
||||
return skills.SuggestSideQuests(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.svc, err = suggest.NewService(e.st, g, suggester, runner, notifytest.New(e.t, e.st), logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
var once sync.Once
|
||||
s.stop = func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
e.t.Cleanup(s.stop)
|
||||
return s
|
||||
}
|
||||
|
||||
// week scripts the next batch: suggest_side_quests answers with the
|
||||
// Side quests named names, each grounded by its own recipe.
|
||||
func (s *suggestEnv) week(names ...string) {
|
||||
s.e.t.Helper()
|
||||
var quests []string
|
||||
for _, name := range names {
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
s.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
s.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
s.images.JPEGs[photo] = recipestest.JPEG(s.e.t, 400, 320, uint8(len(s.images.JPEGs)+1))
|
||||
s.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35))
|
||||
quests = append(quests, fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":"Like your Beef Chili.","cited_regulars":["Beef Chili"],"search_query":%q}`, name, name))
|
||||
}
|
||||
js := `{"suggestions":[` + strings.Join(quests, ",") + `]}`
|
||||
s.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// batch is a suggestion_batches row.
|
||||
type batch struct {
|
||||
ID, Trigger, Status string
|
||||
}
|
||||
|
||||
// batches returns every suggestion batch, oldest first.
|
||||
func (e *env) batches() []batch {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id, trigger, status FROM suggestion_batches ORDER BY created_at, id`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []batch
|
||||
for rows.Next() {
|
||||
var b batch
|
||||
if err := rows.Scan(&b.ID, &b.Trigger, &b.Status); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settled waits until no batch is pending or running and returns the
|
||||
// batches.
|
||||
func (e *env) settled() []batch {
|
||||
e.t.Helper()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
bs := e.batches()
|
||||
busy := false
|
||||
for _, b := range bs {
|
||||
busy = busy || b.Status == suggest.BatchPending || b.Status == suggest.BatchRunning
|
||||
}
|
||||
if !busy {
|
||||
return bs
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
e.t.Fatalf("batches still under way: %+v", bs)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// wantBatches fails unless the batches are scheduled with statuses want.
|
||||
func wantBatches(t *testing.T, what string, got []batch, want ...string) {
|
||||
t.Helper()
|
||||
ok := len(got) == len(want)
|
||||
for i := 0; ok && i < len(got); i++ {
|
||||
ok = got[i].Trigger == suggest.TriggerScheduled && got[i].Status == want[i]
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("%s: batches = %+v, want scheduled %q", what, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// notified returns the new_suggestions keys stored, oldest first, and
|
||||
// reports an error for any whose title is not the one titles gives it.
|
||||
func (e *env) notified(titles ...string) []string {
|
||||
e.t.Helper()
|
||||
var keys []string
|
||||
rows := notifytest.Rows(e.t, e.st, notify.NewSuggestions)
|
||||
for i, r := range rows {
|
||||
keys = append(keys, r.Key)
|
||||
if i < len(titles) && r.Payload.Title != titles[i] {
|
||||
e.t.Errorf("new_suggestions %s title = %q, want %q", r.Key, r.Payload.Title, titles[i])
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyOneBatchAndNotificationPerWeek: at the seeded
|
||||
// Sunday 08:00 the schedule starts one scheduled batch a week, period
|
||||
// suggestions_weekly:YYYY-Www, and each tells the household once, with
|
||||
// new_suggestions:YYYY-Www counting its new Side quests.
|
||||
func TestSuggestionsWeeklyOneBatchAndNotificationPerWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
s.week("Lamb Kofta", "Black Bean Tostadas", "Pork Carnitas")
|
||||
|
||||
// Wednesday Oct 7: the Sunday Oct 4 08:00 fire is days ago, missed.
|
||||
r := e.start(at(2026, 10, 7, 12, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 7, 59))
|
||||
wantBatches(t, "before Sunday 08:00", e.settled())
|
||||
wantKeys(t, "new_suggestions before Sunday 08:00", e.notified(), nil)
|
||||
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "at Sunday 08:00", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41", e.notified("2 new Side quests are in"), []string{"new_suggestions:2026-W41"})
|
||||
|
||||
r.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "rest of W41", e.settled(), suggest.BatchDone)
|
||||
r.runUntil(at(2026, 10, 18, 9, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions of W41 and W42", e.notified("2 new Side quests are in", "3 new Side quests are in"),
|
||||
[]string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41", "2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyFollowsSettings: the schedule fires at the
|
||||
// household's suggestions_dow and suggestions_time.
|
||||
func TestSuggestionsWeeklyFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
e.update(household.SettingsPatch{SuggestionsDow: ptr(3), SuggestionsTime: ptr("18:30")})
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), s.svc.WeeklySchedule()) // Monday
|
||||
r.runUntil(at(2026, 10, 14, 18, 29)) // Wednesday
|
||||
wantBatches(t, "before Wednesday 18:30", e.settled())
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantBatches(t, "at Wednesday 18:30", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified("1 new Side quest is in"), []string{"new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyAIFailureSendsNothing: when suggest_side_quests
|
||||
// fails the batch fails with the failure logged and recorded in ai_runs,
|
||||
// and the household is not notified.
|
||||
func TestSuggestionsWeeklyAIFailureSendsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "after the AI failed", e.settled(), suggest.BatchFailed)
|
||||
wantKeys(t, "new_suggestions after the AI failed", e.notified(), nil)
|
||||
// W40 is the boot's missed period, recorded without running.
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
|
||||
var status, msg string
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, COALESCE(error, '') FROM ai_runs WHERE skill = 'suggest_side_quests'`).Scan(&status, &msg); err != nil {
|
||||
t.Fatalf("ai_runs row of suggest_side_quests: %v", err)
|
||||
}
|
||||
if status != string(harness.StatusProviderError) || msg == "" {
|
||||
t.Errorf("ai_runs suggest_side_quests = %s %q, want %s with its message", status, msg, harness.StatusProviderError)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "suggestion batch failed") || !strings.Contains(logs, "reason="+suggest.ReasonAIFailed) {
|
||||
t.Errorf("logs = %s, want the batch failure with reason %s", logs, suggest.ReasonAIFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyNoDuplicateAfterRestart: a restart after the week's
|
||||
// batch ran, still within the catch-up window, starts no second batch and
|
||||
// sends no second notification: the period is already claimed.
|
||||
func TestSuggestionsWeeklyNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde", "Chicken Shawarma Bowls")
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
wantBatches(t, "first process", e.settled(), suggest.BatchDone)
|
||||
r.stop()
|
||||
s.stop()
|
||||
|
||||
// The restarted process has a new job runner, suggestion service and
|
||||
// scheduler; it would answer a second batch if one were started.
|
||||
s2 := e.suggestions()
|
||||
s2.week("Lamb Kofta")
|
||||
r2 := e.start(at(2026, 10, 11, 9, 0), s2.svc.WeeklySchedule())
|
||||
r2.runUntil(at(2026, 10, 18, 7, 59))
|
||||
wantBatches(t, "after restart", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after restart", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.SuggestionsWeekly), []string{"2026-W40", "2026-W41"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyCatchUpAfterTheWeekEnds: a Sunday fire caught up on
|
||||
// Monday, in the next ISO week, still runs W41's batch keyed by W41, and
|
||||
// the next Sunday's batch notifies for W42.
|
||||
func TestSuggestionsWeeklyCatchUpAfterTheWeekEnds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.week("Chili Verde")
|
||||
s.week("Lamb Kofta")
|
||||
|
||||
r := e.start(at(2026, 10, 12, 7, 0), s.svc.WeeklySchedule()) // Monday, 23h late
|
||||
wantBatches(t, "after catch-up", e.settled(), suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions after catch-up", e.notified(), []string{"new_suggestions:2026-W41"})
|
||||
r.runUntil(at(2026, 10, 18, 8, 0))
|
||||
wantBatches(t, "W42", e.settled(), suggest.BatchDone, suggest.BatchDone)
|
||||
wantKeys(t, "new_suggestions", e.notified(), []string{"new_suggestions:2026-W41", "new_suggestions:2026-W42"})
|
||||
}
|
||||
|
||||
// TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay: a period that
|
||||
// fires while a batch is pending starts no second one and says so.
|
||||
func TestSuggestionsWeeklyStartsNoBatchWhileOneIsUnderWay(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.regulars()
|
||||
s := e.suggestions()
|
||||
s.stop() // the manual batch stays pending
|
||||
manual, err := s.svc.StartBatch(context.Background(), suggest.TriggerManual)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 11, 7, 0), s.svc.WeeklySchedule())
|
||||
r.runUntil(at(2026, 10, 11, 8, 0))
|
||||
if bs := e.batches(); len(bs) != 1 || bs[0].ID != manual.ID {
|
||||
t.Fatalf("batches = %+v, want only the manual batch %s", bs, manual.ID)
|
||||
}
|
||||
if logs := s.logs.String(); !strings.Contains(logs, "scheduled suggestion batch not started") || !strings.Contains(logs, "period=2026-W41") {
|
||||
t.Errorf("logs = %s, want the skipped period logged", logs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package scheduler_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/chat/chattest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/notify/notifytest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// The tests in this file run shopping's shopping_chat_autosend and
|
||||
// week_rollover schedules, and harness's ai_runs_purge, on the fake clock
|
||||
// against the real shopping service, Google Chat client (posting to a fake
|
||||
// webhook), notification preferences and ai_runs purge. The seeded
|
||||
// autosend is Saturday 08:00 in Chicago.
|
||||
|
||||
// w41 is the Monday of 2026-W41.
|
||||
var w41 = mustLocalDate("2026-10-05")
|
||||
|
||||
// shopEnv is a shopping service over a scheduler env's store. Its job
|
||||
// runner never starts, so Generate's title jobs stay queued and every
|
||||
// Supply run stays untitled until Freeze titles it, which fails.
|
||||
type shopEnv struct {
|
||||
e *env
|
||||
svc *shopping.Service
|
||||
chat *chattest.Chat
|
||||
cs *chat.Service
|
||||
plans *planEnv
|
||||
logs *syncBuffer
|
||||
}
|
||||
|
||||
// shopping builds a shopping service over e, with Regulars to plan from.
|
||||
func (e *env) shopping() *shopEnv {
|
||||
e.t.Helper()
|
||||
s := &shopEnv{e: e, chat: chattest.New(e.t), plans: e.plans(e.pool()), logs: &syncBuffer{}}
|
||||
key := make([]byte, secretbox.KeySize)
|
||||
rand.Read(key)
|
||||
box, err := secretbox.New(base64.StdEncoding.EncodeToString(key))
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
s.cs, err = chat.NewService(e.st, box, publicURL, s.chat.Transport())
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(s.logs, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
noTitle := func(context.Context, string, skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
return skills.ArchiveTitle{}, errors.New("no AI in this test")
|
||||
}
|
||||
s.svc, err = shopping.NewService(e.st, noTitle, runner, notifytest.New(e.t, e.st), s.cs, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// list plans the week starting weekStart and generates its Supply run.
|
||||
func (s *shopEnv) list(weekStart store.LocalDate) {
|
||||
s.e.t.Helper()
|
||||
s.plans.planWeek(weekStart)
|
||||
if _, err := s.svc.Generate(context.Background(), weekStart); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// webhook stores the fake Google Chat webhook.
|
||||
func (s *shopEnv) webhook() {
|
||||
s.e.t.Helper()
|
||||
if _, err := s.cs.Put(context.Background(), chattest.Webhook, "Family"); err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// get returns the Supply run of the week starting weekStart.
|
||||
func (s *shopEnv) get(weekStart store.LocalDate) shopping.List {
|
||||
s.e.t.Helper()
|
||||
l, err := s.svc.Get(context.Background(), weekStart)
|
||||
if err != nil {
|
||||
s.e.t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// wantLogged fails unless the service logged want and logged no error.
|
||||
func (s *shopEnv) wantLogged(want string) {
|
||||
s.e.t.Helper()
|
||||
if logs := s.logs.String(); !strings.Contains(logs, want) || strings.Contains(logs, "level=ERROR") {
|
||||
s.e.t.Errorf("logs = %s, want %q and no error", logs, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSendsComingWeek: at the seeded Saturday 08:00 the
|
||||
// schedule posts the coming week's Supply run, W43's, to Google Chat
|
||||
// through SendToChat, in that week's thread, and records sent_to_chat_at;
|
||||
// its period is claimed in schedule_runs as list_ready:chat:2026-10-19,
|
||||
// the coming week's start. It posts once.
|
||||
func TestChatAutosendSendsComingWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
// Saturday 07:00: the previous Saturday's period is past the
|
||||
// catch-up window, so it is recorded as missed.
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 7, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 || s.get(w43).SentToChatAt != nil {
|
||||
t.Fatalf("before 08:00: %d posts, sent_to_chat_at %v, want none", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 17, 8, 0))
|
||||
posts := s.chat.Posts()
|
||||
if len(posts) != 1 {
|
||||
t.Fatalf("at 08:00: %d posts, want 1", len(posts))
|
||||
}
|
||||
if !strings.HasPrefix(posts[0].Text(), "Supply run · Week 43 · Oct 19 – 25") {
|
||||
t.Errorf("posted %q, want the W43 Supply run", posts[0].Text())
|
||||
}
|
||||
if got := posts[0].Query.Get("threadKey"); got != chat.ThreadKey(w43) {
|
||||
t.Errorf("posted in thread %q, want %q", got, chat.ThreadKey(w43))
|
||||
}
|
||||
if s.get(w43).SentToChatAt == nil {
|
||||
t.Error("sent_to_chat_at not recorded")
|
||||
}
|
||||
s.wantLogged("Supply run sent to Google Chat")
|
||||
s.wantLogged("period=list_ready:chat:2026-10-19")
|
||||
|
||||
r.runUntil(at(2026, 10, 24, 7, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("a week on: %d posts, want 1", n)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
// TestChatAutosendFollowsSettings: chat_autosend_dow and
|
||||
// chat_autosend_time move the send without a restart, here to Friday
|
||||
// 18:00, still for the coming week.
|
||||
func TestChatAutosendFollowsSettings(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
|
||||
r := e.start(at(2026, 10, 16, 12, 0), s.svc.ChatAutosendSchedule())
|
||||
e.update(household.SettingsPatch{ChatAutosendDow: ptr(5), ChatAutosendTime: ptr("18:00")})
|
||||
r.changed()
|
||||
r.runUntil(at(2026, 10, 16, 17, 59))
|
||||
if n := len(s.chat.Posts()); n != 0 {
|
||||
t.Fatalf("before Friday 18:00: %d posts, want 0", n)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 16, 18, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 || s.get(w43).SentToChatAt == nil {
|
||||
t.Fatalf("at Friday 18:00: %d posts, sent_to_chat_at %v, want 1 and set", n, s.get(w43).SentToChatAt)
|
||||
}
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("Saturday: %d posts, want still 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendSkips: the schedule posts nothing, records no send and
|
||||
// fails nothing when the coming week's Supply run was already sent, when
|
||||
// the week has no Supply run, when list_ready is switched off for Google
|
||||
// Chat, and when no webhook is stored. Each period is still claimed and
|
||||
// the reason logged.
|
||||
func TestChatAutosendSkips(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
setup func(t *testing.T, s *shopEnv)
|
||||
// posts is how many posts the setup itself made.
|
||||
posts int
|
||||
reason string
|
||||
}{
|
||||
{
|
||||
name: "already sent",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.svc.SendToChat(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
posts: 1,
|
||||
reason: shopping.AutosendAlreadySent,
|
||||
},
|
||||
{
|
||||
name: "no list",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w42)
|
||||
s.webhook()
|
||||
},
|
||||
reason: shopping.AutosendNoList,
|
||||
},
|
||||
{
|
||||
name: "chat off for list_ready",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
if _, err := s.e.st.Writer.Exec(`UPDATE notification_prefs SET chat = 0 WHERE event = 'list_ready'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
reason: shopping.AutosendChatOff,
|
||||
},
|
||||
{
|
||||
name: "no webhook",
|
||||
setup: func(t *testing.T, s *shopEnv) {
|
||||
s.list(w43)
|
||||
},
|
||||
reason: shopping.AutosendNotConfigured,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
c.setup(t, s)
|
||||
var sentBefore *string
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil {
|
||||
sentBefore = l.SentToChatAt
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 17, 7, 0), s.svc.ChatAutosendSchedule())
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
if n := len(s.chat.Posts()); n != c.posts {
|
||||
t.Errorf("posts = %d, want %d", n, c.posts)
|
||||
}
|
||||
if l, err := s.svc.Get(context.Background(), w43); err == nil && !equalPtr(l.SentToChatAt, sentBefore) {
|
||||
t.Errorf("sent_to_chat_at = %v, want %v", l.SentToChatAt, sentBefore)
|
||||
}
|
||||
s.wantLogged("Supply run not sent to Google Chat: " + c.reason)
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatAutosendFailureSurfaces: a send Google Chat refuses is the
|
||||
// period's error, logged as such; the period stays claimed and is not
|
||||
// retried, and sent_to_chat_at is not recorded.
|
||||
func TestChatAutosendFailureSurfaces(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w43)
|
||||
s.webhook()
|
||||
s.chat.SetStatus(500)
|
||||
|
||||
var logs syncBuffer
|
||||
sched, err := scheduler.New(e.st, e.settings, slog.New(slog.NewTextHandler(&logs, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clock := newFakeClock(at(2026, 10, 17, 7, 0))
|
||||
scheduler.SetClock(sched, clock.Now, clock.After)
|
||||
if err := sched.Register(s.svc.ChatAutosendSchedule()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
sched.Wait()
|
||||
})
|
||||
if err := sched.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := &running{t: t, clock: clock, stop: cancel}
|
||||
r.idle()
|
||||
r.runUntil(at(2026, 10, 17, 9, 0))
|
||||
|
||||
if n := len(s.chat.Posts()); n != 1 {
|
||||
t.Errorf("posts = %d, want the one refused", n)
|
||||
}
|
||||
if s.get(w43).SentToChatAt != nil {
|
||||
t.Error("sent_to_chat_at recorded for a refused send")
|
||||
}
|
||||
if got := logs.String(); !strings.Contains(got, "level=ERROR") || !strings.Contains(got, "scheduler: run failed") || !strings.Contains(got, "schedule=shopping_chat_autosend") {
|
||||
t.Errorf("scheduler logs = %s, want the refused send logged as a failed run", got)
|
||||
}
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.ShoppingChatAutosend), []string{"list_ready:chat:2026-10-12", "list_ready:chat:2026-10-19"})
|
||||
}
|
||||
|
||||
func equalPtr(a, b *string) bool {
|
||||
return (a == nil && b == nil) || (a != nil && b != nil && *a == *b)
|
||||
}
|
||||
|
||||
// TestWeekRolloverFreezesPreviousWeek: at Monday 00:05 the schedule
|
||||
// freezes exactly the week that has just ended into the Supply archive,
|
||||
// not the week before it nor the new week; the next Monday freezes the
|
||||
// next week; a Monday whose previous week has no Supply run freezes
|
||||
// nothing and fails nothing. Its periods are week_rollover:YYYY-Www of the
|
||||
// Monday.
|
||||
func TestWeekRolloverFreezesPreviousWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
s := e.shopping()
|
||||
s.list(w41)
|
||||
s.list(w42)
|
||||
s.list(w43)
|
||||
|
||||
status := func() []string {
|
||||
return []string{s.get(w41).Status, s.get(w42).Status, s.get(w43).Status}
|
||||
}
|
||||
cur, arc := shopping.StatusCurrent, shopping.StatusArchived
|
||||
|
||||
// Sunday 23:00: last Monday's rollover is past the catch-up window and
|
||||
// recorded as missed, so W41 is never frozen by it.
|
||||
r := e.start(at(2026, 10, 18, 23, 0), s.svc.RolloverSchedule())
|
||||
r.runUntil(at(2026, 10, 19, 0, 4))
|
||||
wantKeys(t, "before Monday 00:05", status(), []string{cur, cur, cur})
|
||||
|
||||
r.runUntil(at(2026, 10, 19, 0, 5))
|
||||
wantKeys(t, "at Monday 00:05", status(), []string{cur, arc, cur})
|
||||
if s.get(w42).Title != nil {
|
||||
t.Errorf("W42 title = %q, want frozen untitled", *s.get(w42).Title)
|
||||
}
|
||||
if _, err := s.svc.Generate(context.Background(), w42); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("generate W42 after the rollover: %v, want ErrFrozen", err)
|
||||
}
|
||||
|
||||
r.runUntil(at(2026, 10, 26, 0, 5))
|
||||
wantKeys(t, "next Monday", status(), []string{cur, arc, arc})
|
||||
|
||||
r.runUntil(at(2026, 11, 2, 0, 5))
|
||||
s.wantLogged("week rollover froze nothing: the week has no Supply run")
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.WeekRollover), []string{"2026-W42", "2026-W43", "2026-W44", "2026-W45"})
|
||||
}
|
||||
|
||||
// aiRun stores an ai_runs row created at created and returns its id.
|
||||
func (e *env) aiRun(created time.Time) string {
|
||||
e.t.Helper()
|
||||
id := store.NewID()
|
||||
ts := store.Timestamp{Time: created.UTC()}.String()
|
||||
if err := db.New(e.st.Writer).CreateAIRun(context.Background(), db.CreateAIRunParams{
|
||||
ID: id, Skill: "draft_week", SkillVersion: "1", Provider: "anthropic", Model: "claude-test",
|
||||
Temperature: 0.7, Status: "ok", RequestRedacted: "{}", ResponseRedacted: sql.NullString{String: "{}", Valid: true},
|
||||
CreatedAt: ts, UpdatedAt: ts,
|
||||
}); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// aiRuns returns the ids of the ai_runs rows, oldest first.
|
||||
func (e *env) aiRuns() []string {
|
||||
e.t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT id FROM ai_runs ORDER BY created_at`)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var ids []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// TestAIRunsPurgeDaily: every day at 03:00 the schedule deletes the
|
||||
// ai_runs rows created more than 30 days before, and no younger row; its
|
||||
// periods are ai_runs_purge:YYYY-MM-DD.
|
||||
func TestAIRunsPurgeDaily(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.aiRun(at(2026, 9, 10, 12, 0)) // older than Sep 11 03:00
|
||||
edge := e.aiRun(at(2026, 9, 12, 2, 59))
|
||||
young := e.aiRun(at(2026, 9, 12, 3, 1))
|
||||
recent := e.aiRun(at(2026, 10, 11, 12, 0))
|
||||
|
||||
sch, err := harness.PurgeSchedule(e.st, slog.New(slog.NewTextHandler(&syncBuffer{}, nil)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Monday 02:00: the catch-up of Sunday 03:00 purges before Sep 11 03:00.
|
||||
r := e.start(at(2026, 10, 12, 2, 0), sch)
|
||||
wantKeys(t, "after the catch-up", e.aiRuns(), []string{edge, young, recent})
|
||||
|
||||
r.runUntil(at(2026, 10, 12, 2, 59))
|
||||
wantKeys(t, "before 03:00", e.aiRuns(), []string{edge, young, recent})
|
||||
r.runUntil(at(2026, 10, 12, 3, 0))
|
||||
wantKeys(t, "at 03:00", e.aiRuns(), []string{young, recent})
|
||||
r.runUntil(at(2026, 10, 13, 3, 0))
|
||||
wantKeys(t, "next day", e.aiRuns(), []string{recent})
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.AIRunsPurge), []string{"2026-10-11", "2026-10-12", "2026-10-13"})
|
||||
}
|
||||
|
||||
// TestPurgeScheduleRejectsNil: the purge schedule needs a store and a
|
||||
// logger.
|
||||
func TestPurgeScheduleRejectsNil(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := harness.PurgeSchedule(nil, slog.Default()); err == nil {
|
||||
t.Error("nil store accepted")
|
||||
}
|
||||
if _, err := harness.PurgeSchedule(e.st, nil); err == nil {
|
||||
t.Error("nil logger accepted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user