Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #121.
This commit is contained in:
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¬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) }},
|
||||
{"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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user