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:
@@ -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