Feature 14: Weekly Plan Builder (#121)
CI / ci (push) Successful in 3m51s

Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #121.
This commit is contained in:
2026-10-07 16:53:23 -04:00
committed by approver_agent
parent 5291457214
commit 1862f450db
70 changed files with 9332 additions and 114 deletions
+229
View File
@@ -0,0 +1,229 @@
package plan
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"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"
)
// DraftJob is the KindDraftWeek payload: the week to draft, and whether
// the household confirmed drafting over a synced or changed plan.
type DraftJob struct {
WeekStart string `json:"week_start"`
Confirm bool `json:"confirm"`
}
// draftResult is the KindDraftWeek result: the week drafted and its plan.
type draftResult struct {
WeekStart string `json:"week_start"`
PlanID string `json:"plan_id"`
}
func (r draftResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// runDraft drafts the week p.WeekStart: draft_week plans it from the
// Regulars and the Quest log dinners no other current or later plan has,
// given the household context and last week's dinners, and the draft is
// stored as the week's plan if the plan is still as it was before
// draft_week ran and every dinner it planned is still in the pool. A pool
// under seven dinners fails with ReasonNotEnoughDinners and draft_week
// failing with ReasonAIFailed; no week is made up instead.
func (s *Service) runDraft(ctx context.Context, p DraftJob, _ func(draftResult) error) (draftResult, error) {
weekStart, err := ParseWeekStart(p.WeekStart)
if err != nil {
return draftResult{}, fmt.Errorf("plan: draft: %w", err)
}
base, err := s.revision(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
in, err := s.request(ctx, weekStart)
if err != nil {
return draftResult{}, err
}
week, err := s.draft(ctx, household.PeriodKey(weekStart), in)
if err != nil {
if ctx.Err() != nil {
return draftResult{}, ctx.Err()
}
var few *skills.NotEnoughDinnersError
if errors.As(err, &few) {
return draftResult{}, &jobs.Failure{Code: ReasonNotEnoughDinners}
}
// Only the code is logged: the error may quote a model's response.
s.logger.InfoContext(ctx, "week draft failed", "week_start", p.WeekStart, "reason", ReasonAIFailed)
return draftResult{}, &jobs.Failure{Code: ReasonAIFailed}
}
for _, w := range week.Warnings {
s.logger.InfoContext(ctx, "week draft warning", "week_start", p.WeekStart, "warning", w)
}
planID, err := s.storeDraft(ctx, weekStart, household.WeekStart(in.Context.Today), p.Confirm, base, week)
if err != nil {
return draftResult{}, err
}
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, nil
}
// noPlan is the revision of a week with no plan.
const noPlan = -1
// revision returns the revision of the plan of the week starting
// weekStart, or noPlan when the week has none.
func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int64, error) {
cur, err := db.New(s.st.Reader).GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return noPlan, nil
}
if err != nil {
return 0, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
return cur.Revision, nil
}
// request reads what draft_week is given for the week starting weekStart:
// the household context, the pool, last week's dinners and the dinner
// time.
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
}
q := db.New(s.st.Reader)
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: read household settings: %w", err)
}
thisWeek := household.WeekStart(hctx.Today)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make([]skills.Candidate, len(rows))
for i, d := range rows {
kind := skills.KindRegular
if d.Status == dinners.StatusQuestLog {
kind = skills.KindQuestLog
}
pool[i] = skills.Candidate{ID: d.ID, Name: d.Name, Kind: kind, Method: d.Method.String, Minutes: int(d.MinutesTotal.Int64)}
}
last, err := q.ListWeekPlanDinnerIDs(ctx, addDays(weekStart, -7).String())
if err != nil {
return skills.WeekRequest{}, fmt.Errorf("plan: list last week's dinners: %w", err)
}
lastWeek := []string{}
for _, id := range last {
lastWeek = append(lastWeek, id.String)
}
return skills.WeekRequest{
Context: hctx,
WeekStart: weekStart,
Pool: pool,
LastWeek: lastWeek,
DinnerTime: settings.DinnerTime,
}, nil
}
// storeDraft stores week as the plan of the week starting weekStart in one
// transaction and returns the plan's id. A new plan is draft. A draft
// replaces the days of an existing plan, keeping each day's calendar
// event id; a draft plan stays draft and a synced or changed one becomes
// changed, since Calendar no longer shows it. A plan synced since the
// draft was asked for without confirm is left as it is, failing with
// ReasonConfirmRequired, and so is a plan no longer at revision base (the
// revision before draft_week ran, or noPlan), failing with
// ReasonPlanChanged. The pool is read again, as of thisWeek like the pool
// draft_week was given: a day whose dinner is no longer in it, or is in it
// as a Regular rather than the Quest log or the other way round, leaves
// everything as it is, failing with ReasonPoolChanged.
func (s *Service) storeDraft(ctx context.Context, weekStart, thisWeek store.LocalDate, confirm bool, base int64, week skills.Week) (string, error) {
var planID string
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
rows, err := q.ListDraftPool(ctx, db.ListDraftPoolParams{FromWeekStart: thisWeek.String(), WeekStart: weekStart.String()})
if err != nil {
return fmt.Errorf("plan: list draft pool: %w", err)
}
pool := make(map[string]string, len(rows))
for _, d := range rows {
pool[d.ID] = d.Status
}
for _, d := range week.Days {
status, ok := pool[d.DinnerID]
if !ok || (status == dinners.StatusQuestLog) != d.IsNew {
return &jobs.Failure{Code: ReasonPoolChanged}
}
}
now := store.Now().String()
note := sql.NullString{String: week.Note, Valid: week.Note != ""}
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows) && base != noPlan:
return &jobs.Failure{Code: ReasonPlanChanged}
case errors.Is(err, sql.ErrNoRows):
planID = store.NewID()
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{
ID: planID,
WeekStart: weekStart.String(),
AiNote: note,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: create week %s: %w", weekStart, err)
}
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return &jobs.Failure{Code: ReasonConfirmRequired}
case cur.Revision != base:
return &jobs.Failure{Code: ReasonPlanChanged}
default:
planID = cur.ID
status := StatusDraft
if cur.Status != StatusDraft {
status = StatusChanged
}
if err := q.RedraftWeekPlan(ctx, db.RedraftWeekPlanParams{Status: status, AiNote: note, UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: redraft %s: %w", cur.ID, err)
}
}
for _, d := range week.Days {
isNew := int64(0)
if d.IsNew {
isNew = 1
}
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{
ID: store.NewID(),
PlanID: planID,
Date: d.Date,
DinnerID: sql.NullString{String: d.DinnerID, Valid: true},
IsNew: isNew,
CreatedAt: now,
UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: put %s of %s: %w", d.Date, planID, err)
}
}
return nil
})
if err != nil {
return "", err
}
return planID, nil
}
// addDays returns d plus n days.
func addDays(d store.LocalDate, n int) store.LocalDate {
y, m, day := d.Date()
return store.LocalDateOf(time.Date(y, m, day+n, 0, 0, 0, 0, time.UTC))
}
+445
View File
@@ -0,0 +1,445 @@
// Package plan keeps the household's weekly plans: the Draft board the AI
// drafts from the Regulars and the Quest log, and the swaps the household
// makes on it. A draft runs as a job: draft_week plans seven dinners, one
// a day from Monday to Sunday, and the job stores them as the week's plan,
// replacing any draft before it unless the household edited the plan
// while draft_week ran. A plan is draft until Push to Calendar
// syncs it (Feature 16); an edit of a synced plan makes it changed.
//
// The Rotation check follows a plan day: MarkTried moves the Quest log
// dinners planned on it to tried, and the household answers "Keep it in
// rotation?" for each (D-021). Only their keep makes a dinner a Regular.
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"slices"
"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"
)
// KindDraftWeek is the job that drafts a week's plan.
const KindDraftWeek = "draft_week"
// Plan statuses (the week_plans.status column).
const (
StatusDraft = "draft"
StatusSynced = "synced"
StatusChanged = "changed"
)
// Reasons a draft failed, stored as the KindDraftWeek job's failure code.
// Any other failure, such as the database's, is the runner's
// internal_error.
const (
// ReasonNotEnoughDinners: the Regulars and Quest log hold fewer than
// seven dinners to draft from.
ReasonNotEnoughDinners = "not_enough_dinners"
// ReasonAIFailed: draft_week failed; no week is made up instead.
ReasonAIFailed = "ai_failed"
// ReasonConfirmRequired: the plan was synced after the draft was
// asked for without confirm, so the draft does not replace it.
ReasonConfirmRequired = "confirm_required"
// ReasonPlanChanged: the plan was edited while draft_week ran, so the
// draft does not replace the edit.
ReasonPlanChanged = "plan_changed"
// ReasonPoolChanged: a dinner the draft planned left the pool while
// draft_week ran (archived, tried, or its Quest log dinner planned on
// another week), so the draft is not stored; drafting again uses the
// pool as it is now.
ReasonPoolChanged = "pool_changed"
)
// Reasons are every draft failure reason.
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired, ReasonPlanChanged, ReasonPoolChanged}
// ContextBudget is the character budget of the household context
// draft_week is given (see harness.BuildContext).
const ContextBudget = 16000
// plannable are the dinner statuses a day may be swapped to.
var plannable = []string{dinners.StatusRegular, dinners.StatusQuestLog}
// ErrNotFound is returned for a week with no plan.
var ErrNotFound = errors.New("plan not found")
// ErrDinnerNotFound is a swap to an unknown dinner.
var ErrDinnerNotFound = errors.New("dinner not found")
// ErrDinnerAlreadyPlanned is a swap to a dinner already on another day of
// the week.
var ErrDinnerAlreadyPlanned = errors.New("the dinner is already on another day of this week")
// ErrConfirmRequired is a draft over a synced or changed plan asked for
// without confirm.
var ErrConfirmRequired = errors.New("the plan was pushed to Calendar; drafting over it needs confirm")
// ErrDraftRunning is a confirmed draft asked for while a draft of the week
// asked for without confirm is queued or running.
var ErrDraftRunning = errors.New("a draft of the week without confirm is already running; ask again when it finishes")
// InvalidWeekError is a week start that is not a Monday date.
type InvalidWeekError struct {
Value string
}
func (e *InvalidWeekError) Error() string {
return fmt.Sprintf("week start %q is not a Monday date (YYYY-MM-DD)", e.Value)
}
// InvalidDayError is a swap of a date outside the plan's week.
type InvalidDayError struct {
Value string
}
func (e *InvalidDayError) Error() string {
return fmt.Sprintf("date %q is not a day of the week (YYYY-MM-DD)", e.Value)
}
// DinnerNotPlannableError is a swap to a dinner that is neither a Regular
// nor in the Quest log.
type DinnerNotPlannableError struct {
Status string
}
func (e *DinnerNotPlannableError) Error() string {
return fmt.Sprintf("the dinner is %s; only Regulars and Quest log dinners can be planned", e.Status)
}
// ParseWeekStart parses s, a household-local Monday as YYYY-MM-DD.
func ParseWeekStart(s string) (store.LocalDate, error) {
d, err := store.ParseLocalDate(s)
if err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
if _, err := household.WeekDays(d); err != nil {
return store.LocalDate{}, &InvalidWeekError{Value: s}
}
return d, nil
}
// Drafter runs draft_week on in; scope names the week. In production it is
// skills.DraftWeek with the household's harness.Config.
type Drafter func(ctx context.Context, scope string, in skills.WeekRequest) (skills.Week, error)
// Service drafts, reads and edits weekly plans.
type Service struct {
st *store.Store
draft Drafter
logger *slog.Logger
draftJob *jobs.Kind[DraftJob]
}
// NewService returns a service over st that drafts with draft and
// registers the KindDraftWeek job on runner, which must not have started
// yet. The kind is service-only: StartDraft, which keeps one draft in
// flight per week, is the only way to enqueue it.
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
return nil, errors.New("plan: nil store")
case draft == nil:
return nil, errors.New("plan: nil drafter")
case runner == nil:
return nil, errors.New("plan: nil job runner")
case logger == nil:
return nil, errors.New("plan: nil logger")
}
s := &Service{st: st, draft: draft, logger: logger}
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
if err != nil {
return nil, err
}
if err := kind.ServiceOnly(); err != nil {
return nil, err
}
s.draftJob = kind
return s, nil
}
// Plan is a week's plan as the API shows it.
type Plan struct {
ID string `json:"id"`
WeekStart string `json:"week_start"`
// WeekLabel is e.g. "Week 41 · Oct 5 – 11".
WeekLabel string `json:"week_label"`
Status string `json:"status"`
AINote *string `json:"ai_note"`
CalendarSyncedAt *string `json:"calendar_synced_at"`
Days []Day `json:"days"`
UpdatedAt string `json:"updated_at"`
}
// Day is one day of a plan.
type Day struct {
// Date is household-local, YYYY-MM-DD.
Date string `json:"date"`
Dinner Dinner `json:"dinner"`
// IsNew is true for a dinner planned from the Quest log.
IsNew bool `json:"is_new"`
// StartBy is when a crock-pot dinner must start to be ready at the
// household's dinner time; null for any other dinner and for a
// crock-pot dinner whose minutes are unknown.
StartBy *time.Time `json:"start_by"`
}
// Dinner is a planned dinner as a plan day shows it.
type Dinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
Minutes *int64 `json:"minutes"`
Method *string `json:"method"`
Origin string `json:"origin"`
}
// Get returns the plan of the week starting weekStart, or ErrNotFound.
// The plan and the household settings come from one read transaction.
func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (p Plan, err error) {
tx, err := s.st.Reader.BeginTx(ctx, nil)
if err != nil {
return Plan{}, fmt.Errorf("plan: begin read: %w", err)
}
defer func() {
err = errors.Join(err, tx.Rollback())
}()
return readPlan(ctx, db.New(tx), weekStart)
}
// readPlan reads the plan of the week starting weekStart through q, with
// each day's start_by from the household's dinner time and zone.
func readPlan(ctx context.Context, q *db.Queries, weekStart store.LocalDate) (Plan, error) {
label, err := household.WeekLabel(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
row, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return Plan{}, ErrNotFound
}
if err != nil {
return Plan{}, fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
settings, err := q.GetHouseholdSettings(ctx)
if err != nil {
return Plan{}, fmt.Errorf("plan: read household settings: %w", err)
}
zone, err := household.LoadZone(settings.Timezone)
if err != nil {
return Plan{}, fmt.Errorf("plan: household settings: %w", err)
}
rows, err := q.ListPlanDays(ctx, row.ID)
if err != nil {
return Plan{}, fmt.Errorf("plan: read days of %s: %w", row.ID, err)
}
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, fmt.Errorf("plan: %w", err)
}
if len(rows) != len(dates) {
return Plan{}, fmt.Errorf("plan: week %s has %d planned days, want %d", weekStart, len(rows), len(dates))
}
p := Plan{
ID: row.ID,
WeekStart: row.WeekStart,
WeekLabel: label,
Status: row.Status,
AINote: nullString(row.AiNote),
CalendarSyncedAt: nullString(row.CalendarSyncedAt),
Days: make([]Day, len(rows)),
UpdatedAt: row.UpdatedAt,
}
for i, r := range rows {
if r.Date != dates[i].String() {
return Plan{}, fmt.Errorf("plan: week %s day %d is %s, want %s", weekStart, i+1, r.Date, dates[i])
}
startBy, err := startBy(zone, dates[i], settings.DinnerTime, r.Dinner)
if err != nil {
return Plan{}, fmt.Errorf("plan: start by of %s: %w", r.Date, err)
}
p.Days[i] = Day{Date: r.Date, Dinner: fromDinner(r.Dinner), IsNew: r.IsNew == 1, StartBy: startBy}
}
return p, nil
}
// startBy returns when crock-pot dinner d must start on date to be ready at
// dinnerTime in zone, or nil for any other dinner and for a crock-pot
// dinner whose minutes are unknown.
func startBy(zone *time.Location, date store.LocalDate, dinnerTime string, d db.Dinner) (*time.Time, error) {
if !d.Method.Valid || d.Method.String != "crockpot" || !d.MinutesTotal.Valid || d.MinutesTotal.Int64 <= 0 {
return nil, nil
}
at, err := household.LocalTime(zone, date, dinnerTime)
if err != nil {
return nil, err
}
at = at.Add(-time.Duration(d.MinutesTotal.Int64) * time.Minute)
return &at, nil
}
// Draft is a draft asked for: the job drafting the week, and whether this
// call enqueued it rather than found one already queued or running.
type Draft struct {
JobID string `json:"jobId"`
Created bool `json:"-"`
}
// StartDraft enqueues the KindDraftWeek job drafting the week starting
// weekStart, unless one is already queued or running for it, which it
// returns instead with Created false, so a week has at most one draft in
// flight. A confirmed draft joins only a confirmed job: while the week's
// job was asked for without confirm, it fails with ErrDraftRunning and
// enqueues nothing. Drafting over a synced or changed plan needs confirm,
// else it fails with ErrConfirmRequired and enqueues nothing.
func (s *Service) StartDraft(ctx context.Context, weekStart store.LocalDate, confirm bool) (Draft, error) {
if _, err := household.WeekDays(weekStart); err != nil {
return Draft{}, &InvalidWeekError{Value: weekStart.String()}
}
var d Draft
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
case cur.Status != StatusDraft && !confirm:
return ErrConfirmRequired
}
active, err := q.GetActiveDraftJob(ctx, db.GetActiveDraftJobParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
case confirm && active.Confirmed != 1:
return ErrDraftRunning
default:
d = Draft{JobID: active.ID}
return nil
}
id, err := s.draftJob.EnqueueTx(ctx, tx, DraftJob{WeekStart: weekStart.String(), Confirm: confirm})
if err != nil {
return fmt.Errorf("plan: enqueue draft of %s: %w", weekStart, err)
}
d = Draft{JobID: id, Created: true}
return nil
})
if err != nil {
return Draft{}, err
}
if d.Created {
s.draftJob.Wake()
}
return d, nil
}
// Swap plans dinnerID on date of the week starting weekStart, marking the
// day new when the dinner is in the Quest log. Swapping a synced plan
// makes it changed. The week must have a plan (else ErrNotFound), date
// must be one of its days (else *InvalidDayError), the dinner must be a
// Regular or in the Quest log (else ErrDinnerNotFound or
// *DinnerNotPlannableError), and it must not be on another day of the
// week (else ErrDinnerAlreadyPlanned). It returns the plan as swapped.
func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, dinnerID string) (Plan, error) {
dates, err := household.WeekDays(weekStart)
if err != nil {
return Plan{}, &InvalidWeekError{Value: weekStart.String()}
}
day, err := store.ParseLocalDate(date)
if err != nil || !slices.Contains(dates[:], day) {
return Plan{}, &InvalidDayError{Value: date}
}
var p Plan
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
cur, err := q.GetWeekPlan(ctx, weekStart.String())
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return fmt.Errorf("plan: read week %s: %w", weekStart, err)
}
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
if !slices.Contains(plannable, d.Status) {
return &DinnerNotPlannableError{Status: d.Status}
}
days, err := q.ListPlanDays(ctx, cur.ID)
if err != nil {
return fmt.Errorf("plan: read days of %s: %w", cur.ID, err)
}
if slices.ContainsFunc(days, func(r db.ListPlanDaysRow) bool { return r.Date != day.String() && r.Dinner.ID == d.ID }) {
return ErrDinnerAlreadyPlanned
}
now := store.Now().String()
n, err := q.SwapPlanDay(ctx, db.SwapPlanDayParams{
DinnerID: sql.NullString{String: d.ID, Valid: true},
IsNew: isNew(d.Status),
UpdatedAt: now,
PlanID: cur.ID,
Date: day.String(),
})
if err != nil {
return fmt.Errorf("plan: swap %s of %s: %w", day, cur.ID, err)
}
if n != 1 {
return fmt.Errorf("plan: %s has no day %s", cur.ID, day)
}
if err := q.MarkWeekPlanEdited(ctx, db.MarkWeekPlanEditedParams{UpdatedAt: now, ID: cur.ID}); err != nil {
return fmt.Errorf("plan: mark %s edited: %w", cur.ID, err)
}
p, err = readPlan(ctx, q, weekStart)
return err
})
if err != nil {
return Plan{}, err
}
return p, nil
}
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
func isNew(status string) int64 {
if status == dinners.StatusQuestLog {
return 1
}
return 0
}
func fromDinner(d db.Dinner) Dinner {
out := Dinner{
ID: d.ID,
Name: d.Name,
Method: nullString(d.Method),
Origin: d.Origin,
}
if d.MinutesTotal.Valid {
out.Minutes = &d.MinutesTotal.Int64
}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
func nullString(s sql.NullString) *string {
if !s.Valid {
return nil
}
return &s.String
}
+946
View File
@@ -0,0 +1,946 @@
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
// with two workers. 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))
// Two workers, as cmd/server runs, so drafts of a week could overlap.
e.runner, err = jobs.NewRunner(st, logger, 2)
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 {
return e.weekReplyOn(weekDates, note, names...)
}
// weekReplyOn is the draft_week reply planning the dinners named names on
// dates, in order.
func (e *env) weekReplyOn(dates []string, note string, names ...string) llm.GenerateResult {
var days []string
for i, name := range names {
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[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 TestStartByAcrossDaylightSavingTimeEnd(t *testing.T) {
e := newEnv(t)
// New York leaves daylight saving time at 02:00 on Sunday 2026-11-01.
// Beef Chili (240 minutes) is planned Monday to Saturday and a 20-hour
// crock-pot dinner on Sunday, so its start crosses the change.
stew := e.dinner(t, "Overnight Stew", "regular", "crockpot", 1200)
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
dst := mustDate("2026-10-26")
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: dst.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
dates := []string{"2026-10-26", "2026-10-27", "2026-10-28", "2026-10-29", "2026-10-30", "2026-10-31", "2026-11-01"}
for i, date := range dates {
dinner := e.ids["Beef Chili"]
if i == 6 {
dinner = stew
}
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: date,
DinnerID: sql.NullString{String: dinner, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
p, err := e.svc.Get(t.Context(), dst)
if err != nil {
t.Fatal(err)
}
// Saturday's dinner is 17:30 EDT; Sunday's is 17:30 EST, 22:30 UTC, and
// twenty hours before it is 22:30 EDT on Saturday.
for _, tc := range []struct {
day int
want string
}{
{5, `"2026-10-31T13:30:00-04:00"`},
{6, `"2026-10-31T22:30:00-04:00"`},
} {
b, err := json.Marshal(p.Days[tc.day].StartBy)
if err != nil || string(b) != tc.want {
t.Errorf("%s start_by = %s, %v; want %s", dates[tc.day], b, err, tc.want)
}
}
// Swapped to Short Ribs (240 minutes), Sunday starts at 13:30 EST.
ribs := e.dinner(t, "Short Ribs", "regular", "crockpot", 240)
if _, err := e.svc.Swap(t.Context(), dst, "2026-11-01", ribs); err != nil {
t.Fatal(err)
}
if p, err = e.svc.Get(t.Context(), dst); err != nil {
t.Fatal(err)
}
if b, err := json.Marshal(p.Days[6].StartBy); err != nil || string(b) != `"2026-11-01T13:30:00-05:00"` {
t.Errorf("Sunday Short Ribs start_by = %s, %v; want 13:30 EST", 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 TestDraftFailsWhenPoolDinnerLeavesWhileDrafting(t *testing.T) {
for _, status := range []string{"archived", "tried"} {
t.Run(status, func(t *testing.T) {
e := newEnv(t)
e.start(t)
// Pad Thai leaves the Quest log while draft_week plans it.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
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
if _, err := e.st.Writer.ExecContext(t.Context(), `UPDATE dinners SET status = ? WHERE id = ?`, status, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
})
}
}
func TestDraftsOfTwoWeeksDoNotShareAQuestLogDinner(t *testing.T) {
e := newEnv(t)
e.start(t)
// This week's draft plans Pad Thai; while draft_week runs, the other
// worker drafts a later week with Pad Thai too and stores it first.
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("", fullWeek...)
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
later := mustDate("2099-01-05")
laterDates := []string{"2099-01-05", "2099-01-06", "2099-01-07", "2099-01-08", "2099-01-09", "2099-01-10", "2099-01-11"}
e.model.QueueGenerate(e.weekReplyOn(laterDates, "", fullWeek...))
other, err := e.svc.StartDraft(t.Context(), later, false)
if err != nil {
t.Fatal(err)
}
if job := e.waitJob(t, other.JobID); job.Status != jobs.StatusSucceeded {
t.Fatalf("later week's draft %s %v", job.Status, job.Error)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPoolChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPoolChanged)
}
if _, err := e.svc.Get(t.Context(), week); !errors.Is(err, plan.ErrNotFound) {
t.Errorf("Get = %v, want ErrNotFound: no partial plan", err)
}
}
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 TestSimultaneousStartDraftsShareOneJob(t *testing.T) {
for _, confirmFirst := range []bool{false, true} {
t.Run(fmt.Sprintf("first confirm %v", confirmFirst), func(t *testing.T) {
e := newEnv(t)
first, err := e.svc.StartDraft(t.Context(), week, confirmFirst)
if err != nil {
t.Fatal(err)
}
// Twenty simultaneous drafts, half of them confirmed: every
// one joins the queued job, except a confirmed one while the
// job is unconfirmed, which is refused.
const n = 20
drafts := make([]plan.Draft, n)
errs := make([]error, n)
var wg sync.WaitGroup
for i := range n {
wg.Go(func() {
drafts[i], errs[i] = e.svc.StartDraft(context.Background(), week, i%2 == 1)
})
}
wg.Wait()
for i, d := range drafts {
if i%2 == 1 && !confirmFirst {
if !errors.Is(errs[i], plan.ErrDraftRunning) {
t.Errorf("confirmed draft %d = %+v, %v, want ErrDraftRunning", i, d, errs[i])
}
continue
}
if errs[i] != nil || d.JobID != first.JobID || d.Created {
t.Errorf("draft %d = %+v, %v, want it to join %s", i, d, errs[i], first.JobID)
}
}
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 1", queued)
}
})
}
}
func TestConfirmedDraftWhileUnconfirmedDraftRuns(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// An unconfirmed redraft is running when the plan is synced; the
// household then confirms a redraft. With a second worker idle, a
// second job would draft alongside the first, so the confirmed draft
// is refused and only the first job runs draft_week.
reversed := slices.Clone(fullWeek)
slices.Reverse(reversed)
started, release := make(chan struct{}), make(chan struct{})
r := e.weekReply("unconfirmed", fullWeek...)
r.Started, r.Release = started, release
e.model.QueueGenerate(r)
e.reply("concurrent", reversed...)
unconfirmed, err := e.svc.StartDraft(t.Context(), week, false)
if err != nil {
t.Fatal(err)
}
<-started
e.setStatus(t, plan.StatusSynced)
if d, err := e.svc.StartDraft(t.Context(), week, true); !errors.Is(err, plan.ErrDraftRunning) {
t.Fatalf("confirmed draft = %+v, %v, want ErrDraftRunning", d, err)
}
close(release)
if job := e.waitJob(t, unconfirmed.JobID); job.Status != jobs.StatusFailed || *job.Error != plan.ReasonConfirmRequired {
t.Errorf("unconfirmed job %s %v, want failed %s", job.Status, job.Error, plan.ReasonConfirmRequired)
}
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 != 2 {
t.Errorf("%d draft jobs, want the first and the unconfirmed redraft", queued)
}
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))
}
// Asked again once the unconfirmed job has finished, the confirmed
// draft replaces the plan.
if p := e.mustDraft(t, true); p.Status != plan.StatusChanged || *p.AINote != "concurrent" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("plan = %s %q %v, want the confirmed draft, changed", p.Status, *p.AINote, dayNames(p))
}
}
func TestDraftFailsWhenPlanSwappedWhileDrafting(t *testing.T) {
e := newEnv(t)
e.start(t)
e.reply("first", fullWeek...)
e.mustDraft(t, false)
// The household swaps Wednesday while draft_week runs; the draft must
// not erase the swap.
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.ids["Fish Tacos"] = e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
if _, err := e.svc.Swap(t.Context(), week, "2026-10-14", e.ids["Fish Tacos"]); err != nil {
t.Fatal(err)
}
close(release)
job := e.waitJob(t, d.JobID)
if job.Status != jobs.StatusFailed || *job.Error != plan.ReasonPlanChanged {
t.Fatalf("job %s %v, want failed %s", job.Status, job.Error, plan.ReasonPlanChanged)
}
p, err := e.svc.Get(t.Context(), week)
if err != nil {
t.Fatal(err)
}
want := slices.Clone(fullWeek)
want[2] = "Fish Tacos"
if *p.AINote != "first" || !slices.Equal(dayNames(p), want) {
t.Errorf("plan = %q %v, want the first draft with the swap %v", *p.AINote, dayNames(p), want)
}
// A draft after the swap replaces the plan.
e.reply("third", reversed...)
if p := e.mustDraft(t, false); *p.AINote != "third" || !slices.Equal(dayNames(p), reversed) {
t.Errorf("redraft after the swap = %q %v, want the third draft", *p.AINote, dayNames(p))
}
}
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)
}
// 2026-02-30 and 2027-02-29 would be Mondays were they dates.
for _, s := range []string{"2026-10-13", "2026-10-18", "2026-10-5", "2026-02-30", "2027-02-29", "2026-13-05", "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)
fishTacos := e.dinner(t, "Fish Tacos", "regular", "stovetop", 25)
p, err := e.svc.Swap(t.Context(), week, "2026-10-12", fishTacos)
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) }},
{"dinner on another day", week, "2026-10-12", e.ids["Pot Roast"], func(err error) bool { return errors.Is(err, plan.ErrDinnerAlreadyPlanned) }},
{"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) }},
{"impossible date", week, "2026-10-32", 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))
}
}
+223
View File
@@ -0,0 +1,223 @@
package plan
import (
"context"
"database/sql"
"errors"
"fmt"
"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/store"
)
// Rotation check decisions (the rotation_reviews.decision column), the
// household's answer to "Keep it in rotation?" (D-021).
const (
// DecisionKeep makes the tried dinner a Regular.
DecisionKeep = "keep"
// DecisionDrop ("Not for us") archives it; its name stays among the
// passed names, so it is never suggested again.
DecisionDrop = "drop"
// DecisionNotMade ("We didn't make it") returns it to the Quest log.
DecisionNotMade = "not_made"
)
// decisionStatus is the status each decision moves a tried dinner to. Keep
// is the only way a tried dinner becomes a Regular: nothing automatic
// promotes one (D-021).
var decisionStatus = map[string]string{
DecisionKeep: dinners.StatusRegular,
DecisionDrop: dinners.StatusArchived,
DecisionNotMade: dinners.StatusQuestLog,
}
// ErrReviewNotFound is a decision on a dinner that has never had a
// Rotation check: it was never tried.
var ErrReviewNotFound = errors.New("the dinner has no Rotation check")
// ErrNoPendingReview is a decision on a dinner whose Rotation checks are
// all decided, such as a second decision.
var ErrNoPendingReview = errors.New("the dinner has no pending Rotation check")
// ErrDinnerNotTried is a decision on a pending Rotation check whose dinner
// is no longer tried, such as one archived meanwhile.
var ErrDinnerNotTried = errors.New("the dinner is no longer tried")
// InvalidDecisionError is a decision that is not keep, drop or not_made.
type InvalidDecisionError struct {
Value string
}
func (e *InvalidDecisionError) Error() string {
return fmt.Sprintf("decision %q is not one of keep, drop, not_made", e.Value)
}
// ReviewDinner is the dinner of a Rotation check.
type ReviewDinner struct {
ID string `json:"id"`
Name string `json:"name"`
Photo *string `json:"photo"`
}
// Review is a pending Rotation check: the dinner tried and the plan date
// it was planned on.
type Review struct {
Dinner ReviewDinner `json:"dinner"`
// PlanDate is household-local, YYYY-MM-DD.
PlanDate string `json:"plan_date"`
}
// Decided is a Rotation check as decided.
type Decided struct {
Dinner ReviewDinner `json:"dinner"`
PlanDate string `json:"plan_date"`
Decision string `json:"decision"`
// DinnerStatus is the status the decision moved the dinner to.
DinnerStatus string `json:"dinner_status"`
DecidedAt string `json:"decided_at"`
UpdatedBy string `json:"updated_by"`
}
// MarkTried moves every dinner in the Quest log planned on date, the
// household-local day just past, on a plan of any status, to tried and
// opens a pending Rotation check of it for date. It is idempotent: a
// dinner already checked for date is left as it is, so a later run never
// moves one returned to the Quest log ("We didn't make it") again. It
// returns how many dinners it moved.
func (s *Service) MarkTried(ctx context.Context, date store.LocalDate) (int, error) {
var n int
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
ids, err := q.ListTriedCandidates(ctx, date.String())
if err != nil {
return fmt.Errorf("plan: list Quest log dinners planned on %s: %w", date, err)
}
now := store.Now().String()
for _, id := range ids {
moved, err := q.MarkDinnerTried(ctx, db.MarkDinnerTriedParams{UpdatedAt: now, ID: id})
if err != nil {
return fmt.Errorf("plan: mark %s tried: %w", id, err)
}
if moved != 1 {
return fmt.Errorf("plan: dinner %s left the Quest log while marking it tried", id)
}
if err := q.CreateRotationReview(ctx, db.CreateRotationReviewParams{
ID: store.NewID(), DinnerID: id, PlanDate: date.String(), CreatedAt: now, UpdatedAt: now,
}); err != nil {
return fmt.Errorf("plan: open Rotation check of %s for %s: %w", id, date, err)
}
}
n = len(ids)
return nil
})
if err != nil {
return 0, err
}
return n, nil
}
// PendingReviews returns the pending Rotation checks, oldest plan date
// first.
func (s *Service) PendingReviews(ctx context.Context) ([]Review, error) {
rows, err := db.New(s.st.Reader).ListPendingRotationReviews(ctx)
if err != nil {
return nil, fmt.Errorf("plan: list pending Rotation checks: %w", err)
}
out := make([]Review, len(rows))
for i, r := range rows {
out[i] = Review{Dinner: reviewDinner(r.Dinner), PlanDate: r.PlanDate}
}
return out, nil
}
// DecideReview applies decision to the pending Rotation check of dinnerID
// and closes it, recording updatedBy: keep makes the dinner a Regular,
// drop archives it and not_made returns it to the Quest log; its origin is
// kept; a drop records the dinner's name on the check, so renaming the
// dinner later never takes it off the passed names. An unknown dinner is
// ErrDinnerNotFound, a dinner never checked ErrReviewNotFound, one whose
// checks are all decided (such as a second decision) ErrNoPendingReview,
// and one no longer tried ErrDinnerNotTried; none of them changes anything.
func (s *Service) DecideReview(ctx context.Context, dinnerID, decision, updatedBy string) (Decided, error) {
status, ok := decisionStatus[decision]
if !ok {
return Decided{}, &InvalidDecisionError{Value: decision}
}
if updatedBy == "" {
return Decided{}, errors.New("plan: decide Rotation check: no member to record as updated_by")
}
var out Decided
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
q := db.New(tx)
d, err := q.GetDinner(ctx, dinnerID)
if errors.Is(err, sql.ErrNoRows) {
return ErrDinnerNotFound
}
if err != nil {
return fmt.Errorf("plan: read dinner %s: %w", dinnerID, err)
}
review, err := q.GetPendingRotationReview(ctx, d.ID)
if errors.Is(err, sql.ErrNoRows) {
checks, err := q.CountRotationReviews(ctx, d.ID)
if err != nil {
return fmt.Errorf("plan: count Rotation checks of %s: %w", d.ID, err)
}
if checks == 0 {
return ErrReviewNotFound
}
return ErrNoPendingReview
}
if err != nil {
return fmt.Errorf("plan: read Rotation check of %s: %w", d.ID, err)
}
now := store.Now().String()
moved, err := q.MoveTriedDinner(ctx, db.MoveTriedDinnerParams{
Status: status,
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
UpdatedAt: now,
ID: d.ID,
})
if err != nil {
return fmt.Errorf("plan: move %s to %s: %w", d.ID, status, err)
}
if moved != 1 {
return ErrDinnerNotTried
}
closed, err := q.DecideRotationReview(ctx, db.DecideRotationReviewParams{
Decision: sql.NullString{String: decision, Valid: true},
Name: sql.NullString{String: d.Name, Valid: decision == DecisionDrop},
Now: sql.NullString{String: now, Valid: true},
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
ID: review.ID,
})
if err != nil {
return fmt.Errorf("plan: close Rotation check %s: %w", review.ID, err)
}
if closed != 1 {
return fmt.Errorf("plan: Rotation check %s was decided while deciding it", review.ID)
}
out = Decided{
Dinner: reviewDinner(d),
PlanDate: review.PlanDate,
Decision: decision,
DinnerStatus: status,
DecidedAt: now,
UpdatedBy: updatedBy,
}
return nil
})
if err != nil {
return Decided{}, err
}
return out, nil
}
func reviewDinner(d db.Dinner) ReviewDinner {
out := ReviewDinner{ID: d.ID, Name: d.Name}
if d.PhotoID.Valid {
u := dinners.PhotoURL(d.PhotoID.String)
out.Photo = &u
}
return out
}
+379
View File
@@ -0,0 +1,379 @@
package plan_test
import (
"database/sql"
"errors"
"io/fs"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
const reviewer = "member-1"
// Pad Thai and Shawarma Bowls, the seeded Quest log dinners, are planned
// on these days of fullWeek.
var (
padThaiDay = mustDate("2026-10-14")
shawarmaDay = mustDate("2026-10-16")
)
// putWeek stores fullWeek as the plan of week with status, as a draft and
// Push to Calendar would.
func (e *env) putWeek(t *testing.T, status string) {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
id := store.NewID()
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: id, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
for i, name := range fullWeek {
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: id, Date: weekDates[i],
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
t.Fatal(err)
}
}
e.setStatus(t, status)
}
func (e *env) dinnerRow(t *testing.T, name string) db.Dinner {
t.Helper()
d, err := db.New(e.st.Reader).GetDinner(t.Context(), e.ids[name])
if err != nil {
t.Fatal(err)
}
return d
}
// review is a rotation_reviews row as stored.
type review struct {
planDate, status string
decision, name, decidedAt, updatedBy sql.NullString
}
func (e *env) reviews(t *testing.T, name string) []review {
t.Helper()
rows, err := e.st.Reader.QueryContext(t.Context(),
`SELECT plan_date, status, decision, name, decided_at, updated_by FROM rotation_reviews WHERE dinner_id = ? ORDER BY plan_date`, e.ids[name])
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var out []review
for rows.Next() {
var r review
if err := rows.Scan(&r.planDate, &r.status, &r.decision, &r.name, &r.decidedAt, &r.updatedBy); err != nil {
t.Fatal(err)
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
return out
}
func (e *env) markTried(t *testing.T, date store.LocalDate, want int) {
t.Helper()
n, err := e.svc.MarkTried(t.Context(), date)
if err != nil {
t.Fatal(err)
}
if n != want {
t.Fatalf("MarkTried(%s) = %d, want %d", date, n, want)
}
}
func TestMarkTried(t *testing.T) {
e := newEnv(t)
// Any plan status: a plan pushed to Calendar and edited still counts.
e.putWeek(t, plan.StatusChanged)
e.markTried(t, padThaiDay, 1)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "tried" {
t.Errorf("Pad Thai status = %s, want tried", got)
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].planDate != padThaiDay.String() || got[0].status != "pending" ||
got[0].decision.Valid || got[0].name.Valid || got[0].decidedAt.Valid || got[0].updatedBy.Valid {
t.Fatalf("Pad Thai reviews = %+v, want one pending for %s", got, padThaiDay)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls, planned on another day, status = %s, want quest_log", got)
}
// Idempotent: a second run moves nothing and opens no second check.
e.markTried(t, padThaiDay, 0)
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews after a second run = %+v, want the one pending", got)
}
// A day planning a Regular, or with no plan, moves nothing.
e.markTried(t, week, 0)
e.markTried(t, mustDate("2026-10-20"), 0)
if got := e.dinnerRow(t, "Beef Chili").Status; got != "regular" {
t.Errorf("Beef Chili status = %s, want regular", got)
}
if got := e.reviews(t, "Beef Chili"); len(got) != 0 {
t.Errorf("Beef Chili reviews = %+v, want none", got)
}
}
// A dinner returned to the Quest log ("We didn't make it") is not marked
// tried again for the same day.
func TestMarkTriedAfterNotMade(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], plan.DecisionNotMade, reviewer); err != nil {
t.Fatal(err)
}
e.markTried(t, padThaiDay, 0)
if got := e.dinnerRow(t, "Pad Thai").Status; got != "quest_log" {
t.Errorf("Pad Thai status = %s, want quest_log", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "decided" {
t.Errorf("Pad Thai reviews = %+v, want the one decided", got)
}
}
func TestPendingReviews(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
got, err := e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("pending before MarkTried = %+v, want none", got)
}
e.markTried(t, shawarmaDay, 1)
e.markTried(t, padThaiDay, 1)
got, err = e.svc.PendingReviews(t.Context())
if err != nil {
t.Fatal(err)
}
want := []plan.Review{
{Dinner: plan.ReviewDinner{ID: e.ids["Pad Thai"], Name: "Pad Thai"}, PlanDate: padThaiDay.String()},
{Dinner: plan.ReviewDinner{ID: e.ids["Shawarma Bowls"], Name: "Shawarma Bowls"}, PlanDate: shawarmaDay.String()},
}
if !slices.Equal(got, want) {
t.Errorf("pending = %+v, want %+v", got, want)
}
}
func TestDecideReview(t *testing.T) {
for _, tc := range []struct {
decision, status string
}{
{plan.DecisionKeep, "regular"},
{plan.DecisionDrop, "archived"},
{plan.DecisionNotMade, "quest_log"},
} {
t.Run(tc.decision, func(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
d, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], tc.decision, reviewer)
if err != nil {
t.Fatal(err)
}
if d.Dinner.ID != e.ids["Pad Thai"] || d.Dinner.Name != "Pad Thai" || d.PlanDate != padThaiDay.String() ||
d.Decision != tc.decision || d.DinnerStatus != tc.status || d.DecidedAt == "" || d.UpdatedBy != reviewer {
t.Errorf("decided = %+v", d)
}
row := e.dinnerRow(t, "Pad Thai")
if row.Status != tc.status || row.Origin != "side_quest" || row.Name != "Pad Thai" || row.UpdatedBy.String != reviewer {
t.Errorf("Pad Thai = status %s origin %s name %s updated_by %v, want %s side_quest Pad Thai %s",
row.Status, row.Origin, row.Name, row.UpdatedBy, tc.status, reviewer)
}
// Only a drop records the dinner's name.
wantPassed := tc.decision == plan.DecisionDrop
wantName := sql.NullString{}
if wantPassed {
wantName = sql.NullString{String: "Pad Thai", Valid: true}
}
got := e.reviews(t, "Pad Thai")
if len(got) != 1 || got[0].status != "decided" || got[0].decision.String != tc.decision ||
got[0].decidedAt.String != d.DecidedAt || got[0].updatedBy.String != reviewer ||
got[0].name != wantName {
t.Errorf("reviews = %+v, want one decided %s by %s at %s, named only by a drop", got, tc.decision, reviewer, d.DecidedAt)
}
if pending, err := e.svc.PendingReviews(t.Context()); err != nil || len(pending) != 0 {
t.Errorf("pending after deciding = %+v, %v, want none", pending, err)
}
// A second decision, of any kind, changes nothing.
for _, again := range []string{plan.DecisionKeep, plan.DecisionDrop, plan.DecisionNotMade} {
if _, err := e.svc.DecideReview(t.Context(), e.ids["Pad Thai"], again, reviewer); !errors.Is(err, plan.ErrNoPendingReview) {
t.Errorf("second decision %s: %v, want ErrNoPendingReview", again, err)
}
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != tc.status {
t.Errorf("status after a second decision = %s, want %s", got, tc.status)
}
passed, err := db.New(e.st.Reader).ListPassedNames(t.Context())
if err != nil {
t.Fatal(err)
}
recent, err := db.New(e.st.Reader).ListRecentPassedNames(t.Context(), 10)
if err != nil {
t.Fatal(err)
}
if slices.Contains(passed, "Pad Thai") != wantPassed || slices.Contains(recent, "Pad Thai") != wantPassed {
t.Errorf("passed names = %v, recent %v; want Pad Thai among them: %t", passed, recent, wantPassed)
}
})
}
}
func TestDecideReviewRefused(t *testing.T) {
e := newEnv(t)
e.putWeek(t, plan.StatusDraft)
e.markTried(t, padThaiDay, 1)
ctx := t.Context()
var invalid *plan.InvalidDecisionError
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], "promote", reviewer); !errors.As(err, &invalid) {
t.Errorf("decision promote: %v, want *InvalidDecisionError", err)
}
if _, err := e.svc.DecideReview(ctx, "nope", plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotFound) {
t.Errorf("unknown dinner: %v, want ErrDinnerNotFound", err)
}
// Never tried: Shawarma Bowls is in the Quest log with no check.
if _, err := e.svc.DecideReview(ctx, e.ids["Shawarma Bowls"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrReviewNotFound) {
t.Errorf("dinner never tried: %v, want ErrReviewNotFound", err)
}
if got := e.dinnerRow(t, "Shawarma Bowls").Status; got != "quest_log" {
t.Errorf("Shawarma Bowls status = %s, want quest_log", got)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, ""); err == nil {
t.Error("decision with no member succeeded")
}
// A tried dinner archived meanwhile is neither kept nor listed.
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'archived' WHERE id = ?`, e.ids["Pad Thai"]); err != nil {
t.Fatal(err)
}
if _, err := e.svc.DecideReview(ctx, e.ids["Pad Thai"], plan.DecisionKeep, reviewer); !errors.Is(err, plan.ErrDinnerNotTried) {
t.Errorf("archived dinner: %v, want ErrDinnerNotTried", err)
}
if got := e.dinnerRow(t, "Pad Thai").Status; got != "archived" {
t.Errorf("Pad Thai status = %s, want archived", got)
}
if got := e.reviews(t, "Pad Thai"); len(got) != 1 || got[0].status != "pending" {
t.Errorf("Pad Thai reviews = %+v, want the one still pending", got)
}
if pending, err := e.svc.PendingReviews(ctx); err != nil || len(pending) != 0 {
t.Errorf("pending = %+v, %v, want none for an archived dinner", pending, err)
}
}
// TestOnlyKeepPromotesToRegular guards D-021: no code path but the Rotation
// check's keep may make an existing dinner a Regular. Every query writing
// dinners.status and every non-test use of the regular status must be one
// of those reviewed below; a new one fails here until it is reviewed.
func TestOnlyKeepPromotesToRegular(t *testing.T) {
// Each query writing dinners.status, and why it cannot promote.
statusWriters := map[string]string{
"CreateDinner": "inserts a new dinner; only CreateRegular inserts one as a Regular",
"UpdateDinner": "dinners.Service.Update writes the status it read back",
"ArchiveDinner": "sets archived",
"MoveSuggestedDinner": "Try it / Pass move a suggested dinner to quest_log or archived",
"MarkDinnerTried": "sets tried",
"MoveTriedDinner": "DecideReview's decisionStatus: only keep maps to regular",
}
files, err := filepath.Glob("../../migrations/queries/*.sql")
if err != nil || len(files) == 0 {
t.Fatalf("no query files: %v", err)
}
writes := regexp.MustCompile(`(?is)^\s*(INSERT\s+INTO\s+dinners\b|UPDATE\s+dinners\s+SET\b.*\bstatus\s*=)`)
nameRe := regexp.MustCompile(`^\s*(\w+)`)
found := map[string]bool{}
for _, f := range files {
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, stmt := range strings.Split(string(src), "-- name:")[1:] {
name := nameRe.FindStringSubmatch(stmt)[1]
var body []string
for _, line := range strings.Split(stmt, "\n")[1:] {
if !strings.HasPrefix(strings.TrimSpace(line), "--") {
body = append(body, line)
}
}
sqlText := strings.Join(body, "\n")
if !writes.MatchString(sqlText) {
continue
}
found[name] = true
if _, ok := statusWriters[name]; !ok {
t.Errorf("%s: query %s writes dinners.status; review it against D-021 and list it here", f, name)
}
if regexp.MustCompile(`(?i)\bstatus\s*=\s*'regular'`).MatchString(sqlText) {
t.Errorf("%s: query %s sets status regular outright", f, name)
}
}
}
for name := range statusWriters {
if !found[name] {
t.Errorf("reviewed query %s no longer writes dinners.status; drop it from the list", name)
}
}
// Each non-test Go file naming the regular status, and how often.
uses := map[string]int{
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
"plan/plan.go": 1, // plannable
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
"ai/harness/context.go": 1, // reads the Regulars
"ai/skills/draft_week.go": 1, // KindRegular, a planned day's label
}
regular := regexp.MustCompile(`StatusRegular\b|"regular"`)
got := map[string]int{}
err = filepath.WalkDir("..", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == "db" {
return filepath.SkipDir // generated; the queries are checked above
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
if n := len(regular.FindAllIndex(src, -1)); n > 0 {
got[filepath.ToSlash(strings.TrimPrefix(path, ".."+string(filepath.Separator)))] = n
}
return nil
})
if err != nil {
t.Fatal(err)
}
for path, n := range got {
if uses[path] != n {
t.Errorf("%s names the regular status %d times, reviewed %d; review it against D-021", path, n, uses[path])
}
}
for path, n := range uses {
if got[path] == 0 {
t.Errorf("%s no longer names the regular status (reviewed %d); update the list", path, n)
}
}
}