CI / ci (push) Successful in 8m28s
Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
371 lines
14 KiB
Go
371 lines
14 KiB
Go
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)
|
|
}
|
|
}
|