Files
MealQuest/internal/plan/plan_test.go
T
dev_agentandClaude Opus 5.5 75d6ad4e19
CI / ci (push) Successful in 3m20s
CI / ci (pull_request) Successful in 3m1s
Add plan service and weekly plan API
Add internal/plan: a draft_week job that drafts the week through the
harness from every Regular and the Quest log dinners no other current or
later plan holds, given the household context and last week's dinners,
and stores it as the week's plan; plan reads with the week label, is_new
and each crock-pot dinner's start_by (dinner time minus minutes_total,
computed on read); and day swaps that recompute is_new and move a synced
plan to changed. Drafting over a synced or changed plan needs confirm, at
enqueue and again when the job stores the week; a redraft keeps each
day's calendar event id.

Serve GET /api/v1/plans/{weekStart}, POST /api/v1/plans/{weekStart}/draft
(202 {job_id}, single-flight per week) and PUT
/api/v1/plans/{weekStart}/days/{date}; a weekStart that is not a Monday
is 400 invalid_week.

Maestro task ea48e650-d510-49cc-8485-0f07174ee731.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 06:18:09 -04:00

677 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plan_test
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"slices"
"strings"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
)
// week is the Monday the tests draft, 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 *plan.Service
st *store.Store
runner *jobs.Runner
model *llm.Fake
// ids are the seeded dinners' ids by name.
ids map[string]string
mu sync.Mutex
inputs []skills.WeekRequest
}
// newEnv returns a plan service over a migrated store in zone
// America/New_York with dinner time 17:30, the Regulars below and Quest
// log dinners Pad Thai and Shawarma Bowls, and an unstarted job runner. It
// drafts through the real harness and draft_week with the scripted
// e.model.
func newEnv(t *testing.T) *env {
t.Helper()
ctx := t.Context()
st, err := store.Open(ctx, t.TempDir())
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := st.Close(); err != nil {
t.Error(err)
}
})
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
t.Fatal(err)
}
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
t.Fatal(err)
}
e := &env{st: st, model: &llm.Fake{}, ids: map[string]string{}}
for _, d := range []struct {
name, status, method string
minutes int64
}{
{"Beef Chili", "regular", "crockpot", 240},
{"Pot Roast", "regular", "crockpot", 0},
{"Chicken Fajitas", "regular", "stovetop", 30},
{"Spaghetti Bolognese", "regular", "stovetop", 45},
{"Sheet Pan Salmon", "regular", "oven", 35},
{"Pad Thai", "quest_log", "stovetop", 35},
{"Shawarma Bowls", "quest_log", "oven", 40},
} {
e.ids[d.name] = e.dinner(t, d.name, d.status, d.method, d.minutes)
}
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 e.model, nil },
Store: st,
Guard: guard,
}
drafter := func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error) {
e.mu.Lock()
e.inputs = append(e.inputs, in)
e.mu.Unlock()
return skills.DraftWeek(ctx, cfg, scope, in)
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
e.runner, err = jobs.NewRunner(st, logger, 1)
if err != nil {
t.Fatal(err)
}
e.svc, err = plan.NewService(st, drafter, e.runner, logger)
if err != nil {
t.Fatal(err)
}
return e
}
// input returns the request of the ith draft_week run.
func (e *env) input(i int) skills.WeekRequest {
e.mu.Lock()
defer e.mu.Unlock()
return e.inputs[i]
}
// 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()
})
}
// dinner stores a dinner and returns its id; minutes 0 is unknown.
func (e *env) dinner(t *testing.T, name, status, method string, minutes int64) string {
t.Helper()
now := store.Now().String()
origin := "regular"
if status != "regular" {
origin = "side_quest"
}
d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: status,
Origin: origin,
Method: sql.NullString{String: method, Valid: method != ""},
MinutesTotal: sql.NullInt64{Int64: minutes, Valid: minutes > 0},
GroundingStatus: "manual",
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
return d.ID
}
// reply scripts one draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) reply(note string, names ...string) {
e.model.QueueGenerate(e.weekReply(note, names...))
}
// weekReply is the draft_week reply planning the dinners named names on
// weekDates, in order.
func (e *env) weekReply(note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, weekDates[i], e.ids[name]))
}
js := fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
return llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}
}
// fullWeek plans every seeded dinner, Beef Chili on Monday and Pot Roast
// on Tuesday.
var fullWeek = []string{"Beef Chili", "Pot Roast", "Pad Thai", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
// waitJob waits for job id to finish and returns it.
func (e *env) waitJob(t *testing.T, id string) jobs.Job {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for {
job, err := e.runner.Get(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
return job
}
if time.Now().After(deadline) {
t.Fatalf("job %s still %s", id, job.Status)
}
time.Sleep(5 * time.Millisecond)
}
}
// draft asks for a draft of week with confirm and waits for its job.
func (e *env) draft(t *testing.T, confirm bool) jobs.Job {
t.Helper()
d, err := e.svc.StartDraft(t.Context(), week, confirm)
if err != nil {
t.Fatal(err)
}
if !d.Created {
t.Fatalf("draft job %s was already queued", d.JobID)
}
return e.waitJob(t, d.JobID)
}
// mustDraft drafts week with confirm, requiring the job to succeed, and
// returns the plan.
func (e *env) mustDraft(t *testing.T, confirm bool) plan.Plan {
t.Helper()
job := e.draft(t, confirm)
if job.Status != jobs.StatusSucceeded {
t.Fatalf("draft job %s, error %v", job.Status, *job.Error)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
var res struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
if err := json.Unmarshal(job.Result, &res); err != nil {
t.Fatal(err)
}
if res.WeekStart != week.String() || res.PlanID != p.ID {
t.Fatalf("draft result = %+v, want week %s plan %s", res, week, p.ID)
}
return p
}
// setStatus sets the plan of week to status, as Push to Calendar would.
func (e *env) setStatus(t *testing.T, status string) {
t.Helper()
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE week_plans SET status = ?, calendar_synced_at = ? WHERE week_start = ?`,
status, store.Now().String(), week.String()); err != nil {
t.Fatal(err)
}
}
func dayNames(p plan.Plan) []string {
var names []string
for _, d := range p.Days {
names = append(names, d.Dinner.Name)
}
return names
}
func TestDraftCreatesPlan(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("Five Regulars and two from your Quest log.", fullWeek...)
p := e.mustDraft(t, false)
if p.Status != plan.StatusDraft || p.WeekStart != "2026-10-12" || p.WeekLabel != "Week 42 · Oct 12 – 18" {
t.Errorf("plan = %s %s %q, want draft 2026-10-12 \"Week 42 · Oct 12 – 18\"", p.Status, p.WeekStart, p.WeekLabel)
}
if p.AINote == nil || *p.AINote != "Five Regulars and two from your Quest log." {
t.Errorf("ai_note = %v", p.AINote)
}
if p.CalendarSyncedAt != nil {
t.Errorf("calendar_synced_at = %v, want null", *p.CalendarSyncedAt)
}
if got := dayNames(p); !slices.Equal(got, fullWeek) {
t.Errorf("days = %v, want %v", got, fullWeek)
}
for i, d := range p.Days {
if d.Date != weekDates[i] {
t.Errorf("day %d date = %s, want %s", i, d.Date, weekDates[i])
}
wantNew := d.Dinner.Name == "Pad Thai" || d.Dinner.Name == "Shawarma Bowls"
if d.IsNew != wantNew {
t.Errorf("%s is_new = %v, want %v", d.Dinner.Name, d.IsNew, wantNew)
}
if d.Dinner.ID != e.ids[d.Dinner.Name] {
t.Errorf("%s id = %s", d.Dinner.Name, d.Dinner.ID)
}
}
chili := p.Days[0]
if chili.Dinner.Method == nil || *chili.Dinner.Method != "crockpot" || chili.Dinner.Minutes == nil || *chili.Dinner.Minutes != 240 || chili.Dinner.Origin != "regular" {
t.Errorf("Beef Chili dinner = %+v", chili.Dinner)
}
// The pool is every Regular and Quest log dinner; last week has no
// plan.
in := e.input(0)
var pool []string
for _, c := range in.Pool {
pool = append(pool, c.Name+"/"+c.Kind)
}
slices.Sort(pool)
want := []string{"Beef Chili/regular", "Chicken Fajitas/regular", "Pad Thai/quest_log", "Pot Roast/regular",
"Shawarma Bowls/quest_log", "Sheet Pan Salmon/regular", "Spaghetti Bolognese/regular"}
if !slices.Equal(pool, want) {
t.Errorf("pool = %v, want %v", pool, want)
}
if in.WeekStart != week || in.DinnerTime != "17:30" || len(in.LastWeek) != 0 {
t.Errorf("request week %s dinner time %s last week %v", in.WeekStart, in.DinnerTime, in.LastWeek)
}
}
func TestStartByIsDinnerTimeMinusMinutesForCrockPot(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
p := e.mustDraft(t, false)
newYork, err := time.LoadLocation("America/New_York")
if err != nil {
t.Fatal(err)
}
// Beef Chili: crock pot, 240 minutes, dinner at 17:30 EDT.
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 13, 30, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
// Pot Roast is a crock-pot dinner with no minutes; the rest are not
// crock-pot dinners.
for _, d := range p.Days[1:] {
if d.StartBy != nil {
t.Errorf("%s start_by = %v, want null", d.Dinner.Name, *d.StartBy)
}
}
if p.AINote != nil {
t.Errorf("ai_note = %q, want null for an empty note", *p.AINote)
}
// start_by follows the household's dinner time.
dinnerTime := "18:45"
if _, err := household.NewSettingsService(e.st).Update(t.Context(), household.SettingsPatch{DinnerTime: &dinnerTime}, "member-1"); err != nil {
t.Fatal(err)
}
p, err = e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if got, want := p.Days[0].StartBy, time.Date(2026, 10, 12, 14, 45, 0, 0, newYork); got == nil || !got.Equal(want) {
t.Errorf("Beef Chili start_by = %v, want %v", got, want)
}
if b, err := json.Marshal(p.Days[0]); err != nil || !strings.Contains(string(b), `"start_by":"2026-10-12T14:45:00-04:00"`) {
t.Errorf("day JSON = %s, %v; want start_by in the household zone", b, err)
}
}
func TestDraftGivesLastWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
last := mustDate("2026-10-05")
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: last.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: "2026-10-06",
DinnerID: sql.NullString{String: e.ids["Chicken Fajitas"], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.reply("", fullWeek...)
e.mustDraft(t, false)
if got := e.input(0).LastWeek; !slices.Equal(got, []string{e.ids["Chicken Fajitas"]}) {
t.Errorf("last week = %v, want Chicken Fajitas", got)
}
}
func TestDraftPoolLeavesOutQuestLogPlannedOnAnotherWeek(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai is on a later week's plan, so the Quest log no longer
// offers it; an eighth dinner keeps the pool at seven.
q := db.New(e.st.Writer)
now := store.Now().String()
later := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: later, WeekStart: "2099-01-05", CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: later, Date: "2099-01-05",
DinnerID: sql.NullString{String: e.ids["Pad Thai"], Valid: true}, IsNew: 1, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "", 0)
names := []string{"Beef Chili", "Pot Roast", "Fish Tacos", "Chicken Fajitas", "Shawarma Bowls", "Spaghetti Bolognese", "Sheet Pan Salmon"}
e.reply("", names...)
p := e.mustDraft(t, false)
if got := dayNames(p); !slices.Equal(got, names) {
t.Errorf("days = %v", got)
}
for _, c := range e.input(0).Pool {
if c.Name == "Pad Thai" {
t.Errorf("pool has Pad Thai, planned on a later week")
}
}
// A redraft of the week keeps offering its own Quest log dinners.
e.reply("", names...)
e.mustDraft(t, false)
if !slices.ContainsFunc(e.input(1).Pool, func(c skills.Candidate) bool { return c.Name == "Shawarma Bowls" }) {
t.Errorf("redraft pool left out Shawarma Bowls, planned on this week")
}
}
func TestDraftFailsWithNotEnoughDinners(t *testing.T) {
e := newEnv(t)
e.start(t)
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonNotEnoughDinners {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonNotEnoughDinners)
}
if n := len(e.model.Requests()); n != 0 {
t.Errorf("model called %d times", n)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestDraftFailsWhenAIFails(t *testing.T) {
e := newEnv(t)
e.start(t)
e.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
job := e.draft(t, false)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonAIFailed {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonAIFailed)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound", err)
}
}
func TestStartDraftIsSingleFlight(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
again, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
if !first.Created || again.Created || again.JobID != first.JobID {
t.Fatalf("drafts = %+v then %+v, want the second to join the first", first, again)
}
other, err := e.svc.StartDraft(t.Context(), mustDate("2026-10-19"), false)
if err != nil {
t.Fatal(err)
}
if !other.Created || other.JobID == first.JobID {
t.Errorf("another week's draft = %+v, want its own job", other)
}
}
func TestRedraftOverDraftNeedsNoConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
second := e.mustDraft(t, false)
if second.ID != first.ID || second.Status != plan.StatusDraft || *second.AINote != "second" {
t.Errorf("redraft = %s %s %v, want plan %s draft \"second\"", second.ID, second.Status, *second.AINote, first.ID)
}
if got := dayNames(second); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
}
func TestDraftOverSyncedOrChangedPlanNeedsConfirm(t *testing.T) {
for _, status := range []string{plan.StatusSynced, plan.StatusChanged} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
first := e.mustDraft(t, false)
e.setStatus(t, status)
// Push to Calendar records each day's event id.
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE plan_days SET calendar_event_id = 'ev-' || date`); err != nil {
t.Fatal(err)
}
if _, err := e.svc.StartDraft(t.Context(), week, false); !errors.Is(err, plan.ErrConfirmRequired) {
t.Fatalf("StartDraft without confirm = %v, want ErrConfirmRequired", err)
}
var queued int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&queued); err != nil {
t.Fatal(err)
}
if queued != 1 {
t.Errorf("%d draft jobs, want only the first", queued)
}
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
e.reply("second", reversed...)
p := e.mustDraft(t, true)
if p.ID != first.ID || p.Status != plan.StatusChanged || p.CalendarSyncedAt == nil {
t.Errorf("confirmed redraft = %s %s synced %v, want plan %s changed, still synced once", p.ID, p.Status, p.CalendarSyncedAt, first.ID)
}
if got := dayNames(p); !slices.Equal(got, reversed) {
t.Errorf("days = %v, want %v", got, reversed)
}
var events int
if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM plan_days WHERE calendar_event_id = 'ev-' || date`).Scan(&events); err != nil {
t.Fatal(err)
}
if events != 7 {
t.Errorf("%d days kept their calendar event id, want 7", events)
}
})
}
}
func TestDraftFailsWhenPlanSyncedAfterAskingWithoutConfirm(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The redraft is asked for while the plan is draft; the plan is synced
// while draft_week runs, so the draft must not replace it.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("second", reversed...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
d, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || *p.AINote != "first" || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %q %v, want the synced first draft", p.Status, *p.AINote, dayNames(p))
}
}
func TestParseWeekStart(t *testing.T) {
if d, err := plan.ParseWeekStart("2026-10-12"); err != nil || d != week {
t.Errorf("ParseWeekStart(Monday) = %v, %v", d, err)
}
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "next-week", ""} {
var invalid *plan.InvalidWeekError
if _, err := plan.ParseWeekStart(s); !errors.As(err, &invalid) {
t.Errorf("ParseWeekStart(%q) = %v, want *InvalidWeekError", s, err)
}
}
}
func TestSwapDay(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
e.ids["Ramen"] = e.dinner(t, "Ramen", "quest_log", "crockpot", 300)
// A Quest log dinner is new, and a crock-pot one gets its start_by.
p, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Ramen"])
if err != nil {
t.Fatal(err)
}
day := p.Days[2]
if day.Dinner.Name != "Ramen" || !day.IsNew || day.StartBy == nil || day.StartBy.Format("15:04") != "12:30" {
t.Errorf("Wednesday = %+v, want Ramen, new, start by 12:30", day)
}
if p.Status != plan.StatusDraft {
t.Errorf("status = %s, want a swapped draft to stay draft", p.Status)
}
// A Regular in place of a Quest log dinner is not new.
p, err = e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"])
if err != nil {
t.Fatal(err)
}
if day := p.Days[2]; day.Dinner.Name != "Fish Tacos" || day.IsNew || day.StartBy != nil {
t.Errorf("Wednesday = %+v, want Fish Tacos, not new, no start_by", day)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
if got := dayNames(p); !slices.Equal(got, want) {
t.Errorf("days = %v, want %v", got, want)
}
got, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(dayNames(got), want) {
t.Errorf("Get days = %v, want %v", dayNames(got), want)
}
}
func TestSwapStatusTransitions(t *testing.T) {
for _, tc := range []struct{ from, want string }{
{plan.StatusDraft, plan.StatusDraft},
{plan.StatusSynced, plan.StatusChanged},
{plan.StatusChanged, plan.StatusChanged},
} {
t.Run(tc.from, func(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, tc.from)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", e.ids["Pad Thai"])
if err != nil {
t.Fatal(err)
}
if p.Status != tc.want {
t.Errorf("status after swap = %s, want %s", p.Status, tc.want)
}
})
}
}
func TestSwapRefusals(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("", fullWeek...)
e.mustDraft(t, false)
e.setStatus(t, plan.StatusSynced)
suggested := e.dinner(t, "Laksa", "suggested", "stovetop", 30)
archived := e.dinner(t, "Old Stew", "archived", "", 0)
var notPlannable *plan.DinnerNotPlannableError
var badDay *plan.InvalidDayError
for _, tc := range []struct {
name string
week store.LocalDate
date, id string
wantError func(error) bool
}{
{"no plan", mustDate("2026-10-19"), "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.Is(err, plan.ErrNotFound) }},
{"unknown dinner", week, "2026-10-12", "no-such-dinner", func(err error) bool { return errors.Is(err, plan.ErrDinnerNotFound) }},
{"suggested dinner", week, "2026-10-12", suggested, func(err error) bool { return errors.As(err, &notPlannable) }},
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, &notPlannable) }},
{"day of another week", week, "2026-10-19", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
{"not a date", week, "monday", e.ids["Pad Thai"], func(err error) bool { return errors.As(err, &badDay) }},
} {
if _, err := e.svc.Swap(t.Context(), tc.week, tc.date, tc.id); !tc.wantError(err) {
t.Errorf("%s: Swap = %v", tc.name, err)
}
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
if p.Status != plan.StatusSynced || !slices.Equal(dayNames(p), fullWeek) {
t.Errorf("plan = %s %v, want it unchanged by refused swaps", p.Status, dayNames(p))
}
}