Rotation check: tried dinners and keep/drop decisions (#116)
CI / ci (push) Failing after 5s
CI / ci (push) Failing after 5s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
This commit was merged in pull request #116.
This commit is contained in:
@@ -5,6 +5,10 @@
|
||||
// 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 (
|
||||
|
||||
@@ -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