R-01: a Rotation check decided drop now records the dinner's name at the decision in rotation_reviews.name (migration 0014, a table rebuild as 0012 did for suggestions; checks dropped before it take their dinner's name). ListPassedNames and ListRecentPassedNames read that name instead of the mutable dinners.name, so renaming the archived dinner no longer lets the dropped dish back into a Side quest batch. A CHECK keeps a name on drops only. R-02: deciding on a dinner that has never had a Rotation check is now ErrReviewNotFound, 404 not_found; a dinner whose checks are all decided, such as a second decision, stays 409 no_pending_review. Tests: TestLaterBatchNeverResuggestsRenamedDrop (drop, rename, next batch still refuses the original name), TestMigrateRotationReviewName (0013 to 0014 upgrade and constraints), TestPostRotationReviewNeverChecked (404), and TestDecideReview/TestDecideReviewRefused updated. Maestro: a1887d1c-c405-478c-ac7d-8cb8b40d280d Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
380 lines
13 KiB
Go
380 lines
13 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|