Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #182.
This commit is contained in:
+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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1021,7 +1102,7 @@ func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) {
|
||||
func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) {
|
||||
e := newEnv(t) // runner not started: the replenish batch waits for the rename
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1069,14 +1150,14 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ctx := t.Context()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger)
|
||||
dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, func() {}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -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