Files
MealQuest/internal/ai/skills/ideas_from_ingredients_test.go
orchestrator-agent 47a5b5cb89
CI / ci (push) Successful in 6m12s
CI / ci (pull_request) Successful in 5m47s
Feature 18: Cook From What I Have and Quick Dinners (#155)
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-09 10:38:36 -04:00

270 lines
10 KiB
Go

package skills
import (
"context"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
)
// pantry is the household's ingredient list in its order.
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
// idea renders one idea of an ideas_from_ingredients reply.
func idea(name, method string, minutes int, uses, missing []string) string {
u, _ := json.Marshal(append([]string{}, uses...))
m, _ := json.Marshal(append([]string{}, missing...))
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
}
// ideasReply renders a reply holding the ideas given.
func ideasReply(ideas ...string) string {
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
}
// ideas runs IdeasFromIngredients on in with f and checks f was called
// exactly calls times, each at the household temperature.
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
t.Helper()
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), 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 != "ideas_from_ingredients" {
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
}
}
return out, reqs, err
}
func TestIdeasFromIngredientsPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
_, reqs, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
system, user := reqs[0].System, reqs[0].Messages[0].Content
golden(t, "ideas_from_ingredients_any.system.golden", system)
golden(t, "ideas_from_ingredients_any.user.golden", user)
for _, part := range []string{
"## Household context",
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
"## Output contract",
`"uses"`,
`"missing"`,
} {
if !strings.Contains(system, part) {
t.Errorf("system prompt lacks %q", part)
}
}
if strings.Contains(system, "Quick dinners only") {
t.Error("an Any time request asks for Quick dinners only")
}
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
t.Errorf("user message = %q", user)
}
}
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
in := pantry
in.QuickOnly = true
_, reqs, err := ideas(t, &f, in, 1)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
}
}
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// Every ingredient, named in another order and spelling: uses is
// in the household's order, as the household wrote it.
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
// Some of them.
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
// Every ingredient, nothing missing.
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
// Some, nothing missing.
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Dropped) != 0 {
t.Fatalf("dropped %+v", out.Dropped)
}
got := map[string][2]any{}
for _, i := range out.Ideas {
got[i.Name] = [2]any{i.Uses, i.Summary}
}
want := map[string][2]any{
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
}
}
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
// The model claims parmesan and lime, which the household never
// listed, and repeats rice.
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
// Uses only ingredients the household never listed.
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
// Lists an ingredient on hand as missing.
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
want := Ideas{
Ideas: []Idea{
{
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
Summary: "Uses 2 of 5 · missing: 1 cup broth",
},
{
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
Summary: "Uses 1 of 5 · missing: tortillas",
},
},
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
}
if !reflect.DeepEqual(out, want) {
t.Fatalf("output = %+v\nwant %+v", out, want)
}
}
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
)))
out, _, err := ideas(t, &f, pantry, 1)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
t.Fatalf("ideas = %+v", out.Ideas)
}
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
}
}
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
var f llm.Fake
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
out, reqs, err := ideas(t, &f, pantry, 2)
if err != nil {
t.Fatal(err)
}
if len(out.Ideas) != 1 {
t.Fatalf("ideas = %+v", out.Ideas)
}
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
if !strings.Contains(repair, part) {
t.Errorf("repair turn lacks %q:\n%s", part, repair)
}
}
}
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
var f llm.Fake
var nine []string
for i := range 9 {
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
}
f.QueueGenerate(reply(ideasReply(nine...)))
f.QueueGenerate(reply(ideasReply(nine...)))
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
t.Fatalf("err = %v, want a bad output naming the limit", err)
}
}
func TestCleanIngredients(t *testing.T) {
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
if err != nil {
t.Fatal(err)
}
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
t.Fatalf("CleanIngredients = %q, want %q", got, want)
}
tooMany := make([]string, MaxIngredients+1)
for i := range tooMany {
tooMany[i] = fmt.Sprintf("thing %d", i)
}
for _, tc := range []struct {
name string
in []string
want string
}{
{"empty", nil, "list at least one ingredient"},
{"too many", tooMany, "list at most 20"},
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
{"filler only", []string{"the"}, "names no ingredient"},
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("err = %v, want %q", err, tc.want)
}
})
}
}
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
var f llm.Fake
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
t.Fatal("a repeated ingredient was accepted")
}
}
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
t.Errorf("scopes of the same list differ: %q, %q", a, b)
}
for _, other := range []IdeasRequest{
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
{Ingredients: []string{"black beans", "Rice"}},
{Ingredients: []string{"Rice"}},
} {
if IdeasScope(other) == a {
t.Errorf("%+v shares the scope %q", other, a)
}
}
}