quick_ideas v1 suggests Quick dinners of one kind, crockpot or under30,
from the household context, never returning a name already shown: the
exclude list is checked against normalized names with harness.Similar.
POST /api/v1/ideas/quick {kind, exclude[]} validates both fields (no
default kind) and answers 202 {jobId}; the quick_ideas job asks for 12
ideas, grounds them through the same GroundEach rounds as
ideas_from_ingredients and keeps up to 6 whose grounded recipe is of the
kind (crockpot: method crockpot; under30: minutes_total <= 30), so the
model's estimate never decides. Fewer survivors make a shorter page,
never a padded one.
The grounding loop, failure clearing and failed-result view are now
shared generic helpers used by both idea jobs rather than a second copy.
Maestro task 243a6812-f52b-4215-a4a2-8eb599914f7e.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
576 lines
19 KiB
Go
576 lines
19 KiB
Go
package ideas
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
|
)
|
|
|
|
// pantry is the household's ingredient list.
|
|
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
|
|
|
|
type env struct {
|
|
svc *Service
|
|
runner *jobs.Runner
|
|
search *recipestest.Search
|
|
pages *recipestest.Pages
|
|
images *recipestest.Images
|
|
// model answers ideas_from_ingredients and quick_ideas; normalize
|
|
// answers normalize_recipe while grounding.
|
|
model *llm.Fake
|
|
normalize *llm.Fake
|
|
// fail, when set, fails ideas_from_ingredients and quick_ideas without
|
|
// a model call.
|
|
fail error
|
|
st *store.Store
|
|
}
|
|
|
|
// newEnv returns an ideas service over a migrated store with the
|
|
// household's settings seeded and an unstarted
|
|
// job runner. It finds ideas through the real harness and
|
|
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
|
|
// grounds with fake
|
|
// search, pages and images and the scripted e.normalize.
|
|
func newEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
ctx := t.Context()
|
|
dataDir := t.TempDir()
|
|
st, err := store.Open(ctx, dataDir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := st.Close(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
})
|
|
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e := &env{
|
|
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
|
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
|
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
|
model: &llm.Fake{},
|
|
normalize: &llm.Fake{},
|
|
st: st,
|
|
}
|
|
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
|
|
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := harness.Config{
|
|
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
|
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
|
Store: st,
|
|
Guard: guard,
|
|
}
|
|
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
|
if e.fail != nil {
|
|
return skills.Ideas{}, e.fail
|
|
}
|
|
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
|
}
|
|
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
|
if e.fail != nil {
|
|
return skills.QuickIdeaList{}, e.fail
|
|
}
|
|
return skills.QuickIdeas(ctx, cfg, scope, in)
|
|
}
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
e.runner, err = jobs.NewRunner(st, logger, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return e
|
|
}
|
|
|
|
// recipe makes the search for name find one page that grounds it, titled
|
|
// title, with a total time of minutes, and scripts the normalize_recipe
|
|
// reply grounding it takes. The page's total time is the grounded
|
|
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
|
|
// whatever the reply says.
|
|
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
|
|
t.Helper()
|
|
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
|
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
|
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
|
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
|
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
|
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
|
}
|
|
|
|
// idea renders one idea of an ideas_from_ingredients reply whose search
|
|
// query is its name.
|
|
func idea(name, method string, minutes int, uses ...string) string {
|
|
u, _ := json.Marshal(append([]string{}, uses...))
|
|
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
|
name, u, minutes, method, name)
|
|
}
|
|
|
|
// reply scripts one ideas_from_ingredients reply holding ideas.
|
|
func (e *env) reply(ideas ...string) {
|
|
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
|
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
|
}
|
|
|
|
// recorder collects every result a job publishes.
|
|
type recorder struct {
|
|
mu sync.Mutex
|
|
published []Result
|
|
}
|
|
|
|
// latest returns the last result published.
|
|
func (r *recorder) latest() (Result, bool) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
if len(r.published) == 0 {
|
|
return Result{}, false
|
|
}
|
|
return r.published[len(r.published)-1], true
|
|
}
|
|
|
|
func (r *recorder) publish(res Result) error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
// The result's slices are copied, as the runner stores each publish
|
|
// as JSON at once.
|
|
res.Ideas = append([]Idea(nil), res.Ideas...)
|
|
r.published = append(r.published, res)
|
|
return nil
|
|
}
|
|
|
|
// run runs the job's handler directly on ingredients, recording each
|
|
// publish.
|
|
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
|
|
t.Helper()
|
|
ings, err := skills.CleanIngredients(ingredients)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var rec recorder
|
|
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
|
|
return res, rec.published, err
|
|
}
|
|
|
|
// within reports whether every name in sub appears in list, in list's
|
|
// order.
|
|
func within(sub, list []string) bool {
|
|
for _, name := range list {
|
|
if len(sub) > 0 && sub[0] == name {
|
|
sub = sub[1:]
|
|
}
|
|
}
|
|
return len(sub) == 0
|
|
}
|
|
|
|
func names(list []Idea) []string {
|
|
out := []string{}
|
|
for _, i := range list {
|
|
out = append(out, i.Name)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
|
|
e := newEnv(t)
|
|
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
|
|
e.recipe(t, name, name, 35)
|
|
}
|
|
// Eight ideas: two no recipe grounds, so the sixth and seventh are
|
|
// grounded in a second round, and the eighth never is.
|
|
e.reply(
|
|
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
|
idea("Mystery Stew", "stovetop", 35, "rice"),
|
|
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
|
|
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
|
|
idea("Unknown Casserole", "oven", 50, "rice"),
|
|
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
|
|
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
|
idea("Bean Salad", "other", 15, "black beans"),
|
|
)
|
|
res, published, err := e.run(t, pantry, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
|
|
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("ideas = %q, want %q", got, want)
|
|
}
|
|
if res.Stage != StageDone || res.Dropped != 2 {
|
|
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
|
|
}
|
|
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
|
|
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
|
|
}
|
|
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
|
|
t.Errorf("searched %q, want the first seven ideas only", got)
|
|
}
|
|
|
|
// Each publish: the stage, then one more idea at a time, each idea
|
|
// grounded when it appears.
|
|
var stages []string
|
|
var counts []int
|
|
for _, p := range published {
|
|
stages = append(stages, p.Stage)
|
|
counts = append(counts, len(p.Ideas))
|
|
for _, i := range p.Ideas {
|
|
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
|
|
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
|
|
}
|
|
}
|
|
}
|
|
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
|
|
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
|
|
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
|
|
}
|
|
// Ideas ground concurrently, so one may appear before an earlier one,
|
|
// but each publish keeps every idea already shown, in the model's
|
|
// order.
|
|
for i := 3; i < len(published); i++ {
|
|
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
|
|
if !within(prev, cur) || !within(cur, want) {
|
|
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
|
|
}
|
|
}
|
|
|
|
first := res.Ideas[0]
|
|
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
|
|
t.Errorf("first idea = %+v", first)
|
|
}
|
|
}
|
|
|
|
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
|
|
for _, quickOnly := range []bool{true, false} {
|
|
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
|
|
e := newEnv(t)
|
|
// The model estimates every idea quick; the grounded recipes
|
|
// decide.
|
|
e.recipe(t, "Fried Rice", "Fried Rice", 25)
|
|
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
|
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
|
|
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
|
|
e.recipe(t, "Bean Bake", "Bean Bake", 31)
|
|
e.reply(
|
|
idea("Fried Rice", "stovetop", 20, "rice"),
|
|
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
|
|
idea("Bean Enchiladas", "oven", 25, "black beans"),
|
|
idea("Rice Bowls", "stovetop", 15, "rice"),
|
|
idea("Bean Bake", "oven", 30, "black beans"),
|
|
)
|
|
res, _, err := e.run(t, pantry, quickOnly)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
quick := map[string]bool{}
|
|
for _, i := range res.Ideas {
|
|
quick[i.Name] = i.Quick
|
|
}
|
|
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
|
|
wantDropped := 2
|
|
if !quickOnly {
|
|
want["Bean Enchiladas"], want["Bean Bake"] = false, false
|
|
wantDropped = 0
|
|
}
|
|
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
|
|
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
|
|
}
|
|
for _, i := range res.Ideas {
|
|
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
|
|
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
|
|
e := newEnv(t)
|
|
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
|
|
var reply []string
|
|
for _, name := range slow {
|
|
e.recipe(t, name, name, 90)
|
|
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
|
|
}
|
|
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
|
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
|
|
e.reply(reply...)
|
|
res, _, err := e.run(t, pantry, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
|
|
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
|
|
}
|
|
}
|
|
|
|
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
|
|
e.reply(
|
|
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
|
|
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
|
|
)
|
|
res, _, err := e.run(t, pantry, false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
|
|
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
|
|
}
|
|
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
|
|
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
|
|
}
|
|
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
|
|
t.Errorf("searched %q, want only the idea kept", got)
|
|
}
|
|
}
|
|
|
|
func TestFailures(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
setup func(e *env)
|
|
code string
|
|
}{
|
|
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
|
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
|
|
{"search fails", func(e *env) {
|
|
e.search.Err = errors.New("boom")
|
|
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
|
}, ReasonSearchFailed},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
e := newEnv(t)
|
|
tc.setup(e)
|
|
_, published, err := e.run(t, pantry, false)
|
|
var failure *jobs.Failure
|
|
if !errors.As(err, &failure) || failure.Code != tc.code {
|
|
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
|
}
|
|
for _, p := range published {
|
|
if len(p.Ideas) != 0 {
|
|
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
|
|
e := newEnv(t)
|
|
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
|
|
e.recipe(t, name, name, 35)
|
|
}
|
|
// The first idea's search waits, so the two after it ground first.
|
|
gate := make(chan struct{})
|
|
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
|
|
e.reply(
|
|
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
|
idea("Fried Rice", "stovetop", 20, "rice"),
|
|
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
|
)
|
|
ings, err := skills.CleanIngredients(pantry)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var rec recorder
|
|
type outcome struct {
|
|
res Result
|
|
err error
|
|
}
|
|
done := make(chan outcome, 1)
|
|
go func() {
|
|
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
|
|
done <- outcome{res, err}
|
|
}()
|
|
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for {
|
|
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
|
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
|
|
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
|
}
|
|
if latest.Stage != StageGrounding {
|
|
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
|
}
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
close(gate)
|
|
out := <-done
|
|
if out.err != nil {
|
|
t.Fatal(out.err)
|
|
}
|
|
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
|
|
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
|
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
|
}
|
|
}
|
|
|
|
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
quickOnly bool
|
|
setup func(t *testing.T, e *env)
|
|
}{
|
|
{"no recipe grounds any", false, func(t *testing.T, e *env) {
|
|
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
|
|
}},
|
|
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
|
|
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
|
|
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
|
|
}},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
e := newEnv(t)
|
|
tc.setup(t, e)
|
|
_, published, err := e.run(t, pantry, tc.quickOnly)
|
|
var failure *jobs.Failure
|
|
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
|
|
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
|
|
}
|
|
for _, p := range published {
|
|
if len(p.Ideas) != 0 || p.Stage == StageDone {
|
|
t.Fatalf("a job that kept no idea published %+v", p)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
|
|
e := newEnv(t)
|
|
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
|
|
for _, name := range kept {
|
|
e.recipe(t, name, name, 35)
|
|
}
|
|
// The first round keeps four ideas; the second round's search fails.
|
|
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
|
|
e.reply(
|
|
idea("Burrito Bowls", "stovetop", 35, "rice"),
|
|
idea("Mystery Stew", "stovetop", 35, "rice"),
|
|
idea("Fried Rice", "stovetop", 20, "rice"),
|
|
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
|
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
|
|
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
|
)
|
|
_, published, err := e.run(t, pantry, false)
|
|
var failure *jobs.Failure
|
|
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
|
|
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
|
|
}
|
|
most := 0
|
|
for _, p := range published {
|
|
most = max(most, len(p.Ideas))
|
|
}
|
|
if most != len(kept) {
|
|
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
|
|
}
|
|
if last := published[len(published)-1]; len(last.Ideas) != 0 {
|
|
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
|
|
}
|
|
}
|
|
|
|
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
|
|
e := newEnv(t)
|
|
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
|
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
if err := e.runner.Start(ctx); err != nil {
|
|
cancel()
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
cancel()
|
|
e.runner.Wait()
|
|
})
|
|
|
|
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
|
|
t.Fatal("a repeated ingredient was accepted")
|
|
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
|
|
t.Fatalf("err = %v, want an *InvalidError", err)
|
|
}
|
|
|
|
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
var job jobs.Job
|
|
for {
|
|
job, err = e.runner.Get(t.Context(), started.JobID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
|
break
|
|
}
|
|
if time.Now().After(deadline) {
|
|
t.Fatalf("job still %s", job.Status)
|
|
}
|
|
time.Sleep(5 * time.Millisecond)
|
|
}
|
|
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
|
|
t.Fatalf("job = %+v", job)
|
|
}
|
|
var res Result
|
|
if err := json.Unmarshal(job.Result, &res); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
|
|
t.Fatalf("result = %+v", res)
|
|
}
|
|
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
|
|
t.Errorf("idea = %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestIsQuick(t *testing.T) {
|
|
m := func(n int) *int { return &n }
|
|
for _, tc := range []struct {
|
|
method string
|
|
minutes *int
|
|
want bool
|
|
}{
|
|
{"crockpot", m(480), true},
|
|
{"crockpot", nil, true},
|
|
{"stovetop", m(30), true},
|
|
{"oven", m(31), false},
|
|
{"stovetop", nil, false},
|
|
} {
|
|
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
|
|
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
|
|
}
|
|
}
|
|
}
|