Feature 17: Shopping List and Supply Archive (#140)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #140.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Archive groupings: the period Archive groups its posts by.
|
||||
const (
|
||||
ByWeek = "week"
|
||||
ByMonth = "month"
|
||||
ByYear = "year"
|
||||
)
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
|
||||
//
|
||||
// A list still untitled is titled first, with archive_title and the same
|
||||
// conditional write as a KindTitleSupplyRun job, from the week's plan as
|
||||
// Generate titles it, so its post in the Supply archive gets a title when
|
||||
// the AI can write one. Titling never fails the freeze: a title that
|
||||
// cannot be written is logged and the list is frozen untitled, when its
|
||||
// week label stands in for the title for good. Freeze first waits for the
|
||||
// title jobs in flight to store their titles or fail, and holds off new
|
||||
// ones until the list is frozen, so a title a job's archive_title run
|
||||
// wrote is stored before the freeze, and the freeze runs archive_title
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if weekStart.String() >= thisWeek.String() {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
|
||||
}
|
||||
s.titling.Lock()
|
||||
defer s.titling.Unlock()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze: read list: %w", err)
|
||||
}
|
||||
if frozen(list) {
|
||||
return nil
|
||||
}
|
||||
if !list.Title.Valid {
|
||||
p, err := freezeTitleJob(ctx, q, list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.storeTitle(ctx, p); err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
// the week's plan revision and its dinners in day order.
|
||||
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
|
||||
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
|
||||
}
|
||||
days, err := q.ListPlanDays(ctx, plan.ID)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
|
||||
}
|
||||
names := make([]string, len(days))
|
||||
for i, d := range days {
|
||||
names[i] = d.Dinner.Name
|
||||
}
|
||||
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
|
||||
}
|
||||
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
type Period struct {
|
||||
// Key identifies the period: the ISO week ("2026-W41"), the month
|
||||
// ("2026-10") or the year ("2026").
|
||||
Key string
|
||||
// Heading is the period as shown: the week label, the month and year
|
||||
// ("October 2026") or the year ("2026").
|
||||
Heading string
|
||||
Posts []Post
|
||||
}
|
||||
|
||||
// Post is one Supply run in the Supply archive.
|
||||
type Post struct {
|
||||
WeekStart string
|
||||
// Label is the week label, such as "Week 41 · Oct 5 – 11".
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
// and including the household's current week, newest first, grouped by
|
||||
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
|
||||
// week's month and year are those of its Monday, a household-local date,
|
||||
// so a week belongs to one period whatever the server's zone; the week
|
||||
// starting Monday 2026-12-28 is in December 2026.
|
||||
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
|
||||
// period returns the key and heading of the period holding p, whose
|
||||
// week starts on Monday monday.
|
||||
var period func(monday store.LocalDate, p Post) (string, string)
|
||||
switch by {
|
||||
case ByWeek:
|
||||
period = func(monday store.LocalDate, p Post) (string, string) {
|
||||
return household.PeriodKey(monday), p.Label
|
||||
}
|
||||
case ByMonth:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, m, _ := monday.Date()
|
||||
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
|
||||
}
|
||||
case ByYear:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, _, _ := monday.Date()
|
||||
return strconv.Itoa(y), strconv.Itoa(y)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
|
||||
}
|
||||
out = []Period{}
|
||||
for _, row := range lists {
|
||||
monday, err := store.ParseLocalDate(row.WeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
|
||||
}
|
||||
l, err := readList(ctx, q, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, heading := period(monday, p)
|
||||
if n := len(out); n == 0 || out[n-1].Key != k {
|
||||
out = append(out, Period{Key: k, Heading: heading})
|
||||
}
|
||||
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
}
|
||||
dinners := make([]string, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
dinners[i] = g.DinnerName
|
||||
}
|
||||
c := l.Counts()
|
||||
return Post{
|
||||
WeekStart: l.WeekStart,
|
||||
Label: label,
|
||||
Title: l.Title,
|
||||
Dinners: dinners,
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// zoned makes zone the household's time zone and at the instant the
|
||||
// service's clock reads.
|
||||
func (e *env) zoned(t *testing.T, zone, at string) {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.at(t, at)
|
||||
}
|
||||
|
||||
// at sets the service's clock to the RFC 3339 instant at.
|
||||
func (e *env) at(t *testing.T, at string) {
|
||||
t.Helper()
|
||||
now, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopping.SetClock(e.svc, func() time.Time { return now })
|
||||
}
|
||||
|
||||
// frozenState returns the week's list's status and frozen_at; frozen_at is
|
||||
// "" while NULL.
|
||||
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var frozenAt sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return status, frozenAt.String
|
||||
}
|
||||
|
||||
// weekList plans the dinners named names on the days of the week starting
|
||||
// monday, in order, and generates its Supply run.
|
||||
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
|
||||
t.Helper()
|
||||
start := mustDate(monday)
|
||||
days, err := household.WeekDays(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g, err := e.svc.Generate(t.Context(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g.List
|
||||
}
|
||||
|
||||
// endedWeek is the env with the tests' week generated, in New York, on
|
||||
// the Monday after it, so the week has ended.
|
||||
func endedWeek(t *testing.T) (*env, shopping.List) {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
return e, e.generate(t).List
|
||||
}
|
||||
|
||||
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
|
||||
// read back read-only, and freezing it again changes nothing.
|
||||
func TestFreeze(t *testing.T) {
|
||||
e, l := endedWeek(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, frozenAt := e.frozenState(t, week)
|
||||
if status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != shopping.StatusArchived {
|
||||
t.Errorf("read back status = %q, want archived", got.Status)
|
||||
}
|
||||
wantLines(t, "read back", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("second Freeze = %v, want no error", err)
|
||||
}
|
||||
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
|
||||
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
}
|
||||
|
||||
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
|
||||
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
|
||||
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
|
||||
// it is.
|
||||
func TestFreezeWeekNotEnded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
|
||||
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("Freeze on the Monday = %v", err)
|
||||
}
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Freeze of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeTitlesUntitledList: a list still untitled when its week is
|
||||
// frozen is titled by archive_title first, from the plan's dinners, and
|
||||
// frozen with that title. Its title job, still queued, runs after the
|
||||
// freeze, finds the title and succeeds without asking the model again.
|
||||
func TestFreezeTitlesUntitledList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.plan(t, "Fish Tacos", "Beef Chili")
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
e.model.QueueGenerate(titled("Tacos then Chili"))
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
|
||||
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
|
||||
}
|
||||
|
||||
e.start(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
|
||||
}
|
||||
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
|
||||
}
|
||||
|
||||
// pauseTitle makes the env's first archive_title run pause once it has
|
||||
// returned, before its title is stored, until the returned release is
|
||||
// called or the test ends; paused is closed as it pauses.
|
||||
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
|
||||
t.Helper()
|
||||
p, r := make(chan struct{}), make(chan struct{})
|
||||
var first atomic.Bool
|
||||
e.afterTitle = func() {
|
||||
if first.CompareAndSwap(false, true) {
|
||||
close(p)
|
||||
select {
|
||||
case <-r:
|
||||
case <-t.Context().Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
var released sync.Once
|
||||
return p, func() { released.Do(func() { close(r) }) }
|
||||
}
|
||||
|
||||
// waitClosed fails t unless c is closed within 10 seconds.
|
||||
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-c:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the job to store it, freezes the list with that title, and asks the
|
||||
// model nothing itself.
|
||||
func TestFreezeWaitsForTitleJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
paused, release := e.pauseTitle(t)
|
||||
e.start(t)
|
||||
g := e.generate(t)
|
||||
waitClosed(t, "the title job's archive_title run", paused)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-froze:
|
||||
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
release()
|
||||
err = <-froze
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the freeze, then finds the list titled and asks the model nothing.
|
||||
func TestTitleJobWaitsForFreeze(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
paused, release := e.pauseTitle(t)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
waitClosed(t, "the freeze's archive_title run", paused)
|
||||
e.start(t)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusRunning {
|
||||
break
|
||||
}
|
||||
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
release()
|
||||
if err := <-froze; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
|
||||
// list, it is frozen untitled all the same, the failure is logged, and the
|
||||
// Supply archive shows its week label for good.
|
||||
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
|
||||
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
|
||||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("archive = %+v, want the untitled week under its label", periods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
|
||||
// model is not asked again.
|
||||
func TestFreezeKeepsTitle(t *testing.T) {
|
||||
e, _ := endedWeek(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("%d model calls, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// postLine renders p as "weekStart label | title | dinners | items/on hand
|
||||
// | current".
|
||||
func postLine(p shopping.Post) string {
|
||||
title := "-"
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
cur := ""
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
|
||||
// periodLines renders periods one line per heading and post.
|
||||
func periodLines(periods []shopping.Period) []string {
|
||||
var out []string
|
||||
for _, p := range periods {
|
||||
out = append(out, "# "+p.Key+" "+p.Heading)
|
||||
for _, post := range p.Posts {
|
||||
out = append(out, postLine(post))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArchiveGroups reads the Supply archive across a month and a year
|
||||
// boundary in Auckland, where it is already Monday 4 January 2027 while
|
||||
// UTC still reads Sunday 3 January: that week is current, the next one is
|
||||
// not yet in the archive, and each week falls in the month and year of
|
||||
// its Monday.
|
||||
func TestArchiveGroups(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
|
||||
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
|
||||
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
|
||||
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
|
||||
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
|
||||
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
|
||||
e.haveIt(t, dec, "Beef Chili", "onion")
|
||||
e.haveIt(t, dec, "Pad Thai", "lime")
|
||||
e.haveIt(t, jan, "Pad Thai", "lime")
|
||||
e.haveIt(t, nov, "Pot Roast", "carrots")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
by string
|
||||
want []string
|
||||
}{
|
||||
{shopping.ByWeek, []string{
|
||||
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
|
||||
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
|
||||
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
|
||||
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
|
||||
}},
|
||||
{shopping.ByMonth, []string{
|
||||
"# 2027-01 January 2027", posts["jan"],
|
||||
"# 2026-12 December 2026", posts["dec"], posts["d07"],
|
||||
"# 2026-11 November 2026", posts["nov"],
|
||||
}},
|
||||
{shopping.ByYear, []string{
|
||||
"# 2027 2027", posts["jan"],
|
||||
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
|
||||
}},
|
||||
} {
|
||||
got, err := e.svc.Archive(t.Context(), tc.by)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive(%s): %v", tc.by, err)
|
||||
}
|
||||
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
|
||||
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
|
||||
}
|
||||
for _, by := range []string{"", "day", "Week", "months"} {
|
||||
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package shopping
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s ends and dates weeks on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -0,0 +1,732 @@
|
||||
// Package shopping keeps the household's Supply runs: each week's
|
||||
// shopping list, generated from the week's plan and grouped by dinner, and
|
||||
// the Supply archive they are frozen into.
|
||||
//
|
||||
// Generate makes a week's Supply run from its plan, and remakes it after
|
||||
// the plan changes. It is deterministic and runs no model: an item is one
|
||||
// ingredient of the dinner's recipe exactly as recipe grounding stored it
|
||||
// (see Generate). The one model call is the Supply run's title, written by
|
||||
// archive_title in a KindTitleSupplyRun job when the list is generated
|
||||
// untitled (see Generate).
|
||||
//
|
||||
// Freeze moves a week's Supply run into the Supply archive once its week
|
||||
// has ended, and Archive reads the archive back as posts grouped by week,
|
||||
// month or year.
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
// List statuses (the shopping_lists.status column).
|
||||
const (
|
||||
StatusCurrent = "current"
|
||||
StatusArchived = "archived"
|
||||
)
|
||||
|
||||
// Reasons a plan day has no group on its Supply run.
|
||||
const (
|
||||
// SkipNoRecipe: the day's dinner has no recipe, as while its grounding
|
||||
// is pending or after it failed.
|
||||
SkipNoRecipe = "no_recipe"
|
||||
// SkipNoIngredients: the day's dinner's recipe lists no ingredients.
|
||||
SkipNoIngredients = "no_ingredients"
|
||||
)
|
||||
|
||||
// KindTitleSupplyRun is the job kind that titles a Supply run with
|
||||
// archive_title. Only Generate and GeneratePushed enqueue it.
|
||||
const KindTitleSupplyRun = "title_supply_run"
|
||||
|
||||
// ReasonTitleFailed is the failure code of a KindTitleSupplyRun job that
|
||||
// stored no title: archive_title failed, or the Supply run was frozen or
|
||||
// replaced first. The Supply run stays untitled.
|
||||
const ReasonTitleFailed = "title_failed"
|
||||
|
||||
// ErrPlanNotFound is a generation for a week with no plan.
|
||||
var ErrPlanNotFound = errors.New("the week has no plan")
|
||||
|
||||
// ErrPlanChanged is a generation from a pushed plan the household has
|
||||
// edited since it was pushed: the Supply run would not match Calendar, so
|
||||
// none is made, and pushing the plan again makes it.
|
||||
var ErrPlanChanged = errors.New("the plan changed after it was pushed to Calendar; push again")
|
||||
|
||||
// ErrFrozen is a generation of, or a change to an item of, a Supply run
|
||||
// frozen into the Supply archive, which is read-only.
|
||||
var ErrFrozen = errors.New("the week's Supply run is in the Supply archive and can no longer change")
|
||||
|
||||
// ErrListNotFound is a read of a week with no Supply run.
|
||||
var ErrListNotFound = errors.New("the week has no Supply run")
|
||||
|
||||
// ErrItemNotFound is a change to an item on no Supply run.
|
||||
var ErrItemNotFound = errors.New("no Supply run has that item")
|
||||
|
||||
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
|
||||
// the household's time zone.
|
||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
||||
|
||||
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||
// ByYear.
|
||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||
|
||||
// Titler runs archive_title on in; scope names the week, its plan revision
|
||||
// and its dinners.
|
||||
// In production it is skills.TitleArchive with the household's
|
||||
// harness.Config.
|
||||
type Titler func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error)
|
||||
|
||||
// Service generates Supply runs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
title Titler
|
||||
titleJob *jobs.Kind[TitleJob]
|
||||
logger *slog.Logger
|
||||
// now is the clock weeks are ended and current on; tests replace it.
|
||||
now func() time.Time
|
||||
// titling orders the title writes against Freeze. A title job holds it
|
||||
// shared from reading the list to storing its title, and Freeze holds
|
||||
// it exclusively from reading the list to freezing it, so a freeze
|
||||
// waits for the title jobs in flight to store their titles or fail,
|
||||
// and no title job runs archive_title while a freeze is under way.
|
||||
titling sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
// in KindTitleSupplyRun jobs, which it registers on runner, and logs to
|
||||
// logger. runner must not have started yet.
|
||||
func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("shopping: nil store")
|
||||
case title == nil:
|
||||
return nil, errors.New("shopping: nil titler")
|
||||
case runner == nil:
|
||||
return nil, errors.New("shopping: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("shopping: nil logger")
|
||||
}
|
||||
s := &Service{st: st, title: title, logger: logger, now: time.Now}
|
||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.titleJob = kind
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// List is a Supply run.
|
||||
type List struct {
|
||||
ID string `json:"id"`
|
||||
// WeekStart is the Monday of the list's week, YYYY-MM-DD.
|
||||
WeekStart string `json:"week_start"`
|
||||
PlanID string `json:"plan_id"`
|
||||
// Title is the AI-written post title; nil until it is written, when
|
||||
// the week label stands in for it.
|
||||
Title *string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
// SentToChatAt is when the list was sent to Google Chat; nil until it
|
||||
// is.
|
||||
SentToChatAt *string `json:"sent_to_chat_at"`
|
||||
Groups []Group `json:"groups"`
|
||||
}
|
||||
|
||||
// Counts are how many items of a Supply run are still needed and how many
|
||||
// it has in all.
|
||||
type Counts struct {
|
||||
Needed int `json:"needed_count"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
|
||||
// Counts counts l's items.
|
||||
func (l List) Counts() Counts {
|
||||
var c Counts
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
c.Total++
|
||||
if it.Needed {
|
||||
c.Needed++
|
||||
}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Group is the items of one plan day's dinner.
|
||||
type Group struct {
|
||||
ID string `json:"id"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
DinnerName string `json:"dinner_name"`
|
||||
// Date is the plan day, household-local YYYY-MM-DD.
|
||||
Date string `json:"date"`
|
||||
Position int `json:"position"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Item is one ingredient to buy.
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
// Qty is the quantity as shown, such as "2 cups" or "500 g"; nil when
|
||||
// the recipe gives none.
|
||||
Qty *string `json:"qty"`
|
||||
// Needed is false once the household marks the item "have it".
|
||||
Needed bool `json:"needed"`
|
||||
Position int `json:"position"`
|
||||
}
|
||||
|
||||
// SkippedDay is a plan day with no group on the list, and why.
|
||||
type SkippedDay struct {
|
||||
Date string `json:"date"`
|
||||
DinnerID string `json:"dinner_id"`
|
||||
DinnerName string `json:"dinner_name"`
|
||||
// Reason is SkipNoRecipe or SkipNoIngredients.
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// Generated is the outcome of Generate.
|
||||
type Generated struct {
|
||||
List List `json:"list"`
|
||||
// Skipped are the plan days whose dinner has no ingredients to shop
|
||||
// for, by date.
|
||||
Skipped []SkippedDay `json:"skipped"`
|
||||
// TitleJobID is the KindTitleSupplyRun job titling the list; nil when
|
||||
// the list already has its title.
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// Generate makes the Supply run of the week starting weekStart from the
|
||||
// week's plan, creating it untitled the first time and remaking it after
|
||||
// that, in one transaction.
|
||||
//
|
||||
// The list has one group per plan day with a dinner, by date, holding one
|
||||
// item per ingredient of the dinner's recipe, by the ingredient's
|
||||
// position. A fetched recipe's ingredients are normalized when it is
|
||||
// grounded (normalize_recipe): the item is the ingredient's name, and its
|
||||
// quantity is qty and unit joined by a space ("2 cups"), either alone when
|
||||
// the other is missing, or nil when both are. A recipe entered by hand is
|
||||
// stored as typed, unnormalized: each item is its line as typed, with no
|
||||
// quantity. A day whose dinner has no recipe or whose recipe has no
|
||||
// ingredients gets no group and is reported in Skipped.
|
||||
//
|
||||
// Remaking keeps what the household marked: an item is the same item as
|
||||
// before when its dinner and name are the same, wherever the dinner and
|
||||
// the ingredient now are, so it keeps its id and needed flag. When a
|
||||
// dinner lists a name more than once, its first such item matches the
|
||||
// first one before, the second the second, and so on. Any other item is
|
||||
// new and needed; an item no longer on the plan is removed, and with it a
|
||||
// group no longer on the plan.
|
||||
//
|
||||
// A list still untitled once it is stored, as a new list always is, gets a
|
||||
// KindTitleSupplyRun job, enqueued in the same transaction, that titles it
|
||||
// with archive_title from the names of the plan's dinners in day order.
|
||||
// Generate returns without waiting for the job: the list is returned
|
||||
// untitled, with the job's id. A title is written once: a remake keeps it
|
||||
// and enqueues no job. Titling never fails the generation: a title that
|
||||
// cannot be written leaves the list untitled, the failure is logged and
|
||||
// the job failed with ReasonTitleFailed, and the next generation of the
|
||||
// week, Generate or GeneratePushed, tries again.
|
||||
//
|
||||
// A week with no plan is ErrPlanNotFound; a list frozen into the Supply
|
||||
// archive is ErrFrozen. A fetched recipe's ingredient without a name is an
|
||||
// error: grounding always names one.
|
||||
func (s *Service) Generate(ctx context.Context, weekStart store.LocalDate) (Generated, error) {
|
||||
return s.generate(ctx, weekStart, func(db.WeekPlan) error { return nil })
|
||||
}
|
||||
|
||||
// GeneratePushed makes the Supply run of the week starting weekStart as
|
||||
// Generate does, from the plan a Push to Calendar pushed at revision. A
|
||||
// plan edited since (another revision) is ErrPlanChanged, and the Supply
|
||||
// run is left as it was.
|
||||
func (s *Service) GeneratePushed(ctx context.Context, weekStart store.LocalDate, revision int64) (Generated, error) {
|
||||
return s.generate(ctx, weekStart, func(plan db.WeekPlan) error {
|
||||
if plan.Revision != revision {
|
||||
return ErrPlanChanged
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// generate makes the Supply run of the week starting weekStart from the
|
||||
// week's plan, as Generate says, once check accepts that plan, in the same
|
||||
// transaction.
|
||||
func (s *Service) generate(ctx context.Context, weekStart store.LocalDate, check func(db.WeekPlan) error) (Generated, error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return Generated{}, fmt.Errorf("shopping: generate: %w", err)
|
||||
}
|
||||
var out Generated
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
plan, err := q.GetWeekPlan(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrPlanNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read plan: %w", err)
|
||||
}
|
||||
if err := check(plan); err != nil {
|
||||
return err
|
||||
}
|
||||
want, skipped, dinnerNames, err := wanted(ctx, q, plan.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := store.Now().String()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
switch {
|
||||
case err != nil: // no list yet
|
||||
list = db.ShoppingList{ID: store.NewID(), WeekStart: weekStart.String(), PlanID: plan.ID, Status: StatusCurrent}
|
||||
err = q.CreateShoppingList(ctx, db.CreateShoppingListParams{
|
||||
ID: list.ID, WeekStart: list.WeekStart, PlanID: list.PlanID, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
case frozen(list):
|
||||
return ErrFrozen
|
||||
default:
|
||||
list.PlanID = plan.ID
|
||||
err = q.RegenerateShoppingList(ctx, db.RegenerateShoppingListParams{PlanID: plan.ID, UpdatedAt: now, ID: list.ID})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: store list: %w", err)
|
||||
}
|
||||
groups, err := reconcile(ctx, q, list.ID, want, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out = Generated{List: listOf(list, groups), Skipped: skipped}
|
||||
if list.Title.Valid {
|
||||
return nil
|
||||
}
|
||||
id, err := s.titleJob.EnqueueTx(ctx, tx, TitleJob{
|
||||
ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: dinnerNames,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: enqueue title: %w", err)
|
||||
}
|
||||
out.TitleJobID = &id
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Generated{}, err
|
||||
}
|
||||
if out.TitleJobID != nil {
|
||||
s.titleJob.Wake()
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// TitleJob is a KindTitleSupplyRun job's payload: the untitled Supply run
|
||||
// ListID of the week WeekStart, made from the plan at Revision, whose
|
||||
// dinners in day order are Dinners.
|
||||
type TitleJob struct {
|
||||
ListID string `json:"list_id"`
|
||||
WeekStart string `json:"week_start"`
|
||||
Revision int64 `json:"revision"`
|
||||
Dinners []string `json:"dinners"`
|
||||
}
|
||||
|
||||
// titleResult is a KindTitleSupplyRun job's result: the Supply run and the
|
||||
// title it has.
|
||||
type titleResult struct {
|
||||
ListID string `json:"list_id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// PublicJSON returns the Supply run's id and title.
|
||||
func (r titleResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// runTitleJob titles the Supply run of p with archive_title and stores the
|
||||
// title. A title that cannot be stored leaves the list untitled: the
|
||||
// failure is logged and the job fails with ReasonTitleFailed.
|
||||
func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResult) error) (titleResult, error) {
|
||||
title, err := s.writeTitle(ctx, p)
|
||||
if err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run left untitled; its next generation titles it again",
|
||||
"week", p.WeekStart, "list", p.ListID, "error", err)
|
||||
return titleResult{}, &jobs.Failure{Code: ReasonTitleFailed}
|
||||
}
|
||||
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||
}
|
||||
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title,
|
||||
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||
// end first, and a freeze waits for it to end.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
s.titling.RLock()
|
||||
defer s.titling.RUnlock()
|
||||
return s.storeTitle(ctx, p)
|
||||
}
|
||||
|
||||
// storeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// When the list was titled by another job, before this one ran or
|
||||
// meanwhile, that title stands and is returned. The caller holds titling.
|
||||
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
title, err := s.currentTitle(ctx, q, p)
|
||||
if err != nil || title != "" {
|
||||
return title, err
|
||||
}
|
||||
out, err := s.title(ctx, titleScope(p), skills.ArchiveTitleRequest{Dinners: p.Dinners})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: title: %w", err)
|
||||
}
|
||||
write, cancel := jobs.FinalWriteContext(ctx)
|
||||
defer cancel()
|
||||
n, err := q.SetShoppingListTitle(write, db.SetShoppingListTitleParams{
|
||||
Title: sql.NullString{String: out.Title, Valid: true}, UpdatedAt: store.Now().String(), ID: p.ListID,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("shopping: store title: %w", err)
|
||||
}
|
||||
if n == 1 {
|
||||
return out.Title, nil
|
||||
}
|
||||
title, err = s.currentTitle(write, q, p)
|
||||
if err == nil && title == "" {
|
||||
err = errors.New("shopping: store title: the Supply run is still untitled")
|
||||
}
|
||||
return title, err
|
||||
}
|
||||
|
||||
// currentTitle returns the title of p's Supply run, or "" while it is
|
||||
// untitled. A list frozen or replaced untitled is an error: it can no
|
||||
// longer be titled.
|
||||
func (s *Service) currentTitle(ctx context.Context, q *db.Queries, p TitleJob) (string, error) {
|
||||
list, err := q.GetShoppingList(ctx, p.WeekStart)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("shopping: title: read list: %w", err)
|
||||
}
|
||||
switch {
|
||||
case err == nil && list.ID == p.ListID && list.Title.Valid:
|
||||
return list.Title.String, nil
|
||||
case err != nil || list.ID != p.ListID || frozen(list):
|
||||
return "", errors.New("shopping: store title: the Supply run was frozen or replaced before its title was stored")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// titleScope is the archive_title scope of p: its week and plan revision,
|
||||
// and a fingerprint of its dinners in day order, so runs share a title only
|
||||
// when they title the same dinner names. A renamed dinner changes the
|
||||
// names but not the plan revision.
|
||||
func titleScope(p TitleJob) string {
|
||||
names, _ := json.Marshal(p.Dinners) // a []string always encodes
|
||||
return fmt.Sprintf("%s@%d/%x", p.WeekStart, p.Revision, sha256.Sum256(names))
|
||||
}
|
||||
|
||||
// Get returns the Supply run of the week starting weekStart, as stored: a
|
||||
// week with none is ErrListNotFound.
|
||||
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, err error) {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return List{}, fmt.Errorf("shopping: get: %w", err)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return List{}, ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
|
||||
}
|
||||
byGroup := make(map[string][]Item, len(rows))
|
||||
for _, r := range items {
|
||||
it := r.ShoppingItem
|
||||
byGroup[it.GroupID] = append(byGroup[it.GroupID], Item{
|
||||
ID: it.ID, Name: it.Name, Qty: nullString(it.Qty), Needed: it.Needed == 1, Position: int(it.Position),
|
||||
})
|
||||
}
|
||||
groups := make([]Group, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
g := r.ShoppingGroup
|
||||
groups = append(groups, Group{
|
||||
ID: g.ID, DinnerID: g.DinnerID, DinnerName: r.DinnerName, Date: g.Date, Position: int(g.Position),
|
||||
Items: byGroup[g.ID],
|
||||
})
|
||||
}
|
||||
return listOf(list, groups), nil
|
||||
}
|
||||
|
||||
// SetNeeded marks item itemID needed, or "have it" when needed is false,
|
||||
// and returns its Supply run's counts after the change. An item on no
|
||||
// Supply run is ErrItemNotFound; one on a list frozen into the Supply
|
||||
// archive is ErrFrozen, and is left as it was.
|
||||
func (s *Service) SetNeeded(ctx context.Context, itemID string, needed bool) (Counts, error) {
|
||||
var c Counts
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
row, err := q.GetShoppingItemList(ctx, itemID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read list of item %s: %w", itemID, err)
|
||||
}
|
||||
if frozen(row.ShoppingList) {
|
||||
return ErrFrozen
|
||||
}
|
||||
flag := int64(0)
|
||||
if needed {
|
||||
flag = 1
|
||||
}
|
||||
if err := q.SetShoppingItemNeeded(ctx, db.SetShoppingItemNeededParams{
|
||||
Needed: flag, UpdatedAt: store.Now().String(), ID: itemID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: mark item %s: %w", itemID, err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, row.ShoppingList.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
for _, r := range items {
|
||||
c.Total++
|
||||
if r.ShoppingItem.Needed == 1 {
|
||||
c.Needed++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Counts{}, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// frozen reports whether list is frozen into the Supply archive.
|
||||
func frozen(list db.ShoppingList) bool {
|
||||
return list.FrozenAt.Valid || list.Status == StatusArchived
|
||||
}
|
||||
|
||||
// listOf is list with groups.
|
||||
func listOf(list db.ShoppingList, groups []Group) List {
|
||||
return List{
|
||||
ID: list.ID,
|
||||
WeekStart: list.WeekStart,
|
||||
PlanID: list.PlanID,
|
||||
Title: nullString(list.Title),
|
||||
Status: list.Status,
|
||||
SentToChatAt: nullString(list.SentToChatAt),
|
||||
Groups: groups,
|
||||
}
|
||||
}
|
||||
|
||||
// wantGroup is a group the plan asks for, before it is stored.
|
||||
type wantGroup struct {
|
||||
date, dinnerID, dinnerName string
|
||||
items []wantItem
|
||||
}
|
||||
|
||||
type wantItem struct {
|
||||
name string
|
||||
qty sql.NullString
|
||||
}
|
||||
|
||||
// wanted reads plan planID's days and their dinners' ingredients: the
|
||||
// groups the list must have, by date, the days that get none, and the
|
||||
// names of the plan's dinners in day order.
|
||||
func wanted(ctx context.Context, q *db.Queries, planID string) ([]wantGroup, []SkippedDay, []string, error) {
|
||||
days, err := q.ListPlanDays(ctx, planID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read plan days: %w", err)
|
||||
}
|
||||
var (
|
||||
groups []wantGroup
|
||||
skipped []SkippedDay
|
||||
names []string
|
||||
)
|
||||
for _, day := range days {
|
||||
d := day.Dinner
|
||||
names = append(names, d.Name)
|
||||
skip := SkippedDay{Date: day.Date, DinnerID: d.ID, DinnerName: d.Name}
|
||||
if !d.RecipeID.Valid {
|
||||
skip.Reason = SkipNoRecipe
|
||||
skipped = append(skipped, skip)
|
||||
continue
|
||||
}
|
||||
recipe, err := q.GetRecipe(ctx, d.RecipeID.String)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read recipe %s of dinner %s: %w", d.RecipeID.String, d.ID, err)
|
||||
}
|
||||
ings, err := q.ListRecipeIngredients(ctx, recipe.ID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: read ingredients of recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
if len(ings) == 0 {
|
||||
skip.Reason = SkipNoIngredients
|
||||
skipped = append(skipped, skip)
|
||||
continue
|
||||
}
|
||||
manual := dinners.IsManualRecipe(recipe.SourceUrl)
|
||||
g := wantGroup{date: day.Date, dinnerID: d.ID, dinnerName: d.Name}
|
||||
for _, ing := range ings {
|
||||
it, err := item(ing, manual)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("shopping: recipe %s: %w", recipe.ID, err)
|
||||
}
|
||||
g.items = append(g.items, it)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, skipped, names, nil
|
||||
}
|
||||
|
||||
// item is the item for ingredient ing of a recipe entered by hand
|
||||
// (manual) or fetched.
|
||||
func item(ing db.RecipeIngredient, manual bool) (wantItem, error) {
|
||||
if manual {
|
||||
return wantItem{name: ing.RawText}, nil
|
||||
}
|
||||
if !ing.Name.Valid || ing.Name.String == "" {
|
||||
return wantItem{}, fmt.Errorf("ingredient %d (%q) has no normalized name", ing.Position+1, ing.RawText)
|
||||
}
|
||||
var parts []string
|
||||
for _, p := range []sql.NullString{ing.Qty, ing.Unit} {
|
||||
if p.Valid && p.String != "" {
|
||||
parts = append(parts, p.String)
|
||||
}
|
||||
}
|
||||
qty := strings.Join(parts, " ")
|
||||
return wantItem{name: ing.Name.String, qty: sql.NullString{String: qty, Valid: qty != ""}}, nil
|
||||
}
|
||||
|
||||
// itemKey is what makes an item the same item across a remake.
|
||||
type itemKey struct {
|
||||
dinnerID, name string
|
||||
}
|
||||
|
||||
// groupKey is what makes a group the same group across a remake.
|
||||
type groupKey struct {
|
||||
date, dinnerID string
|
||||
}
|
||||
|
||||
// reconcile makes the groups and items of list listID those of want, as
|
||||
// Generate says, and returns them.
|
||||
func reconcile(ctx context.Context, q *db.Queries, listID string, want []wantGroup, now string) ([]Group, error) {
|
||||
oldGroups, err := q.ListShoppingGroups(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read groups: %w", err)
|
||||
}
|
||||
oldItems, err := q.ListShoppingItems(ctx, listID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
groupsByKey := make(map[groupKey]db.ShoppingGroup, len(oldGroups))
|
||||
for _, g := range oldGroups {
|
||||
groupsByKey[groupKey{g.Date, g.DinnerID}] = g
|
||||
}
|
||||
// itemsByKey holds each key's items in list order, so the nth item a
|
||||
// dinner lists under a name matches its nth one before.
|
||||
itemsByKey := make(map[itemKey][]db.ShoppingItem)
|
||||
for _, r := range oldItems {
|
||||
k := itemKey{r.DinnerID, r.ShoppingItem.Name}
|
||||
itemsByKey[k] = append(itemsByKey[k], r.ShoppingItem)
|
||||
}
|
||||
kept := make(map[string]bool) // ids of the groups and items kept
|
||||
|
||||
groups := make([]Group, 0, len(want))
|
||||
for gi, w := range want {
|
||||
g := Group{DinnerID: w.dinnerID, DinnerName: w.dinnerName, Date: w.date, Position: gi}
|
||||
if old, ok := groupsByKey[groupKey{w.date, w.dinnerID}]; ok {
|
||||
g.ID = old.ID
|
||||
kept[old.ID] = true
|
||||
if err := q.MoveShoppingGroup(ctx, db.MoveShoppingGroupParams{Position: int64(gi), UpdatedAt: now, ID: g.ID}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: move group %s: %w", g.ID, err)
|
||||
}
|
||||
} else {
|
||||
g.ID = store.NewID()
|
||||
if err := q.CreateShoppingGroup(ctx, db.CreateShoppingGroupParams{
|
||||
ID: g.ID, ListID: listID, DinnerID: w.dinnerID, Date: w.date, Position: int64(gi),
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: store group of %s: %w", w.date, err)
|
||||
}
|
||||
}
|
||||
for ii, wi := range w.items {
|
||||
it := Item{Name: wi.name, Qty: nullString(wi.qty), Needed: true, Position: ii}
|
||||
k := itemKey{w.dinnerID, wi.name}
|
||||
if same := itemsByKey[k]; len(same) > 0 {
|
||||
old := same[0]
|
||||
itemsByKey[k] = same[1:]
|
||||
it.ID, it.Needed = old.ID, old.Needed == 1
|
||||
kept[old.ID] = true
|
||||
if err := q.MoveShoppingItem(ctx, db.MoveShoppingItemParams{
|
||||
GroupID: g.ID, Qty: wi.qty, Position: int64(ii), UpdatedAt: now, ID: it.ID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: move item %s: %w", it.ID, err)
|
||||
}
|
||||
} else {
|
||||
it.ID = store.NewID()
|
||||
if err := q.CreateShoppingItem(ctx, db.CreateShoppingItemParams{
|
||||
ID: it.ID, GroupID: g.ID, Name: wi.name, Qty: wi.qty, Position: int64(ii),
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("shopping: store item %q of %s: %w", wi.name, w.date, err)
|
||||
}
|
||||
}
|
||||
g.Items = append(g.Items, it)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
|
||||
// Items first: a group is removed only once it is empty.
|
||||
for _, r := range oldItems {
|
||||
if !kept[r.ShoppingItem.ID] {
|
||||
if err := q.DeleteShoppingItem(ctx, r.ShoppingItem.ID); err != nil {
|
||||
return nil, fmt.Errorf("shopping: remove item %s: %w", r.ShoppingItem.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, g := range oldGroups {
|
||||
if !kept[g.ID] {
|
||||
if err := q.DeleteShoppingGroup(ctx, g.ID); err != nil {
|
||||
return nil, fmt.Errorf("shopping: remove group %s: %w", g.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
func nullString(s sql.NullString) *string {
|
||||
if !s.Valid {
|
||||
return nil
|
||||
}
|
||||
return &s.String
|
||||
}
|
||||
@@ -0,0 +1,823 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"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/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// week is the Monday the tests plan, and weekDates its days.
|
||||
var (
|
||||
week = mustDate("2026-10-12")
|
||||
weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
||||
)
|
||||
|
||||
func mustDate(s string) store.LocalDate {
|
||||
d, err := store.ParseLocalDate(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
type env struct {
|
||||
svc *shopping.Service
|
||||
st *store.Store
|
||||
dataDir string
|
||||
planID string
|
||||
// ids are the dinners' ids by name.
|
||||
ids map[string]string
|
||||
// runner runs the title jobs, two at a time, once start starts it.
|
||||
runner *jobs.Runner
|
||||
// model answers archive_title, which titles the Supply runs; with
|
||||
// nothing queued it fails, and the lists stay untitled.
|
||||
model *llm.Fake
|
||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||
beforeTitle func()
|
||||
// afterTitle, when set, runs once archive_title has returned, before
|
||||
// its title is stored.
|
||||
afterTitle func()
|
||||
// beforeModel, when set, runs as the model is sent a request.
|
||||
beforeModel func(llm.Request)
|
||||
// log holds what the service logged.
|
||||
log *syncBuffer
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer safe for concurrent use.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(t.Context(), dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{st: st, dataDir: dataDir, ids: map[string]string{}, model: &llm.Fake{}, log: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
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 hookedModel{e}, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
title := func(ctx context.Context, scope string, in skills.ArchiveTitleRequest) (skills.ArchiveTitle, error) {
|
||||
if e.beforeTitle != nil {
|
||||
e.beforeTitle()
|
||||
}
|
||||
out, err := skills.TitleArchive(ctx, cfg, scope, in)
|
||||
if e.afterTitle != nil {
|
||||
e.afterTitle()
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = shopping.NewService(st, title, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// hookedModel is the env's model, running beforeModel first.
|
||||
type hookedModel struct{ e *env }
|
||||
|
||||
func (m hookedModel) Generate(ctx context.Context, req llm.Request) (llm.Response, error) {
|
||||
if m.e.beforeModel != nil {
|
||||
m.e.beforeModel(req)
|
||||
}
|
||||
return m.e.model.Generate(ctx, req)
|
||||
}
|
||||
|
||||
func (m hookedModel) ListModels(ctx context.Context) ([]llm.Model, error) {
|
||||
return m.e.model.ListModels(ctx)
|
||||
}
|
||||
|
||||
func (m hookedModel) Verify(ctx context.Context) error { return m.e.model.Verify(ctx) }
|
||||
|
||||
// start starts the env's job runner, stopping it when the test ends.
|
||||
func (e *env) start(t *testing.T) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// waitTitle waits for g's title job to finish and returns it.
|
||||
func (e *env) waitTitle(t *testing.T, g shopping.Generated) jobs.Job {
|
||||
t.Helper()
|
||||
if g.TitleJobID == nil {
|
||||
t.Fatal("generation enqueued no title job")
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
return job
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s still %s", job.ID, job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// ing is a recipe ingredient as recipe grounding stores it: an empty
|
||||
// qty or unit is NULL.
|
||||
type ing struct {
|
||||
name, qty, unit string
|
||||
}
|
||||
|
||||
// dinner stores a Regular named name and returns its id. With ings it is
|
||||
// grounded in a fetched recipe listing them; with none it has no recipe.
|
||||
func (e *env) dinner(t *testing.T, name string, ings ...ing) string {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
var recipeID sql.NullString
|
||||
status := "pending"
|
||||
if ings != nil {
|
||||
recipeID = e.recipe(t, name, ings)
|
||||
status = "grounded"
|
||||
}
|
||||
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: "regular",
|
||||
Origin: "regular",
|
||||
RecipeID: recipeID,
|
||||
GroundingStatus: status,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.ids[name] = d.ID
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// recipe stores a fetched recipe titled title with ings, as
|
||||
// recipes.Grounder stores a normalized page, and returns its id.
|
||||
func (e *env) recipe(t *testing.T, title string, ings []ing) sql.NullString {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
r, err := q.UpsertRecipe(t.Context(), db.UpsertRecipeParams{
|
||||
ID: store.NewID(),
|
||||
SourceUrl: "https://recipes.example/" + strings.ReplaceAll(strings.ToLower(title), " ", "-"),
|
||||
Title: title,
|
||||
RawJsonld: "{}",
|
||||
FetchedAt: now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.setIngredients(t, r.ID, ings)
|
||||
return sql.NullString{String: r.ID, Valid: true}
|
||||
}
|
||||
|
||||
// setIngredients replaces recipe id's ingredients with ings, as a
|
||||
// regrounding does.
|
||||
func (e *env) setIngredients(t *testing.T, id string, ings []ing) {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
if err := q.DeleteRecipeIngredients(t.Context(), id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := store.Now().String()
|
||||
for i, in := range ings {
|
||||
qtyUnit := strings.TrimSpace(in.qty + " " + in.unit)
|
||||
if err := q.CreateRecipeIngredient(t.Context(), db.CreateRecipeIngredientParams{
|
||||
ID: store.NewID(),
|
||||
RecipeID: id,
|
||||
Position: int64(i),
|
||||
RawText: strings.TrimSpace(qtyUnit + " " + in.name),
|
||||
Name: sql.NullString{String: in.name, Valid: true},
|
||||
Qty: sql.NullString{String: in.qty, Valid: in.qty != ""},
|
||||
Unit: sql.NullString{String: in.unit, Valid: in.unit != ""},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recipeOf returns the id of dinner name's recipe.
|
||||
func (e *env) recipeOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d.RecipeID.String
|
||||
}
|
||||
|
||||
// plan stores the week's plan with the dinners named names on weekDates,
|
||||
// in order.
|
||||
func (e *env) plan(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
e.planID = store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: e.planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: e.planID, Date: weekDates[i],
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swap plans the dinner named name on date, as a swap on the Draft board
|
||||
// does.
|
||||
func (e *env) swap(t *testing.T, date, name string) {
|
||||
t.Helper()
|
||||
n, err := db.New(e.st.Writer).SwapPlanDay(t.Context(), db.SwapPlanDayParams{
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, UpdatedAt: store.Now().String(),
|
||||
PlanID: e.planID, Date: date,
|
||||
})
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("swap %s to %s: %d rows, %v", date, name, n, err)
|
||||
}
|
||||
}
|
||||
|
||||
// haveIt marks the item named name of dinner's group "have it".
|
||||
func (e *env) haveIt(t *testing.T, l shopping.List, dinner, name string) string {
|
||||
t.Helper()
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
if g.DinnerName == dinner && it.Name == name {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, it.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return it.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
t.Fatalf("no item %q of %s", name, dinner)
|
||||
return ""
|
||||
}
|
||||
|
||||
func (e *env) generate(t *testing.T) shopping.Generated {
|
||||
t.Helper()
|
||||
g, err := e.svc.Generate(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// lines renders l one item a line as "position date dinner: position
|
||||
// name [qty] needed|have it".
|
||||
func lines(l shopping.List) []string {
|
||||
var out []string
|
||||
for _, g := range l.Groups {
|
||||
for _, it := range g.Items {
|
||||
qty := "-"
|
||||
if it.Qty != nil {
|
||||
qty = *it.Qty
|
||||
}
|
||||
mark := "needed"
|
||||
if !it.Needed {
|
||||
mark = "have it"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", g.Position, g.Date, g.DinnerName, it.Position, it.Name, qty, mark))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stored renders the stored list of the week as lines does, so a test
|
||||
// checks the database holds what Generate returned.
|
||||
func (e *env) stored(t *testing.T) []string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT g.position, g.date, d.name, i.position, i.name, COALESCE(i.qty, '-'), i.needed
|
||||
FROM shopping_lists l
|
||||
JOIN shopping_groups g ON g.list_id = l.id
|
||||
JOIN dinners d ON d.id = g.dinner_id
|
||||
JOIN shopping_items i ON i.group_id = g.id
|
||||
WHERE l.week_start = ?
|
||||
ORDER BY g.position, i.position`, week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var (
|
||||
gpos, ipos, needed int
|
||||
date, dinner, name string
|
||||
qty string
|
||||
)
|
||||
if err := rows.Scan(&gpos, &date, &dinner, &ipos, &name, &qty, &needed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mark := "needed"
|
||||
if needed == 0 {
|
||||
mark = "have it"
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d %s %s: %d %s [%s] %s", gpos, date, dinner, ipos, name, qty, mark))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// count returns the number of rows in table.
|
||||
func (e *env) count(t *testing.T, table string) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM ` + table).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func wantLines(t *testing.T, what string, got, want []string) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s =\n %s\nwant\n %s", what, strings.Join(got, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSevenDayPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"kidney beans", "2", "cans"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pot Roast", ing{"chuck roast", "3", "lb"}, ing{"carrots", "4", ""})
|
||||
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"bell peppers", "3", ""}, ing{"tortillas", "8", ""})
|
||||
e.dinner(t, "Spaghetti Bolognese", ing{"spaghetti", "1", "lb"}, ing{"crushed tomatoes", "28", "oz"})
|
||||
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""}, ing{"dill", "", "sprig"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"bean sprouts", "2", "cups"}, ing{"salt", "", ""})
|
||||
e.dinner(t, "Shawarma Bowls", ing{"chicken thighs", "2", "lb"}, ing{"pita", "4", ""})
|
||||
e.plan(t, "Beef Chili", "Pot Roast", "Chicken Fajitas", "Spaghetti Bolognese", "Sheet Pan Salmon", "Pad Thai", "Shawarma Bowls")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 kidney beans [2 cans] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 onion [1] needed",
|
||||
"1 2026-10-13 Pot Roast: 0 chuck roast [3 lb] needed",
|
||||
"1 2026-10-13 Pot Roast: 1 carrots [4] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 1 bell peppers [3] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 2 tortillas [8] needed",
|
||||
"3 2026-10-15 Spaghetti Bolognese: 0 spaghetti [1 lb] needed",
|
||||
"3 2026-10-15 Spaghetti Bolognese: 1 crushed tomatoes [28 oz] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 0 salmon fillets [4] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 1 lemon [1] needed",
|
||||
"4 2026-10-16 Sheet Pan Salmon: 2 dill [sprig] needed",
|
||||
"5 2026-10-17 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"5 2026-10-17 Pad Thai: 1 bean sprouts [2 cups] needed",
|
||||
"5 2026-10-17 Pad Thai: 2 salt [-] needed",
|
||||
"6 2026-10-18 Shawarma Bowls: 0 chicken thighs [2 lb] needed",
|
||||
"6 2026-10-18 Shawarma Bowls: 1 pita [4] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 0 {
|
||||
t.Errorf("skipped = %+v, want none", g.Skipped)
|
||||
}
|
||||
for _, grp := range g.List.Groups {
|
||||
if grp.DinnerID != e.ids[grp.DinnerName] {
|
||||
t.Errorf("group %s dinner id = %s, want %s", grp.DinnerName, grp.DinnerID, e.ids[grp.DinnerName])
|
||||
}
|
||||
}
|
||||
if g.List.WeekStart != "2026-10-12" || g.List.PlanID != e.planID || g.List.Status != shopping.StatusCurrent || g.List.Title != nil {
|
||||
t.Errorf("list = %+v, want the week's current, untitled list of plan %s", g.List, e.planID)
|
||||
}
|
||||
var title sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE id = ?`, g.List.ID).Scan(&title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if title.Valid {
|
||||
t.Errorf("stored title = %q, want NULL until the AI titles the list", title.String)
|
||||
}
|
||||
|
||||
// Generating again from the same plan changes nothing: same rows, same
|
||||
// ids, no extra rows. The list is still untitled, so it gets another
|
||||
// title job.
|
||||
again := e.generate(t)
|
||||
if again.TitleJobID == nil || g.TitleJobID == nil || *again.TitleJobID == *g.TitleJobID {
|
||||
t.Errorf("title jobs = %v then %v, want one per generation of the untitled list", deref(g.TitleJobID), deref(again.TitleJobID))
|
||||
}
|
||||
again.TitleJobID = g.TitleJobID
|
||||
if !reflect.DeepEqual(again, g) {
|
||||
t.Errorf("second generation =\n %+v\nwant the first\n %+v", again, g)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 1 {
|
||||
t.Errorf("%d lists, want 1", n)
|
||||
}
|
||||
if n := e.count(t, "shopping_items"); n != len(want) {
|
||||
t.Errorf("%d items stored, want %d", n, len(want))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateManualRecipe grounds a dinner with a recipe typed in, through
|
||||
// dinners.Service as the household does: its lines are stored unnormalized,
|
||||
// so each is an item as typed, with no quantity.
|
||||
func TestGenerateManualRecipe(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
model := &llm.Fake{}
|
||||
grounder := recipestest.NewGrounder(t, e.st, e.dataDir, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, model)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(e.st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dsvc, err := dinners.NewService(e.st, grounder, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.dinner(t, "Grandma's Goulash")
|
||||
minutes, method := 45, "stovetop"
|
||||
if _, err := dsvc.Ground(t.Context(), e.ids["Grandma's Goulash"], dinners.Fix{Manual: &dinners.ManualRecipe{
|
||||
Ingredients: []string{"2 lb beef chuck", "1 onion, diced", "paprika to taste"},
|
||||
Steps: []string{"Brown the beef."},
|
||||
Minutes: &minutes,
|
||||
Method: &method,
|
||||
}}, "member-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Grandma's Goulash", "Beef Chili")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Grandma's Goulash: 0 2 lb beef chuck [-] needed",
|
||||
"0 2026-10-12 Grandma's Goulash: 1 1 onion, diced [-] needed",
|
||||
"0 2026-10-12 Grandma's Goulash: 2 paprika to taste [-] needed",
|
||||
"1 2026-10-13 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 0 {
|
||||
t.Errorf("skipped = %+v, want none", g.Skipped)
|
||||
}
|
||||
if reqs := model.Requests(); len(reqs) != 0 {
|
||||
t.Errorf("%d model requests, want none", len(reqs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateSkipsDinnersWithoutIngredients plans a dinner with no recipe
|
||||
// (its grounding pending) and one whose recipe lists no ingredients: each
|
||||
// day gets no group and is reported with why.
|
||||
func TestGenerateSkipsDinnersWithoutIngredients(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Mystery Stew")
|
||||
e.dinner(t, "Toast", []ing{}...) // a recipe with no ingredients
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Mystery Stew", "Toast", "Beef Chili")
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{"0 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed"}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
wantSkipped := []shopping.SkippedDay{
|
||||
{Date: "2026-10-12", DinnerID: e.ids["Mystery Stew"], DinnerName: "Mystery Stew", Reason: shopping.SkipNoRecipe},
|
||||
{Date: "2026-10-13", DinnerID: e.ids["Toast"], DinnerName: "Toast", Reason: shopping.SkipNoIngredients},
|
||||
}
|
||||
if !reflect.DeepEqual(g.Skipped, wantSkipped) {
|
||||
t.Errorf("skipped = %+v, want %+v", g.Skipped, wantSkipped)
|
||||
}
|
||||
if n := e.count(t, "shopping_groups"); n != 1 {
|
||||
t.Errorf("%d groups stored, want 1: a skipped day gets no empty group", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateAfterSwapKeepsHaveIt swaps a day to a new dinner and moves a
|
||||
// dinner to another day, and regrounds a dinner so its ingredients change
|
||||
// order and quantity: an item whose dinner and name are unchanged keeps
|
||||
// its id and "have it" wherever it now is; the rest is added as needed or
|
||||
// removed.
|
||||
func TestGenerateAfterSwapKeepsHaveIt(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Chicken Fajitas", ing{"chicken breast", "500", "g"}, ing{"tortillas", "8", ""})
|
||||
e.dinner(t, "Sheet Pan Salmon", ing{"salmon fillets", "4", ""}, ing{"lemon", "1", ""})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.plan(t, "Beef Chili", "Chicken Fajitas", "Sheet Pan Salmon")
|
||||
first := e.generate(t).List
|
||||
onion := e.haveIt(t, first, "Beef Chili", "onion")
|
||||
tortillas := e.haveIt(t, first, "Chicken Fajitas", "tortillas")
|
||||
lemon := e.haveIt(t, first, "Sheet Pan Salmon", "lemon")
|
||||
|
||||
// Tuesday becomes Pad Thai and Fajitas moves to Wednesday, dropping
|
||||
// the Salmon; the Chili is regrounded: onion first, no beans, a
|
||||
// pepper added and more beef.
|
||||
e.swap(t, "2026-10-13", "Pad Thai")
|
||||
e.swap(t, "2026-10-14", "Chicken Fajitas")
|
||||
e.setIngredients(t, e.recipeOf(t, "Beef Chili"), []ing{{"onion", "2", ""}, {"ground beef", "2", "lb"}, {"jalapeño", "1", ""}})
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Beef Chili: 0 onion [2] have it",
|
||||
"0 2026-10-12 Beef Chili: 1 ground beef [2 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 jalapeño [1] needed",
|
||||
"1 2026-10-13 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"1 2026-10-13 Pad Thai: 1 lime [2] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 0 chicken breast [500 g] needed",
|
||||
"2 2026-10-14 Chicken Fajitas: 1 tortillas [8] have it",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
ids := map[string]string{}
|
||||
for _, grp := range g.List.Groups {
|
||||
for _, it := range grp.Items {
|
||||
ids[grp.DinnerName+"/"+it.Name] = it.ID
|
||||
}
|
||||
}
|
||||
if ids["Beef Chili/onion"] != onion || ids["Chicken Fajitas/tortillas"] != tortillas {
|
||||
t.Errorf("kept items have ids %s and %s, want %s and %s", ids["Beef Chili/onion"], ids["Chicken Fajitas/tortillas"], onion, tortillas)
|
||||
}
|
||||
var n int
|
||||
if err := e.st.Reader.QueryRow(`SELECT count(*) FROM shopping_items WHERE id = ?`, lemon).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Error("the Salmon's lemon is still stored after the Salmon left the plan")
|
||||
}
|
||||
if got := e.count(t, "shopping_items"); got != len(want) {
|
||||
t.Errorf("%d items stored, want %d", got, len(want))
|
||||
}
|
||||
if got := e.count(t, "shopping_groups"); got != 3 {
|
||||
t.Errorf("%d groups stored, want 3", got)
|
||||
}
|
||||
if g.List.ID != first.ID {
|
||||
t.Errorf("list id = %s, want the first generation's %s", g.List.ID, first.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateMatchesRepeatedNamesInOrder lists a name twice in one
|
||||
// dinner: the second "salt" marked "have it" stays the second.
|
||||
func TestGenerateMatchesRepeatedNamesInOrder(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Pasta", ing{"salt", "1", "tbsp"}, ing{"spaghetti", "1", "lb"}, ing{"salt", "", "pinch"})
|
||||
e.plan(t, "Pasta")
|
||||
first := e.generate(t).List
|
||||
second := first.Groups[0].Items[2].ID
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_items SET needed = 0 WHERE id = ?`, second); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.setIngredients(t, e.recipeOf(t, "Pasta"), []ing{{"spaghetti", "1", "lb"}, {"salt", "1", "tbsp"}, {"salt", "", "pinch"}})
|
||||
|
||||
g := e.generate(t)
|
||||
|
||||
wantLines(t, "list", lines(g.List), []string{
|
||||
"0 2026-10-12 Pasta: 0 spaghetti [1 lb] needed",
|
||||
"0 2026-10-12 Pasta: 1 salt [1 tbsp] needed",
|
||||
"0 2026-10-12 Pasta: 2 salt [pinch] have it",
|
||||
})
|
||||
if id := g.List.Groups[0].Items[2].ID; id != second {
|
||||
t.Errorf("second salt id = %s, want %s", id, second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWeekWithoutPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrPlanNotFound) {
|
||||
t.Fatalf("Generate = %v, want ErrPlanNotFound", err)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 0 {
|
||||
t.Errorf("%d lists stored, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefusesNonMonday(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Generate(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Generate of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRefusesFrozenList leaves a list frozen into the Supply
|
||||
// archive as it is.
|
||||
func TestGenerateRefusesFrozenList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Fatalf("Generate = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
|
||||
// TestGeneratePushedRefusesEditedPlan makes the Supply run of a pushed
|
||||
// plan at the revision pushed, then edits the plan: the pushed revision no
|
||||
// longer makes one, and the Supply run is left as it was.
|
||||
func TestGeneratePushedRefusesEditedPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Beef Chili")
|
||||
q := db.New(e.st.Writer)
|
||||
pushed, err := q.GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); err != nil {
|
||||
t.Fatalf("GeneratePushed at the pushed revision: %v", err)
|
||||
}
|
||||
e.swap(t, "2026-10-12", "Pad Thai")
|
||||
if err := q.MarkWeekPlanEdited(t.Context(), db.MarkWeekPlanEditedParams{UpdatedAt: store.Now().String(), ID: e.planID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.GeneratePushed(t.Context(), week, pushed.Revision); !errors.Is(err, shopping.ErrPlanChanged) {
|
||||
t.Fatalf("GeneratePushed after an edit = %v, want ErrPlanChanged", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
|
||||
// TestGenerateRefusesUnnamedIngredient: grounding always names a fetched
|
||||
// recipe's ingredient, so one without a name is an error, and nothing is
|
||||
// stored.
|
||||
func TestGenerateRefusesUnnamedIngredient(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE recipe_ingredients SET name = NULL WHERE position = 1`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := e.svc.Generate(t.Context(), week)
|
||||
if err == nil || !strings.Contains(err.Error(), `ingredient 2 ("1 onion") has no normalized name`) {
|
||||
t.Fatalf("Generate = %v, want the unnamed ingredient refused", err)
|
||||
}
|
||||
if n := e.count(t, "shopping_lists"); n != 0 {
|
||||
t.Errorf("%d lists stored, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetReadsStoredList reads back what Generate stored, with what the
|
||||
// household marked, and counts it.
|
||||
func TestGetReadsStoredList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
e.plan(t, "Beef Chili", "Pot Roast")
|
||||
made := e.generate(t).List
|
||||
e.haveIt(t, made, "Beef Chili", "onion")
|
||||
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantLines(t, "read list", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] have it",
|
||||
"1 2026-10-13 Pot Roast: 0 carrots [4] needed",
|
||||
})
|
||||
if got.ID != made.ID || got.WeekStart != week.String() || got.Status != shopping.StatusCurrent || got.Title != nil || got.SentToChatAt != nil {
|
||||
t.Errorf("read list = %+v", got)
|
||||
}
|
||||
if c := got.Counts(); c != (shopping.Counts{Needed: 2, Total: 3}) {
|
||||
t.Errorf("Counts = %+v, want 2 of 3", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Get = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if _, err := e.svc.Get(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Get of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetNeeded marks items and returns the counts after each change.
|
||||
func TestSetNeeded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"garlic", "2", "cloves"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
onion, garlic := l.Groups[0].Items[1].ID, l.Groups[0].Items[2].ID
|
||||
|
||||
for _, step := range []struct {
|
||||
id string
|
||||
needed bool
|
||||
want shopping.Counts
|
||||
}{
|
||||
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
|
||||
{onion, false, shopping.Counts{Needed: 2, Total: 3}},
|
||||
{garlic, false, shopping.Counts{Needed: 1, Total: 3}},
|
||||
{onion, true, shopping.Counts{Needed: 2, Total: 3}},
|
||||
} {
|
||||
c, err := e.svc.SetNeeded(t.Context(), step.id, step.needed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c != step.want {
|
||||
t.Errorf("SetNeeded(%s, %v) = %+v, want %+v", step.id, step.needed, c, step.want)
|
||||
}
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
"0 2026-10-12 Beef Chili: 2 garlic [2 cloves] have it",
|
||||
})
|
||||
}
|
||||
|
||||
func TestSetNeededUnknownItem(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
if _, err := e.svc.SetNeeded(t.Context(), store.NewID(), false); !errors.Is(err, shopping.ErrItemNotFound) {
|
||||
t.Fatalf("SetNeeded = %v, want ErrItemNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetNeededRefusesFrozenList leaves an item of a list in the Supply
|
||||
// archive as it was.
|
||||
func TestSetNeededRefusesFrozenList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
l := e.generate(t).List
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[0].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Fatalf("SetNeeded = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed"})
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
)
|
||||
|
||||
// titled is archive_title's reply giving title.
|
||||
func titled(title string) llm.GenerateResult {
|
||||
js := fmt.Sprintf(`{"title":%q}`, title)
|
||||
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
|
||||
}
|
||||
|
||||
// titleWeek stores three dinners, one with no recipe, and plans them on
|
||||
// the week's first three days; its job runner is started.
|
||||
func titleWeek(t *testing.T) *env {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"})
|
||||
e.plan(t, "Pad Thai", "Fish Tacos", "Beef Chili")
|
||||
return e
|
||||
}
|
||||
|
||||
// storedTitle returns the week's list's stored title; nil when NULL.
|
||||
func (e *env) storedTitle(t *testing.T) *string {
|
||||
t.Helper()
|
||||
var title sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT title FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&title); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !title.Valid {
|
||||
return nil
|
||||
}
|
||||
return &title.String
|
||||
}
|
||||
|
||||
// runStatuses returns the status of every archive_title run recorded in
|
||||
// ai_runs, oldest first.
|
||||
func (e *env) runStatuses(t *testing.T) []string {
|
||||
t.Helper()
|
||||
rows, err := e.st.Reader.Query(`SELECT status FROM ai_runs WHERE skill = 'archive_title' ORDER BY created_at, rowid`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var s string
|
||||
if err := rows.Scan(&s); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func wantTitle(t *testing.T, what string, got *string, want string) {
|
||||
t.Helper()
|
||||
if got == nil || *got != want {
|
||||
t.Errorf("%s = %v, want %q", what, deref(got), want)
|
||||
}
|
||||
}
|
||||
|
||||
func wantUntitled(t *testing.T, what string, got *string) {
|
||||
t.Helper()
|
||||
if got != nil {
|
||||
t.Errorf("%s = %q, want none", what, *got)
|
||||
}
|
||||
}
|
||||
|
||||
func deref(s *string) any {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// wantJob fails t unless job ended with status and, when failed, code.
|
||||
func wantJob(t *testing.T, job jobs.Job, status jobs.Status, code string) {
|
||||
t.Helper()
|
||||
if job.Status != status || (code != "" && (job.Error == nil || *job.Error != code)) {
|
||||
t.Errorf("title job = %s %v, want %s %s", job.Status, deref(job.Error), status, code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitlesNewList: a new list is returned untitled with a title
|
||||
// job, which titles it by archive_title from the plan's dinners in day
|
||||
// order, a dinner with no group included, and stores the title.
|
||||
func TestGenerateTitlesNewList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
|
||||
g := e.generate(t)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
job := e.waitTitle(t, g)
|
||||
|
||||
wantJob(t, job, jobs.StatusSucceeded, "")
|
||||
if want := `{"list_id":"` + g.List.ID + `","title":"Noodles, Tacos and Chili"}`; string(job.Result) != want {
|
||||
t.Errorf("job result = %s, want %s", job.Result, want)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "title read back", got.Title, "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
want := "Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili"
|
||||
if user := reqs[0].Messages[0].Content; user != want {
|
||||
t.Errorf("user message = %q, want %q", user, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusOK) {
|
||||
t.Errorf("archive_title runs = %q, want one ok", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleTooLongRepaired: a title over 48 characters is repaired
|
||||
// through the repair turn, and the repaired title is stored, not a
|
||||
// truncation of the first.
|
||||
func TestGenerateTitleTooLongRepaired(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
long := "Pad Thai Monday, Fish Taco Tuesday and Chili Wednesday"
|
||||
e.model.QueueGenerate(titled(long), titled("Noodles, Tacos and Chili"))
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusSucceeded, "")
|
||||
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 2 || !strings.Contains(reqs[1].Messages[2].Content, "has 54 characters; shorten it to at most 48") {
|
||||
t.Fatalf("model calls = %+v, want a repair turn asking for a shorter title", reqs)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusRepaired) {
|
||||
t.Errorf("archive_title runs = %q, want one repaired", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleFailureKeepsList: archive_title giving an emoji twice
|
||||
// fails, and the list is still made, whole and untitled, with the failure
|
||||
// logged, recorded in ai_runs and failing the title job.
|
||||
func TestGenerateTitleFailureKeepsList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Taco Week 🌮"), titled("Taco Week 🌮"))
|
||||
|
||||
g, err := e.svc.Generate(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate = %v, want the list made though its title failed", err)
|
||||
}
|
||||
|
||||
want := []string{
|
||||
"0 2026-10-12 Pad Thai: 0 rice noodles [8 oz] needed",
|
||||
"1 2026-10-14 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
}
|
||||
wantLines(t, "list", lines(g.List), want)
|
||||
wantLines(t, "stored list", e.stored(t), want)
|
||||
if len(g.Skipped) != 1 || g.Skipped[0].DinnerName != "Fish Tacos" {
|
||||
t.Errorf("skipped = %+v, want Fish Tacos", g.Skipped)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "returned title", g.List.Title)
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if g.List.Status != shopping.StatusCurrent {
|
||||
t.Errorf("status = %q, want current", g.List.Status)
|
||||
}
|
||||
log := e.log.String()
|
||||
for _, part := range []string{"level=WARN", "Supply run left untitled", "week=2026-10-12", "has an emoji (U+1F32E)"} {
|
||||
if !strings.Contains(log, part) {
|
||||
t.Errorf("log lacks %q:\n%s", part, log)
|
||||
}
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 1 || s[0] != string(harness.StatusBadOutput) {
|
||||
t.Errorf("archive_title runs = %q, want one bad_output", s)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateRetriesTitle: a list left untitled is titled by the week's
|
||||
// next generation, Generate or GeneratePushed; once titled, a remake keeps
|
||||
// its title and asks for none.
|
||||
func TestGenerateRetriesTitle(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(llm.GenerateResult{Err: fmt.Errorf("%w: model down", llm.ErrProvider)})
|
||||
first := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
wantUntitled(t, "first title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "model down") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
plan, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := e.svc.GeneratePushed(t.Context(), week, plan.Revision)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.List.ID != first.List.ID {
|
||||
t.Fatalf("second generation made list %s, want list %s again", second.List.ID, first.List.ID)
|
||||
}
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "retried title", e.storedTitle(t), "Noodles, Tacos and Chili")
|
||||
|
||||
e.swap(t, "2026-10-12", "Beef Chili")
|
||||
third := e.generate(t)
|
||||
wantTitle(t, "title after a remake", third.List.Title, "Noodles, Tacos and Chili")
|
||||
if third.TitleJobID != nil {
|
||||
t.Errorf("a remake of a titled list enqueued title job %s", *third.TitleJobID)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 2 {
|
||||
t.Errorf("%d model calls, want 2: a titled list is not titled again", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnFrozenList: a list frozen into the Supply
|
||||
// archive while its title is written keeps no title, and the failure is
|
||||
// logged.
|
||||
func TestGenerateTitleNotStoredOnFrozenList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.beforeTitle = func() {
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2026-10-19T04:05:00Z'`); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, e.generate(t)), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleOverlapAcrossRename: a dinner renamed between two
|
||||
// generations of an untitled list changes the names to title but not the
|
||||
// plan revision. The two title jobs, running at once, each send the model
|
||||
// their own names; neither is given the other's title.
|
||||
func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"), titled("Drunken Noodles Week"))
|
||||
// Each model call waits for the other, so the runs overlap.
|
||||
var arrived sync.WaitGroup
|
||||
arrived.Add(2)
|
||||
e.beforeModel = func(llm.Request) {
|
||||
arrived.Done()
|
||||
done := make(chan struct{})
|
||||
go func() { arrived.Wait(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Error("the title runs did not overlap: one model call never came")
|
||||
}
|
||||
}
|
||||
revision := func() int64 {
|
||||
p, err := db.New(e.st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p.Revision
|
||||
}
|
||||
|
||||
before := revision()
|
||||
first := e.generate(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE dinners SET name = 'Drunken Noodles', normalized_name = 'drunken noodles' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := e.generate(t)
|
||||
if revision() != before {
|
||||
t.Fatalf("plan revision moved from %d to %d: the rename must leave it", before, revision())
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, first), jobs.StatusSucceeded, "")
|
||||
wantJob(t, e.waitTitle(t, second), jobs.StatusSucceeded, "")
|
||||
var users []string
|
||||
for _, r := range e.model.Requests() {
|
||||
users = append(users, r.Messages[0].Content)
|
||||
}
|
||||
slices.Sort(users)
|
||||
want := []string{
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Drunken Noodles\n- Fish Tacos\n- Beef Chili",
|
||||
"Title the week whose dinners are, in day order from Monday:\n- Pad Thai\n- Fish Tacos\n- Beef Chili",
|
||||
}
|
||||
if !slices.Equal(users, want) {
|
||||
t.Errorf("model was sent %q, want each generation's own names %q", users, want)
|
||||
}
|
||||
if s := e.runStatuses(t); len(s) != 2 {
|
||||
t.Errorf("archive_title runs = %q, want two", s)
|
||||
}
|
||||
if got := e.storedTitle(t); got == nil || (*got != "Noodles, Tacos and Chili" && *got != "Drunken Noodles Week") {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user