Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #172.
This commit is contained in:
@@ -77,13 +77,15 @@ func DayOfReminder(date store.LocalDate, dinnerID, name string) notify.Event {
|
||||
}
|
||||
}
|
||||
|
||||
// NewSuggestions is the new_suggestions event of suggestion batch batchID,
|
||||
// made in the week starting on week, naming its Side quests.
|
||||
func NewSuggestions(batchID string, week store.LocalDate, names []string) notify.Event {
|
||||
// NewSuggestions is the new_suggestions event keyed by id, the ISO week
|
||||
// ("2026-W41") of a scheduled suggestion batch or the batch id of a manual
|
||||
// one, of the week starting on week: how many new Side quests there are,
|
||||
// and their names.
|
||||
func NewSuggestions(id string, week store.LocalDate, names []string) notify.Event {
|
||||
return notify.Event{
|
||||
Type: notify.NewSuggestions,
|
||||
Key: string(notify.NewSuggestions) + ":" + batchID,
|
||||
Title: "New Side quests are in",
|
||||
Key: string(notify.NewSuggestions) + ":" + id,
|
||||
Title: count(len(names), "new Side quest is in", "new Side quests are in"),
|
||||
Body: nameList(names),
|
||||
URL: "/discover",
|
||||
Week: week,
|
||||
|
||||
@@ -110,9 +110,19 @@ func TestMessages(t *testing.T) {
|
||||
key: "new_suggestions:01J9BATCH",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "New Side quests are in",
|
||||
title: "5 new Side quests are in",
|
||||
body: "Lemon orzo, Bibimbap, Shakshuka + 2 more",
|
||||
chatT: "New Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
chatT: "5 new Side quests are in: Lemon orzo, Bibimbap, Shakshuka + 2 more. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "new suggestions of a scheduled week",
|
||||
ev: NewSuggestions("2026-W42", week, []string{"Pho"}),
|
||||
key: "new_suggestions:2026-W42",
|
||||
url: "/discover",
|
||||
week: "2026-10-12",
|
||||
title: "1 new Side quest is in",
|
||||
body: "Pho",
|
||||
chatT: "1 new Side quest is in: Pho. Open Discover →",
|
||||
},
|
||||
{
|
||||
name: "rotation review",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+97
-37
@@ -22,6 +22,9 @@ import (
|
||||
// BatchJob is the KindSuggestionBatch payload: the batch to run.
|
||||
type BatchJob struct {
|
||||
BatchID string `json:"batch_id"`
|
||||
// Week is the YYYY-MM-DD Monday of the ISO week a scheduled batch is
|
||||
// for, which keys its new_suggestions; empty for any other trigger.
|
||||
Week string `json:"week,omitempty"`
|
||||
}
|
||||
|
||||
// Batch stages, as the job's result reports them.
|
||||
@@ -50,19 +53,22 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// wanted at a time, until BatchSize are kept or none are left. A Side quest
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. Each round's grounded Side quests are stored before the next
|
||||
// round starts, so the deck fills as the batch goes. The batch ends done
|
||||
// or failed; Side quests stored before a failure stay in the deck. When
|
||||
// recording the batch's end fails, so does the job; when it times out at
|
||||
// shutdown, the job is left running and the batch with it, for the next
|
||||
// start to fail.
|
||||
// instead. The batch ends done or failed. When recording the batch's end
|
||||
// fails, so does the job; when it times out at shutdown, the job is left
|
||||
// running and the batch with it, for the next start to fail.
|
||||
//
|
||||
// A scheduled or manual batch emits new_suggestions, keyed by the batch,
|
||||
// in the transaction that first adds Side quests to the deck, so the
|
||||
// household is told of every Side quest stored, whether or not the batch
|
||||
// then ends done: a later round, or recording the batch's end, may fail.
|
||||
// A replenish batch, which tops up the deck as the household swipes, never
|
||||
// notifies. See store.
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
// noticeOf). A scheduled or manual batch notifies: it holds every round's
|
||||
// grounded Side quests and, once the last round is done, stores them all
|
||||
// in one transaction with one new_suggestions counting and naming every
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
// See store.
|
||||
func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batch, err := db.New(s.st.Reader).GetSuggestionBatch(ctx, p.BatchID)
|
||||
if err != nil {
|
||||
@@ -78,7 +84,7 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
if n == 0 {
|
||||
return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID)
|
||||
}
|
||||
res, err := s.fill(ctx, p.BatchID, batch.Trigger != TriggerReplenish, publish)
|
||||
res, err := s.fill(ctx, batch.Trigger, p, publish)
|
||||
if err == nil {
|
||||
if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -98,10 +104,12 @@ func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchRe
|
||||
return batchResult{}, errors.Join(err, finishErr)
|
||||
}
|
||||
|
||||
// fill does runBatch's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. When notifies is true, store emits new_suggestions
|
||||
// with the Side quests it adds.
|
||||
func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publish func(batchResult) error) (batchResult, error) {
|
||||
// fill does runBatch's work for job of a batch with trigger, returning a
|
||||
// *jobs.Failure for a failure the household may see. A batch with a notice
|
||||
// is stored once, when its last round is done; any other batch round by
|
||||
// round.
|
||||
func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publish func(batchResult) error) (batchResult, error) {
|
||||
batchID := job.BatchID
|
||||
res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}}
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
@@ -110,6 +118,10 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
note, err := noticeOf(trigger, job, in.Context.Today)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
out, err := s.suggest(ctx, batchID, in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
@@ -122,34 +134,81 @@ func (s *Service) fill(ctx context.Context, batchID string, notifies bool, publi
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
var week *store.LocalDate
|
||||
if notifies {
|
||||
w := household.WeekStart(in.Context.Today)
|
||||
week = &w
|
||||
}
|
||||
left := out.Suggestions
|
||||
for len(res.SuggestionIDs) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))]
|
||||
// held are a notifying batch's grounded Side quests, not yet stored.
|
||||
var held []groundedQuest
|
||||
for len(res.SuggestionIDs)+len(held) < BatchSize && len(left) > 0 {
|
||||
round := left[:min(BatchSize-len(res.SuggestionIDs)-len(held), len(left))]
|
||||
left = left[len(round):]
|
||||
grounded, err := s.ground(ctx, round)
|
||||
if err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
ids, err := s.store(ctx, batchID, week, grounded)
|
||||
if err != nil {
|
||||
if note != nil {
|
||||
held = append(held, grounded...)
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
res.Dropped += len(grounded) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
if err := publish(res); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
if note != nil {
|
||||
if err := s.storeInto(ctx, &res, note, held); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
ids, err := s.store(ctx, res.BatchID, note, quests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Dropped += len(quests) - len(ids)
|
||||
res.SuggestionIDs = append(res.SuggestionIDs, ids...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// notice is how a batch's new_suggestions is keyed: by id, as
|
||||
// new_suggestions:{id}, for the week starting on week.
|
||||
type notice struct {
|
||||
id string
|
||||
week store.LocalDate
|
||||
}
|
||||
|
||||
// noticeOf returns the notice of job, run for a batch with trigger on
|
||||
// household-local date today, or nil for a replenish batch, which never
|
||||
// notifies. A scheduled batch is keyed by the ISO week it was scheduled
|
||||
// for ("2026-W41"), not the week it happens to run in, so each week's
|
||||
// schedule tells the household once even when its batch runs late; a
|
||||
// manual batch is keyed by its batch id, for the week of today. A job
|
||||
// whose week does not fit its trigger is an error.
|
||||
func noticeOf(trigger string, job BatchJob, today store.LocalDate) (*notice, error) {
|
||||
if trigger != TriggerScheduled {
|
||||
if job.Week != "" {
|
||||
return nil, fmt.Errorf("suggest: %s batch %s has a week", trigger, job.BatchID)
|
||||
}
|
||||
if trigger == TriggerReplenish {
|
||||
return nil, nil
|
||||
}
|
||||
return ¬ice{id: job.BatchID, week: household.WeekStart(today)}, nil
|
||||
}
|
||||
week, err := store.ParseLocalDate(job.Week)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week: %w", job.BatchID, err)
|
||||
}
|
||||
if household.WeekStart(week) != week {
|
||||
return nil, fmt.Errorf("suggest: scheduled batch %s week %s is not a Monday", job.BatchID, week)
|
||||
}
|
||||
return ¬ice{id: household.PeriodKey(week), week: week}, nil
|
||||
}
|
||||
|
||||
// request reads what suggest_side_quests is given: the household context,
|
||||
// the deck's names and every passed name (ListPassedNames). A household with no Regulars
|
||||
// fails with ReasonNoRegulars.
|
||||
@@ -224,13 +283,14 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When week is not nil and a Side quest is stored, new_suggestions:{batchID}
|
||||
// naming those stored is emitted in the same transaction, for the week
|
||||
// starting week, so the Side quests and their notification commit together
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
// transaction, so the Side quests and their notification commit together
|
||||
// or not at all: a notification that cannot be stored fails the store, and
|
||||
// the batch with it. The event's key makes the first round to store Side
|
||||
// quests the one that notifies; a later round's emit stores nothing.
|
||||
func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDate, quests []groundedQuest) ([]string, error) {
|
||||
// the batch with it. A notifying batch calls store once, with all its
|
||||
// Side quests, so the count is the batch's (see runBatch). This is the
|
||||
// only place new_suggestions is emitted.
|
||||
func (s *Service) store(ctx context.Context, batchID string, note *notice, quests []groundedQuest) ([]string, error) {
|
||||
ids := []string{}
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
@@ -298,15 +358,15 @@ func (s *Service) store(ctx context.Context, batchID string, week *store.LocalDa
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
if week == nil || len(stored) == 0 {
|
||||
if note == nil || len(stored) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(batchID, *week, stored))
|
||||
return s.notifier.EmitTx(ctx, tx, chat.NewSuggestions(note.id, note.week, stored))
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if week != nil && len(stored) > 0 {
|
||||
if note != nil && len(stored) > 0 {
|
||||
s.notifier.Wake()
|
||||
}
|
||||
return ids, nil
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package suggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/scheduler"
|
||||
)
|
||||
|
||||
// WeeklySchedule is the suggestions_weekly schedule: at the household's
|
||||
// suggestions_dow and suggestions_time it starts a scheduled batch for the
|
||||
// ISO week of the fire date, its period scheduler.Weekly keys
|
||||
// suggestions_weekly:YYYY-Www. The batch tells the household of the Side
|
||||
// quests it adds with new_suggestions:YYYY-Www for that week, once; a batch
|
||||
// that fails before adding any, as when the AI fails, sends nothing. When a
|
||||
// batch is already pending or running the period starts none, and says so.
|
||||
func (s *Service) WeeklySchedule() scheduler.Schedule {
|
||||
return scheduler.Schedule{
|
||||
Name: scheduler.SuggestionsWeekly,
|
||||
Rule: func(settings household.Settings) scheduler.Rule {
|
||||
return scheduler.Weekly(time.Weekday(settings.SuggestionsDow), settings.SuggestionsTime)
|
||||
},
|
||||
Run: func(ctx context.Context, p scheduler.Period) error {
|
||||
b, err := s.StartScheduledBatch(ctx, p.Week)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !b.Created {
|
||||
s.logger.WarnContext(ctx, "scheduled suggestion batch not started: a batch is already under way",
|
||||
"period", p.Key, "batch_id", b.ID, "trigger", b.Trigger)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"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/dinners"
|
||||
"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/recipes"
|
||||
@@ -154,17 +155,35 @@ type Batch struct {
|
||||
Created bool `json:"-"`
|
||||
}
|
||||
|
||||
// StartBatch creates a batch with trigger and enqueues the
|
||||
// StartBatch creates a manual or replenish batch and enqueues the
|
||||
// KindSuggestionBatch job that runs it, unless a batch is already pending
|
||||
// or running, which it returns instead with Created false.
|
||||
// or running, which it returns instead with Created false. A scheduled
|
||||
// batch is started by StartScheduledBatch.
|
||||
func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) {
|
||||
if trigger == TriggerScheduled {
|
||||
return Batch{}, errors.New("suggest: a scheduled batch is started by StartScheduledBatch")
|
||||
}
|
||||
if !slices.Contains(triggers, trigger) {
|
||||
return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger)
|
||||
}
|
||||
return s.start(ctx, trigger, "")
|
||||
}
|
||||
|
||||
// StartScheduledBatch is StartBatch for a scheduled batch for the ISO week
|
||||
// starting on Monday week, which keys its new_suggestions (see noticeOf).
|
||||
func (s *Service) StartScheduledBatch(ctx context.Context, week store.LocalDate) (Batch, error) {
|
||||
if week.IsZero() || household.WeekStart(week) != week {
|
||||
return Batch{}, fmt.Errorf("suggest: scheduled batch week %s is not a Monday", week)
|
||||
}
|
||||
return s.start(ctx, TriggerScheduled, week.String())
|
||||
}
|
||||
|
||||
// start does StartBatch's work for a batch with trigger and the job week.
|
||||
func (s *Service) start(ctx context.Context, trigger, week string) (Batch, error) {
|
||||
var b Batch
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
var err error
|
||||
b, err = s.startBatch(ctx, tx, trigger)
|
||||
b, err = s.startBatch(ctx, tx, trigger, week)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
@@ -228,10 +247,11 @@ func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) {
|
||||
return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil
|
||||
}
|
||||
|
||||
// startBatch does StartBatch's work in tx: the batch and the job that runs
|
||||
// it are committed together, so a batch is never seen without its job. The
|
||||
// caller wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) {
|
||||
// startBatch does StartBatch's work in tx, for a batch with trigger whose
|
||||
// job has week (BatchJob.Week): the batch and the job that runs it are
|
||||
// committed together, so a batch is never seen without its job. The caller
|
||||
// wakes the job runner once tx has committed and Created is true.
|
||||
func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger, week string) (Batch, error) {
|
||||
q := db.New(tx)
|
||||
cur, err := s.activeBatch(ctx, q)
|
||||
if err != nil {
|
||||
@@ -250,7 +270,7 @@ func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (B
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: create batch: %w", err)
|
||||
}
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID})
|
||||
jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID, Week: week})
|
||||
if err != nil {
|
||||
return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err)
|
||||
}
|
||||
@@ -428,7 +448,7 @@ func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, update
|
||||
if left >= ReplenishBelow {
|
||||
return nil
|
||||
}
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish)
|
||||
b, err := s.startBatch(ctx, tx, TriggerReplenish, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -300,6 +300,45 @@ func (e *env) runBatch(t *testing.T) batchResult {
|
||||
return res
|
||||
}
|
||||
|
||||
// scheduledWeek is the week the tests' scheduled batches are for: the
|
||||
// Monday of 2026-W41.
|
||||
var scheduledWeek = mustDate("2026-10-05")
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func mustZone(t *testing.T, name string) *time.Location {
|
||||
t.Helper()
|
||||
loc, err := household.LoadZone(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return loc
|
||||
}
|
||||
|
||||
// startBatch starts a batch with trigger, a scheduled one for
|
||||
// scheduledWeek, and returns it with the key its new_suggestions has.
|
||||
func (e *env) startBatch(t *testing.T, trigger string) (suggest.Batch, string) {
|
||||
t.Helper()
|
||||
if trigger == suggest.TriggerScheduled {
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), scheduledWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:2026-W41"
|
||||
}
|
||||
b, err := e.svc.StartBatch(t.Context(), trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b, "new_suggestions:" + b.ID
|
||||
}
|
||||
|
||||
func (e *env) batchStatus(t *testing.T, id string) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
@@ -404,8 +443,9 @@ func TestBatchStoresGroundedSideQuests(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestBatchEmitsNewSuggestions: a scheduled or manual batch that adds Side
|
||||
// quests stores new_suggestions keyed by the batch, naming them; a
|
||||
// replenish batch, or one that adds none, stores nothing.
|
||||
// quests stores new_suggestions counting and naming them, keyed by the
|
||||
// scheduled ISO week or the manual batch, for that week or the current
|
||||
// one; a replenish batch, or one that adds none, stores nothing.
|
||||
func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
@@ -428,10 +468,7 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"),
|
||||
sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"),
|
||||
)
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
@@ -446,64 +483,70 @@ func TestBatchEmitsNewSuggestions(t *testing.T) {
|
||||
t.Fatalf("new_suggestions rows = %+v, want one on push", rows)
|
||||
}
|
||||
r := rows[0]
|
||||
if r.Key != "new_suggestions:"+b.ID || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "New Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week == "" {
|
||||
t.Errorf("new_suggestions row = %+v, want key new_suggestions:%s naming the Side quests, /discover", r, b.ID)
|
||||
week := household.WeekStart(store.LocalDateOf(time.Now().In(mustZone(t, "America/New_York")))).String()
|
||||
if tc.trigger == suggest.TriggerScheduled {
|
||||
week = scheduledWeek.String()
|
||||
}
|
||||
if r.Key != key || r.Channel != notify.ChannelPush || r.Payload.URL != "/discover" ||
|
||||
r.Payload.Title != "2 new Side quests are in" || r.Payload.Body != "Chicken Shawarma Bowls, Chili Verde" || r.Payload.Week != week {
|
||||
t.Errorf("new_suggestions row = %+v, want key %s counting and naming the Side quests, week %s, /discover", r, key, week)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchNotifiesAsSideQuestsAreStored: new_suggestions is stored with
|
||||
// the first round to add Side quests, so a batch whose later round fails
|
||||
// still tells the household of those in the deck, once; a later round that
|
||||
// succeeds adds no second notification; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
// twoRoundBatch scripts a suggest_side_quests reply of BatchSize+1 Side
|
||||
// quests, Fajita Dish A to F, whose first round grounds all but B, so a
|
||||
// second round grounds F. Unless lastGrounds, normalize_recipe fails for
|
||||
// F.
|
||||
func (e *env) twoRoundBatch(t *testing.T, lastGrounds bool) {
|
||||
t.Helper()
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
switch {
|
||||
case i == suggest.BatchSize && !lastGrounds:
|
||||
// A page is found, but no normalize_recipe reply is scripted
|
||||
// for it, so the AI call grounding it fails.
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
case i != 1:
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
}
|
||||
|
||||
// TestBatchNotifiesOnceWithEverySideQuestStored: a batch that fills across
|
||||
// two grounding rounds stores new_suggestions once, when it completes,
|
||||
// counting and naming every Side quest it stored, not just the first
|
||||
// round's; a replenish batch notifies nothing.
|
||||
func TestBatchNotifiesOnceWithEverySideQuestStored(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
lastFails bool
|
||||
notify bool
|
||||
trigger string
|
||||
notify bool
|
||||
}{
|
||||
{suggest.TriggerScheduled, true, true},
|
||||
{suggest.TriggerManual, true, true},
|
||||
{suggest.TriggerManual, false, true},
|
||||
{suggest.TriggerReplenish, true, false},
|
||||
{suggest.TriggerScheduled, true},
|
||||
{suggest.TriggerManual, true},
|
||||
{suggest.TriggerReplenish, false},
|
||||
} {
|
||||
t.Run(fmt.Sprintf("%s last round fails %v", tc.trigger, tc.lastFails), func(t *testing.T) {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// The first round grounds BatchSize Side quests but B; the
|
||||
// second round grounds F.
|
||||
var quests []string
|
||||
for i := range suggest.BatchSize + 1 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
}
|
||||
e.reply(quests...)
|
||||
if tc.lastFails {
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_f BEFORE INSERT ON dinners WHEN NEW.name = 'Fajita Dish F' BEGIN SELECT RAISE(ABORT, 'F refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
e.twoRoundBatch(t, true)
|
||||
|
||||
b, err := e.svc.StartBatch(t.Context(), tc.trigger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
b, key := e.startBatch(t, tc.trigger)
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, want succeeded", job.Status)
|
||||
}
|
||||
job := e.waitJob(t, b.JobID)
|
||||
want, deck := jobs.StatusSucceeded, suggest.BatchSize
|
||||
if tc.lastFails {
|
||||
want, deck = jobs.StatusFailed, suggest.BatchSize-1
|
||||
}
|
||||
if job.Status != want {
|
||||
t.Fatalf("batch job %s, want %s", job.Status, want)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != deck {
|
||||
t.Fatalf("deck = %v, want %d Side quests", got, deck)
|
||||
deck := e.deckNames(t)
|
||||
if want := []string{"Fajita Dish A", "Fajita Dish C", "Fajita Dish D", "Fajita Dish E", "Fajita Dish F"}; !slices.Equal(deck, want) {
|
||||
t.Fatalf("deck = %v, want %v", deck, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if !tc.notify {
|
||||
@@ -512,9 +555,47 @@ func TestBatchNotifiesAsSideQuestsAreStored(t *testing.T) {
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 1 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed by batch %s naming the first round", rows, b.ID)
|
||||
want := fmt.Sprintf("%d new Side quests are in", len(deck))
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != want ||
|
||||
rows[0].Payload.Body != "Fajita Dish A, Fajita Dish C, Fajita Dish D + 2 more" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchFailingInALaterRoundStoresNothing: when normalize_recipe fails
|
||||
// in a batch's second grounding round, a scheduled or manual batch stores
|
||||
// none of the first round's Side quests and sends no new_suggestions, so
|
||||
// no Side quest is left in the deck unannounced; a replenish batch, which
|
||||
// never notifies, keeps the first round's in the deck.
|
||||
func TestBatchFailingInALaterRoundStoresNothing(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
trigger string
|
||||
deck int
|
||||
}{
|
||||
{suggest.TriggerScheduled, 0},
|
||||
{suggest.TriggerManual, 0},
|
||||
{suggest.TriggerReplenish, suggest.BatchSize - 1},
|
||||
} {
|
||||
t.Run(tc.trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.twoRoundBatch(t, false)
|
||||
|
||||
b, _ := e.startBatch(t, tc.trigger)
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != suggest.ReasonAIFailed {
|
||||
t.Fatalf("batch job %s, error %+v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed)
|
||||
}
|
||||
if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed {
|
||||
t.Errorf("batch status = %s, want failed", got)
|
||||
}
|
||||
if got := e.deckNames(t); len(got) != tc.deck {
|
||||
t.Errorf("deck = %v, want %d Side quests", got, tc.deck)
|
||||
}
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 0 {
|
||||
t.Errorf("new_suggestions rows = %+v, want none", rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1299,7 +1380,7 @@ func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) {
|
||||
t.Errorf("batch status = %s with its job failed, want failed", got)
|
||||
}
|
||||
// Its Side quests stay in the deck, and the household was told of them.
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Body != "Chili Verde" {
|
||||
if rows := notifytest.Rows(t, e.st, notify.NewSuggestions); len(rows) != 1 || rows[0].Key != "new_suggestions:"+b.ID || rows[0].Payload.Title != "1 new Side quest is in" || rows[0].Payload.Body != "Chili Verde" {
|
||||
t.Errorf("new_suggestions rows of a batch whose end was not recorded = %+v, want one naming Chili Verde", rows)
|
||||
}
|
||||
}
|
||||
@@ -1474,6 +1555,47 @@ func TestStartBatchRefusesUnknownTrigger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchNeedsItsWeek: a scheduled batch is started only by
|
||||
// StartScheduledBatch, for a Monday.
|
||||
func TestScheduledBatchNeedsItsWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.StartBatch(t.Context(), suggest.TriggerScheduled); err == nil {
|
||||
t.Error("StartBatch started a scheduled batch with no week")
|
||||
}
|
||||
for _, week := range []store.LocalDate{{}, mustDate("2026-10-06")} {
|
||||
if _, err := e.svc.StartScheduledBatch(t.Context(), week); err == nil {
|
||||
t.Errorf("StartScheduledBatch(%q) accepted a week that is not a Monday", week)
|
||||
}
|
||||
}
|
||||
if n := e.activeBatches(t); n != 0 {
|
||||
t.Errorf("%d active batches, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduledBatchKeyedByItsWeekWhenRunLate: a scheduled batch's
|
||||
// new_suggestions is keyed by the week it was scheduled for, not the week
|
||||
// it runs in, so a batch caught up after its week ended still tells the
|
||||
// household of that week's Side quests and leaves the next week's key free.
|
||||
func TestScheduledBatchKeyedByItsWeekWhenRunLate(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.recipe(t, "Chili Verde")
|
||||
e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"))
|
||||
// A week long past: the batch runs today, weeks later.
|
||||
b, err := e.svc.StartScheduledBatch(t.Context(), mustDate("2026-01-05"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s", job.Status)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
if len(rows) != 1 || rows[0].Key != "new_suggestions:2026-W02" || rows[0].Payload.Week != "2026-01-05" ||
|
||||
rows[0].Payload.Title != "1 new Side quest is in" {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed new_suggestions:2026-W02 for 2026-01-05", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// noCalendar is a plan.Calendar for tests that never push.
|
||||
type noCalendar struct{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user