Drafts a week of seven dinners, Monday to Sunday, from a candidate pool of Regulars and Quest log dinners, per CTXD PROMPTS.MD §3. Validators require the seven dates of the week in order, every dinner_id in the pool, no dinner twice and a note of at most 160 characters; one repair turn, then ErrBadOutput. Repeating more of last week's dinners than the pool made necessary is a warning, not a failure. is_new and a crock-pot dinner's start-by time are computed in code. A pool of fewer than seven dinners fails with NotEnoughDinnersError before any LLM call. Maestro task 7ab59240-a4fd-43cf-b195-4698c8747491. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
12 KiB
Go
325 lines
12 KiB
Go
package skills
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
)
|
|
|
|
// weekOf asks for the week of Monday 2026-10-12 from a pool of eight, two
|
|
// of them planned last week.
|
|
var weekOf = WeekRequest{
|
|
Context: harness.HouseholdContext{
|
|
Today: date(2026, 10, 8),
|
|
Timezone: "America/New_York",
|
|
Regulars: []harness.RegularDinner{
|
|
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240},
|
|
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true},
|
|
},
|
|
QuestLog: []string{"Pad Thai"},
|
|
Passed: []string{},
|
|
Plans: []harness.PlannedDinner{
|
|
{Date: date(2026, 10, 5), Name: "Chicken Fajitas"},
|
|
{Date: date(2026, 10, 6), Name: "Fish Tacos"},
|
|
},
|
|
},
|
|
WeekStart: date(2026, 10, 12),
|
|
Pool: []Candidate{
|
|
{ID: "chili", Name: "Beef Chili", Kind: KindRegular, Method: "crockpot", Minutes: 240},
|
|
{ID: "fajitas", Name: "Chicken Fajitas", Kind: KindRegular, Method: "stovetop", Minutes: 30},
|
|
{ID: "bolognese", Name: "Spaghetti Bolognese", Kind: KindRegular, Method: "stovetop", Minutes: 45},
|
|
{ID: "salmon", Name: "Sheet Pan Salmon", Kind: KindRegular, Method: "oven"},
|
|
{ID: "tacos", Name: "Fish Tacos", Kind: KindRegular, Minutes: 25},
|
|
{ID: "padthai", Name: "Pad Thai", Kind: KindQuestLog, Method: "stovetop", Minutes: 35},
|
|
{ID: "roast", Name: "Pot Roast", Kind: KindRegular, Method: "crockpot"},
|
|
{ID: "shawarma", Name: "Chicken Shawarma Bowls", Kind: KindQuestLog, Method: "oven", Minutes: 40},
|
|
},
|
|
LastWeek: []string{"fajitas", "tacos", "gone"},
|
|
DinnerTime: "17:30",
|
|
}
|
|
|
|
// weekDates are the dates of weekOf, Monday to Sunday.
|
|
var weekDates = []string{"2026-10-12", "2026-10-13", "2026-10-14", "2026-10-15", "2026-10-16", "2026-10-17", "2026-10-18"}
|
|
|
|
// week renders a reply planning ids on dates, in order.
|
|
func week(note string, dates []string, ids ...string) string {
|
|
var days []string
|
|
for i, id := range ids {
|
|
days = append(days, fmt.Sprintf(`{"date":%q,"dinner_id":%q}`, dates[i], id))
|
|
}
|
|
return fmt.Sprintf(`{"days":[%s],"note":%q}`, strings.Join(days, ","), note)
|
|
}
|
|
|
|
// clean plans no dinner of last week.
|
|
var clean = []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
|
|
|
// wantWeek is the output for a reply planning ids on weekDates with note.
|
|
func wantWeek(note string, ids ...string) Week {
|
|
newYork, err := time.LoadLocation("America/New_York")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
out := Week{Note: note}
|
|
for i, id := range ids {
|
|
day := WeekDay{Date: weekDates[i], DinnerID: id, IsNew: id == "padthai" || id == "shawarma"}
|
|
if id == "chili" {
|
|
y, m, d := date(2026, 10, 12+i).Date()
|
|
day.StartBy = time.Date(y, m, d, 13, 30, 0, 0, newYork)
|
|
}
|
|
out.Days = append(out.Days, day)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// draft runs DraftWeek on in with f and checks f was called exactly calls
|
|
// times, each at the household temperature with the draft_week schema.
|
|
func draft(t *testing.T, f *llm.Fake, in WeekRequest, calls int) (Week, []llm.Request, error) {
|
|
t.Helper()
|
|
out, err := DraftWeek(context.Background(), testConfig(t, f), "2026-W42", in)
|
|
reqs := f.Requests()
|
|
if len(reqs) != calls {
|
|
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
|
}
|
|
if g, _, _ := f.Remaining(); g != 0 {
|
|
t.Fatalf("%d scripted Generate results unused", g)
|
|
}
|
|
for i, req := range reqs {
|
|
if req.Temperature == nil || *req.Temperature != 0.7 {
|
|
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
|
}
|
|
if req.Schema == nil || req.Schema.Name != "draft_week" {
|
|
t.Fatalf("request %d schema = %v, want draft_week", i+1, req.Schema)
|
|
}
|
|
}
|
|
return out, reqs, err
|
|
}
|
|
|
|
func assertWeek(t *testing.T, got, want Week) {
|
|
t.Helper()
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("output = %+v\nwant %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDraftWeekValid(t *testing.T) {
|
|
var f llm.Fake
|
|
f.QueueGenerate(reply(week(" A cosy week,\nwith Pad Thai midweek. ", weekDates, clean...)))
|
|
out, reqs, err := draft(t, &f, weekOf, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertWeek(t, out, wantWeek("A cosy week, with Pad Thai midweek.", clean...))
|
|
if !out.Days[0].StartBy.Equal(time.Date(2026, 10, 12, 17, 30, 0, 0, time.UTC)) {
|
|
t.Errorf("Beef Chili StartBy = %v, want 13:30 EDT", out.Days[0].StartBy)
|
|
}
|
|
|
|
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
|
golden(t, "draft_week_valid.system.golden", system)
|
|
golden(t, "draft_week_valid.user.golden", user)
|
|
for _, part := range []string{
|
|
"## Household context",
|
|
"### Candidate pool",
|
|
`- id "chili": Beef Chili (Regular, crockpot, 240 min)`,
|
|
`- id "fajitas": Chicken Fajitas (Regular, stovetop, 30 min, planned last week)`,
|
|
`- id "salmon": Sheet Pan Salmon (Regular, oven)`,
|
|
`- id "tacos": Fish Tacos (Regular, 25 min, planned last week)`,
|
|
`- id "padthai": Pad Thai (Quest log, stovetop, 35 min)`,
|
|
"## Output contract",
|
|
`"dinner_id"`,
|
|
} {
|
|
if !strings.Contains(system, part) {
|
|
t.Errorf("system prompt lacks %q", part)
|
|
}
|
|
}
|
|
if strings.Index(system, "### Candidate pool") > strings.Index(system, "## Output contract") {
|
|
t.Error("the output contract is not after the candidate pool")
|
|
}
|
|
want := "Draft the week of Mon 2026-10-12, Tue 2026-10-13, Wed 2026-10-14, Thu 2026-10-15, Fri 2026-10-16, Sat 2026-10-17, Sun 2026-10-18."
|
|
if user != want {
|
|
t.Errorf("user message = %q, want %q", user, want)
|
|
}
|
|
}
|
|
|
|
// repaired checks a first reply is repaired once, quoting it and the
|
|
// problems wanted.
|
|
func repaired(t *testing.T, first string, want ...string) {
|
|
t.Helper()
|
|
var f llm.Fake
|
|
f.QueueGenerate(reply(first), reply(week("Fixed.", weekDates, clean...)))
|
|
out, reqs, err := draft(t, &f, weekOf, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertWeek(t, out, wantWeek("Fixed.", clean...))
|
|
repair := reqs[1].Messages
|
|
if len(repair) != 3 || repair[1].Content != first {
|
|
t.Fatalf("repair turn does not quote the first reply: %+v", repair)
|
|
}
|
|
for _, w := range want {
|
|
if !strings.Contains(repair[2].Content, w) {
|
|
t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, w)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDraftWeekUnknownIDRepaired(t *testing.T) {
|
|
ids := slices.Clone(clean)
|
|
ids[2] = "lasagna"
|
|
repaired(t, week("A week.", weekDates, ids...),
|
|
`day 3 dinner_id "lasagna" is not in the candidate pool; use only ids from it`)
|
|
}
|
|
|
|
func TestDraftWeekDuplicateRepaired(t *testing.T) {
|
|
ids := slices.Clone(clean)
|
|
ids[5] = "chili"
|
|
repaired(t, week("A week.", weekDates, ids...),
|
|
`day 6 dinner_id "chili" (Beef Chili) is also on day 1; plan each dinner at most once`)
|
|
}
|
|
|
|
func TestDraftWeekWrongDatesTwiceFails(t *testing.T) {
|
|
sundayFirst := append([]string{"2026-10-11"}, weekDates[:6]...)
|
|
var f llm.Fake
|
|
f.QueueGenerate(reply(week("A week.", sundayFirst, clean...)), reply(week("A week.", sundayFirst, clean...)))
|
|
out, _, err := draft(t, &f, weekOf, 2)
|
|
var bad *harness.BadOutputError
|
|
if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) {
|
|
t.Fatalf("err = %v, want a BadOutputError", err)
|
|
}
|
|
if !slices.Contains(bad.Repair, `day 1 date "2026-10-11" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`) {
|
|
t.Fatalf("repair problems = %q", bad.Repair)
|
|
}
|
|
if out.Days != nil {
|
|
t.Fatalf("output = %+v, want none", out)
|
|
}
|
|
}
|
|
|
|
func TestDraftWeekNotEnoughDinners(t *testing.T) {
|
|
small := weekOf
|
|
small.Pool = slices.Clone(weekOf.Pool[:6])
|
|
var f llm.Fake
|
|
_, _, err := draft(t, &f, small, 0)
|
|
var short *NotEnoughDinnersError
|
|
if !errors.As(err, &short) || short.Pool != 6 {
|
|
t.Fatalf("err = %v, want a NotEnoughDinnersError for a pool of 6", err)
|
|
}
|
|
if err.Error() != "not enough dinners: the pool holds 6, a week needs 7" {
|
|
t.Fatalf("err = %q", err)
|
|
}
|
|
}
|
|
|
|
func TestDraftWeekLastWeekRepeats(t *testing.T) {
|
|
// The pool has six dinners not planned last week, so the week needs one
|
|
// of last week's two and may plan it without a warning.
|
|
one := []string{"chili", "padthai", "bolognese", "salmon", "shawarma", "roast", "fajitas"}
|
|
both := []string{"chili", "padthai", "bolognese", "tacos", "shawarma", "roast", "fajitas"}
|
|
for name, tc := range map[string]struct {
|
|
ids []string
|
|
warn []string
|
|
}{
|
|
"needed repeat": {one, nil},
|
|
"avoidable both": {both, []string{"repeats 2 of last week's dinners (Fish Tacos, Chicken Fajitas) though the pool needed at most 1"}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var f llm.Fake
|
|
f.QueueGenerate(reply(week("A week.", weekDates, tc.ids...)))
|
|
out, _, err := draft(t, &f, weekOf, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := wantWeek("A week.", tc.ids...)
|
|
want.Warnings = tc.warn
|
|
assertWeek(t, out, want)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWeekValidate(t *testing.T) {
|
|
s, err := newDraftWeek(weekOf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reply := func(note string, ids ...string) Week {
|
|
var w Week
|
|
if err := w.UnmarshalJSON([]byte(week(note, weekDates, ids...))); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return w
|
|
}
|
|
if p := s.Validate(reply(strings.Repeat("é", NoteLimit), clean...)); len(p) != 0 {
|
|
t.Fatalf("valid output has problems: %q", p)
|
|
}
|
|
for name, tc := range map[string]struct {
|
|
out Week
|
|
want []string
|
|
}{
|
|
"empty": {Week{}, []string{"days has 0 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"}},
|
|
"six days": {reply("", clean[:6]...), []string{
|
|
"days has 6 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18",
|
|
}},
|
|
"eight days": {
|
|
Week{Days: append(reply("", clean...).Days, WeekDay{Date: "2026-10-19", DinnerID: "tacos"})},
|
|
[]string{"days has 8 entries; give exactly 7, one for each date from 2026-10-12 to 2026-10-18"},
|
|
},
|
|
"long note": {reply(strings.Repeat("a", NoteLimit+1), clean...), []string{"note has 161 characters; keep it to at most 160"}},
|
|
"dates out of order": {
|
|
Week{Days: []WeekDay{
|
|
{weekDates[1], "chili", false, time.Time{}}, {weekDates[0], "padthai", false, time.Time{}},
|
|
{weekDates[2], "bolognese", false, time.Time{}}, {weekDates[3], "salmon", false, time.Time{}},
|
|
{weekDates[4], "shawarma", false, time.Time{}}, {weekDates[5], "roast", false, time.Time{}},
|
|
{weekDates[6], "fajitas", false, time.Time{}},
|
|
}},
|
|
[]string{
|
|
`day 1 date "2026-10-13" is not 2026-10-12; give the dates 2026-10-12 to 2026-10-18 in order`,
|
|
`day 2 date "2026-10-12" is not 2026-10-13; give the dates 2026-10-12 to 2026-10-18 in order`,
|
|
},
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
if got := s.Validate(tc.out); !slices.Equal(got, tc.want) {
|
|
t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDraftWeekRequestRejected(t *testing.T) {
|
|
with := func(change func(*WeekRequest)) WeekRequest {
|
|
in := weekOf
|
|
in.Pool = slices.Clone(weekOf.Pool)
|
|
change(&in)
|
|
return in
|
|
}
|
|
for name, tc := range map[string]struct {
|
|
in WeekRequest
|
|
want string
|
|
}{
|
|
"not a Monday": {with(func(in *WeekRequest) { in.WeekStart = date(2026, 10, 13) }),
|
|
"draft_week: household: week start 2026-10-13 is a Tuesday, want Monday"},
|
|
"bad kind": {with(func(in *WeekRequest) { in.Pool[0].Kind = "suggestion" }),
|
|
`draft_week: pool dinner 1: chili kind "suggestion" is not regular or quest_log`},
|
|
"bad method": {with(func(in *WeekRequest) { in.Pool[1].Method = "air fryer" }),
|
|
`draft_week: pool dinner 2: fajitas method "air fryer" is not one of stovetop, oven, crockpot, grill, other`},
|
|
"empty id": {with(func(in *WeekRequest) { in.Pool[2].ID = " " }),
|
|
`draft_week: pool dinner 3: id " " is empty or has a line break`},
|
|
"id twice": {with(func(in *WeekRequest) { in.Pool[3].ID = "chili" }),
|
|
`draft_week: pool dinner 4: id "chili" is in the pool twice`},
|
|
"bad dinner time": {with(func(in *WeekRequest) { in.DinnerTime = "5:30pm" }),
|
|
`draft_week: dinner time: household: invalid time "5:30pm": want HH:MM`},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
var f llm.Fake
|
|
if _, _, err := draft(t, &f, tc.in, 0); err == nil || err.Error() != tc.want {
|
|
t.Fatalf("err = %v, want %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|