The plan service read "today" from time.Now, so the plan-draft schedule tests could not pin the plan_ready title on the fake clock. NewService now takes a clock; the server passes the scheduler's Now. The plan-draft tests read the started scheduler's fake clock and assert each plan_ready row's title and channel alongside the existing key assertions. Maestro task ece60217-04d0-43a4-9690-4aba2a00d420 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
470 lines
17 KiB
Go
470 lines
17 KiB
Go
package scheduler_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"slices"
|
|
"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() {}, time.Now)
|
|
}
|
|
|
|
// scheduledPlans is plans whose today is read from the clock of the
|
|
// scheduler start last started, as in the server, so a draft's plan_ready
|
|
// title follows the fake clock. It drafts only once a scheduler started.
|
|
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
|
e.t.Helper()
|
|
return e.newPlans(ids, func() {}, func() time.Time {
|
|
s := e.sched.Load()
|
|
if s == nil {
|
|
panic("plan service read the clock before a scheduler started")
|
|
}
|
|
return s.Now()
|
|
})
|
|
}
|
|
|
|
// 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, time.Now)
|
|
}
|
|
|
|
// newPlans is plans whose service calls wake after a plan change and reads
|
|
// today from now.
|
|
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *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, now, 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
|
|
}
|
|
|
|
// readyRow is a stored plan_ready notification as these tests check it.
|
|
type readyRow struct {
|
|
Key string
|
|
Channel notify.Channel
|
|
Title string
|
|
}
|
|
|
|
// planReadyRows returns every plan_ready notification stored, oldest
|
|
// first, each week's Google Chat row before its push row.
|
|
func (e *env) planReadyRows() []readyRow {
|
|
e.t.Helper()
|
|
var out []readyRow
|
|
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
|
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// nextWeekReady is the plan_ready rows of a draft of the week starting
|
|
// week, drafted the week before: one to Google Chat and one to push.
|
|
func nextWeekReady(week string) []readyRow {
|
|
const title = "Next week's plan is ready"
|
|
key := string(notify.PlanReady) + ":" + week
|
|
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
|
|
}
|
|
|
|
// wantReady fails unless the plan_ready rows are got.
|
|
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
|
|
t.Helper()
|
|
all := slices.Concat(want...)
|
|
if !slices.Equal(got, all) {
|
|
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
|
|
}
|
|
}
|
|
|
|
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.scheduledPlans(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"})
|
|
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("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"})
|
|
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("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.scheduledPlans(e.pool())
|
|
p.reply(w43)
|
|
p.reply(w43) // a second draft would succeed too
|
|
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
|
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"})
|
|
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
|
before, err := p.svc.Get(context.Background(), w43)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
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"})
|
|
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("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.scheduledPlans(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)
|
|
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
|
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.scheduledPlans(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"})
|
|
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("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.scheduledPlans(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)
|
|
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
|
|
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.scheduledPlans(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)
|
|
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
|
|
}
|
|
|
|
// 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.scheduledPlans(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"})
|
|
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("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"})
|
|
}
|