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>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
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. 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)
|
||||
}
|
||||
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, p.Confirm, week)
|
||||
if err != nil {
|
||||
return draftResult{}, err
|
||||
}
|
||||
return draftResult{WeekStart: p.WeekStart, PlanID: planID}, 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.
|
||||
func (s *Service) storeDraft(ctx context.Context, weekStart store.LocalDate, confirm bool, week skills.Week) (string, error) {
|
||||
var planID string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
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):
|
||||
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}
|
||||
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))
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
// 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. A plan is draft until Push to Calendar
|
||||
// syncs it (Feature 16); an edit of a synced plan makes it changed.
|
||||
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"
|
||||
)
|
||||
|
||||
// Reasons are every draft failure reason.
|
||||
var Reasons = []string{ReasonNotEnoughDinners, ReasonAIFailed, ReasonConfirmRequired}
|
||||
|
||||
// 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")
|
||||
|
||||
// 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")
|
||||
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
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:"job_id"`
|
||||
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. 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
|
||||
}
|
||||
id, err := q.GetActiveDraftJobID(ctx, db.GetActiveDraftJobIDParams{Kind: KindDraftWeek, WeekStart: weekStart.String()})
|
||||
if err == nil {
|
||||
d = Draft{JobID: id}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("plan: read draft job of %s: %w", weekStart, err)
|
||||
}
|
||||
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), and the dinner must be
|
||||
// a Regular or in the Quest log (else ErrDinnerNotFound or
|
||||
// *DinnerNotPlannableError). 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}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,676 @@
|
||||
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, ¬Plannable) }},
|
||||
{"archived dinner", week, "2026-10-12", archived, func(err error) bool { return errors.As(err, ¬Plannable) }},
|
||||
{"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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user