From 47a5b5cb89a27896e08211f21b357f012ff71de3 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 10:38:36 -0400 Subject: [PATCH 1/3] Feature 18: Cook From What I Have and Quick Dinners (#155) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- cmd/server/main.go | 35 +- docs/ARCHITECTURE.md | 112 ++- internal/ai/skills/ideas_from_ingredients.go | 356 ++++++++++ .../ai/skills/ideas_from_ingredients_test.go | 269 +++++++ internal/ai/skills/prompts/checksums.txt | 2 + .../prompts/ideas_from_ingredients.v1.tmpl | 25 + .../ai/skills/prompts/quick_ideas.v1.tmpl | 34 + internal/ai/skills/quick_ideas.go | 297 ++++++++ internal/ai/skills/quick_ideas_test.go | 235 +++++++ .../ideas_from_ingredients_any.system.golden | 94 +++ .../ideas_from_ingredients_any.user.golden | 1 + .../quick_ideas_crockpot.system.golden | 93 +++ .../testdata/quick_ideas_crockpot.user.golden | 1 + .../quick_ideas_under30.system.golden | 93 +++ .../testdata/quick_ideas_under30.user.golden | 1 + internal/dinners/dinners.go | 103 ++- internal/ideas/ideas.go | 662 ++++++++++++++++++ internal/ideas/ideas_test.go | 575 +++++++++++++++ internal/ideas/quick_test.go | 400 +++++++++++ internal/jobs/jobs.go | 51 +- internal/jobs/jobs_test.go | 44 ++ internal/plan/plan.go | 34 + internal/plan/rotation_test.go | 2 +- internal/recipes/ground.go | 22 +- internal/recipes/recipestest/recipestest.go | 18 +- internal/server/aisettings_test.go | 2 +- internal/server/dinners_test.go | 2 +- internal/server/google_test.go | 2 +- internal/server/ideaactions_test.go | 416 +++++++++++ internal/server/ideas.go | 224 ++++++ internal/server/ideas_test.go | 350 +++++++++ internal/server/login_test.go | 2 +- internal/server/media_test.go | 2 +- internal/server/origin_test.go | 2 +- internal/server/plans_test.go | 2 +- internal/server/quickideas_test.go | 181 +++++ internal/server/recipesearch_test.go | 2 +- internal/server/server.go | 15 +- internal/server/server_test.go | 56 +- internal/server/settings_test.go | 2 +- internal/server/suggestions_test.go | 2 +- web/src/App.test.tsx | 38 +- web/src/App.tsx | 4 + web/src/ideas/CookFromPantryPage.module.css | 226 ++++++ web/src/ideas/CookFromPantryPage.test.tsx | 512 ++++++++++++++ web/src/ideas/CookFromPantryPage.tsx | 312 +++++++++ web/src/ideas/QuickDinnersPage.module.css | 166 +++++ web/src/ideas/QuickDinnersPage.test.tsx | 514 ++++++++++++++ web/src/ideas/QuickDinnersPage.tsx | 261 +++++++ web/src/ideas/api.ts | 332 +++++++++ web/src/plan/api.ts | 4 +- 51 files changed, 7127 insertions(+), 63 deletions(-) create mode 100644 internal/ai/skills/ideas_from_ingredients.go create mode 100644 internal/ai/skills/ideas_from_ingredients_test.go create mode 100644 internal/ai/skills/prompts/ideas_from_ingredients.v1.tmpl create mode 100644 internal/ai/skills/prompts/quick_ideas.v1.tmpl create mode 100644 internal/ai/skills/quick_ideas.go create mode 100644 internal/ai/skills/quick_ideas_test.go create mode 100644 internal/ai/skills/testdata/ideas_from_ingredients_any.system.golden create mode 100644 internal/ai/skills/testdata/ideas_from_ingredients_any.user.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_crockpot.system.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_crockpot.user.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_under30.system.golden create mode 100644 internal/ai/skills/testdata/quick_ideas_under30.user.golden create mode 100644 internal/ideas/ideas.go create mode 100644 internal/ideas/ideas_test.go create mode 100644 internal/ideas/quick_test.go create mode 100644 internal/server/ideaactions_test.go create mode 100644 internal/server/ideas.go create mode 100644 internal/server/ideas_test.go create mode 100644 internal/server/quickideas_test.go create mode 100644 web/src/ideas/CookFromPantryPage.module.css create mode 100644 web/src/ideas/CookFromPantryPage.test.tsx create mode 100644 web/src/ideas/CookFromPantryPage.tsx create mode 100644 web/src/ideas/QuickDinnersPage.module.css create mode 100644 web/src/ideas/QuickDinnersPage.test.tsx create mode 100644 web/src/ideas/QuickDinnersPage.tsx create mode 100644 web/src/ideas/api.ts diff --git a/cmd/server/main.go b/cmd/server/main.go index f1307ce..53df18c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -23,6 +23,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -138,6 +139,11 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } + ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger) + if err != nil { + return err + } + sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { return err @@ -154,7 +160,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets()) if err != nil { return err } @@ -280,7 +286,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds // service registers its own kinds when it is built, before the runner // starts (dinners.NewService registers dinners.KindGroundDinner and // suggest.NewService suggest.KindSuggestionBatch, plan.NewService -// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun). +// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun, +// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -337,6 +344,30 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler } } +// ideaFinder runs ideas_from_ingredients with the AI settings current at +// each run, sharing guard. +func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator { + return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.Ideas{}, err + } + return skills.IdeasFromIngredients(ctx, cfg, scope, in) + } +} + +// quickIdeaFinder runs quick_ideas with the AI settings current at each +// run, sharing guard. +func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator { + return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.QuickIdeaList{}, err + } + return skills.QuickIdeas(ctx, cfg, scope, in) + } +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6522df2..05db94d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -308,6 +308,89 @@ automatic** (D-021). still `pending` or `running` with no job queued or running (the process stopped, or recording its end failed) is set `failed`, so it does not block the next. +- Cook from what I have (`ideas.Service`, Feature 18). `POST + /api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both + required) checks the list with `skills.CleanIngredients` — 1 to 20 + entries, each non-blank, at most 80 characters, with a non-empty + `harness.Normalize` form that no earlier entry shares — answering 400 + `invalid_request` otherwise, then enqueues an `ideas_from_ingredients` + job (service-only: `POST /jobs` refuses the kind) and answers 202 + `{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope: + `skills.IdeasScope`, the flag and the normalized list in order), then + grounds its ideas in the model's order with `Grounder.GroundEach`, as + many as are still wanted at a time, until 5 are kept or none are left. + An idea no recipe grounds is dropped; with `quick_only`, so is one whose + grounded recipe is not a Quick dinner (`ideas.IsQuick`: method + `crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's + `est_minutes`). Ideas are not stored as dinners. The job publishes + `{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary, + method, minutes_total, difficulty, quick, recipe_id, photo_url, + source_url, site_name}], dropped}` at each stage (`suggesting`, + `grounding`, `done`) and again as each idea is kept, the moment its + grounding finishes, so the result fills idea by idea; the ideas shown + are always in the model's order, an earlier idea that grounds later + taking its place before those shown. It fails with `no_ideas_found` + (every idea was dropped), `ai_failed` (the skill or `normalize_recipe` + failed; no idea is made up instead, D-017), `search_not_configured` or + `search_failed`. A failed job shows no ideas, even those kept before the + failure: the kind registers a `FailedResult` view, so `GET + /api/v1/jobs/{id}` empties the `ideas` of any failed job's result on + read, however it failed (including `interrupted by restart` and an + `internal_error` whose clearing write failed). Before it fails it also + publishes its result again with no ideas, to tidy what is stored. +- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick + {kind, exclude[]}` (both required; `exclude` is `[]` on a first page) + checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`, + no default — and `exclude` with `skills.CleanExclude` — at most 60 + names, each non-blank, at most 120 characters, with a non-empty + `harness.Normalize` form no earlier name shares — answering 400 + `invalid_request` otherwise, then enqueues a `quick_ideas` job + (service-only) and answers 202 `{jobId}` without waiting. The job reads + the household context (`harness.BuildContext`, budget 16000), runs + `quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the + normalized excluded names in order), then grounds its ideas exactly as + the ingredients job does (the same `Grounder.GroundEach` rounds, model + order and failure codes) until 6 are kept or none are left. The kind is + enforced on the grounded recipe, never the model's estimate + (`ideas.OfQuickKind`): `crockpot` keeps method `crockpot` + (`normalize_recipe` writes a slow cooker as `crockpot`), `under30` + keeps a known `minutes_total` ≤ 30; every other idea is dropped. The + model is asked for 12, more than a page, so dropped ideas can be + replaced; when fewer than 6 survive the page shows only those, never + padded with an idea not of the kind. The job publishes `{stage, kind, + exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id, + photo_url, source_url, site_name}], dropped}` at each stage and as each + idea is kept, fails with the same reasons, and a failed job shows no + ideas through the same `FailedResult` view. "↻ Show me more" posts + again with every name the tab has shown so far in `exclude`. +- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything + else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients` + or `quick_ideas` job, read from its stored result + (`ideas.Service.Idea`): its name and `recipe_id`, never grounded again. + A job still queued or running is 409 `ideas_not_ready` (its ideas may + still move); an unknown job, another kind, a failed job or an index past + its ideas is 404 `not_found`. `POST + /api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs + `dinners.Service.AddToQuestLog`: in one transaction, an unarchived + dinner the idea repeats — the same rule that refuses a duplicate Regular + (`harness.Similar` name, or the recipe's link) — is answered 200 as it + is (a Regular stays a Regular); one awaiting the household's choice + (`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing; + otherwise it creates a dinner with status `quest_log`, origin + `ingredients` or `quick`, grounded in the idea's recipe (its method, + minutes, difficulty and photo), answered 201. Repeating the call + answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight` + (no body) takes today as the household-local date in the household + timezone and its Monday (`plan.Service.Tonight`); when that week has no + plan it answers 409 `no_plan` and changes nothing — no dinner, no plan + (a plan holds all seven days; the household plans the week first). + Otherwise it adds or finds the dinner as `quest-log` does and puts it + on today through `plan.Service.Swap`, exactly like a Draft board swap + (plan marked edited, revision raised, a synced plan becomes `changed`, + the day's calendar event id cleared, `is_new` for a Quest log dinner), + answering 200 `{dinner, plan}`. The week is always today's own, so no + other day is written; a dinner already on another day of the week is + 409 `dinner_already_planned`. `GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id, name, photo_url, difficulty, method, minutes_total, cuisine, reason, cited_regular_ids, status, dinner_status, decided_at, created_at}], @@ -470,6 +553,32 @@ automatic** (D-021). not a Regular; and one whose reason is over 180 characters or names none of its cited Regulars. Cited names are rewritten as the household writes them. Grounding each survivor in a recipe is the caller's next step. +- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill + `ideas_from_ingredients` v1): from the household's ingredient list (the + "Ingredients on hand" context section) and the Quick only flag, asks for + 8 ideas `{name, uses[], missing[], est_minutes, method, difficulty, + search_query}` at the household temperature. Malformed replies (a + missing field, a bad enum, an empty name, more than 8 ideas) get the one + repair turn. The invariant is enforced in Go: a `uses` entry is kept + only when its `harness.Normalize` form is a listed ingredient's, and is + then written as the household wrote it, once, in the household's order; + `missing` loses blanks, repeats and anything listed. An idea left using + none of the listed ingredients, or repeating an earlier idea + (`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary + line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses + all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 · + missing: …" otherwise, with "nothing missing" in place of the list when + nothing is. +- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the + household context, a kind (`crockpot` or `under30`, which picks the + prompt's rule) and the names already shown (the "Already shown" context + section), asks for 12 ideas `{name, est_minutes, method, difficulty, + search_query}` at the household temperature. Malformed replies (a + missing field, a bad enum, an empty name, more than 12 ideas) get the + one repair turn. An idea repeating an excluded name or an earlier idea + (`harness.Similar`, on the normalized names) is dropped into `Dropped`, + so an excluded name is never returned. Whether an idea is of the kind + is left to the grounded recipe (see Quick dinners above). - **Supply run titles** (`skills.TitleArchive`, skill `archive_title` v1): from the names of the week's planned dinners in day order, writes `{title}`, the Supply run's post title in the Supply archive, at the @@ -625,7 +734,8 @@ untitled (the job fails `title_failed`), and stores nothing. The candidates `Ground` would try, without grounding any. Search failures wrap `recipes.ErrSearchFailed` and model provider failures `recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per - item, so one failure never affects its siblings. A recipe is usable only + item, so one failure never affects its siblings; `GroundEach` grounds + the same way but reports each item, one at a time, as it finishes. A recipe is usable only with ingredients, an image and, if given, a positive total time. ## Scheduler diff --git a/internal/ai/skills/ideas_from_ingredients.go b/internal/ai/skills/ideas_from_ingredients.go new file mode 100644 index 0000000..5d943d2 --- /dev/null +++ b/internal/ai/skills/ideas_from_ingredients.go @@ -0,0 +1,356 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strconv" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +const ( + // IdeasRequested is how many ideas ideas_from_ingredients asks the + // model for; validation and grounding drop some. + IdeasRequested = 8 + // IdeasKept is the most ideas the household is shown. + IdeasKept = 5 + // MaxIngredients is the most ingredients the household may list. + MaxIngredients = 20 + // IngredientLimit is the most characters one listed ingredient may + // have. + IngredientLimit = 80 +) + +// IdeasRequest is what ideas_from_ingredients reads. +type IdeasRequest struct { + // Ingredients are the ingredients the household has on hand, in its + // order, as CleanIngredients returns them. + Ingredients []string + // QuickOnly asks the model for Quick dinners only. It is a hint: the + // caller enforces it after grounding, from the grounded recipe. + QuickOnly bool +} + +// Idea is one dinner idea. Method, EstMinutes and Difficulty are the +// model's estimates until the idea is grounded in a recipe. +type Idea struct { + Name string `json:"name"` + // Uses are the household's ingredients the idea uses, as the household + // wrote them and in its order. PostProcess drops every entry that is + // not one of them. + Uses []string `json:"uses"` + // Missing are the ingredients the idea needs that the household does + // not have, in the model's order. + Missing []string `json:"missing"` + EstMinutes int `json:"est_minutes"` + // Method is one of stovetop, oven, crockpot, grill and other. + Method string `json:"method"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + // SearchQuery is a web search query that finds a recipe for the dinner. + SearchQuery string `json:"search_query"` + // Summary is the line the household reads under the idea, computed + // by IdeaSummary from Uses and Missing. It is never part of the + // model's reply. + Summary string `json:"-"` +} + +// Ideas is ideas_from_ingredients' output. +type Ideas struct { + // Ideas are the ideas that passed every rule, in the model's order. + Ideas []Idea `json:"ideas"` + // Dropped are the ideas the model gave that broke a rule, in the + // model's order. It is never part of the model's reply. + Dropped []DroppedIdea `json:"-"` +} + +// DroppedIdea is an idea dropped by PostProcess. +type DroppedIdea struct { + Name string + // Why says which rule it broke. + Why string +} + +// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns +// ideas whose uses hold only the household's own ingredients: an entry +// naming anything else is removed, and an idea left using none of them is +// dropped, as is one repeating an earlier idea. Each kept idea carries its +// summary line, computed from the corrected uses and missing lists. scope +// names the request (see harness.Run; IdeasScope). An ingredient list +// CleanIngredients refuses fails before any LLM call. +func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) { + ings, err := CleanIngredients(in.Ingredients) + if err != nil { + return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err) + } + in.Ingredients = ings + return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in) +} + +// IdeasScope is the harness scope of a request: its normalized +// ingredients, in order, and whether it is for Quick dinners only, so two +// requests share a run only when the model would be asked the same. +func IdeasScope(in IdeasRequest) string { + parts := make([]string, len(in.Ingredients)) + for i, ing := range in.Ingredients { + parts[i] = harness.Normalize(ing) + } + return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|") +} + +// CleanIngredients returns the household's ingredient list with each entry +// on one line, or why it cannot be used: it is empty or longer than +// MaxIngredients, or an entry is blank, longer than IngredientLimit +// characters, has nothing left once normalized (harness.Normalize), or +// normalizes the same as an earlier one. +func CleanIngredients(list []string) ([]string, error) { + if len(list) == 0 { + return nil, errors.New("list at least one ingredient") + } + if len(list) > MaxIngredients { + return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients) + } + out := make([]string, len(list)) + seen := map[string]string{} + for i, ing := range list { + ing = oneLine(ing) + switch { + case ing == "": + return nil, fmt.Errorf("ingredient %d is blank", i+1) + case utf8.RuneCountInString(ing) > IngredientLimit: + return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit) + } + n := harness.Normalize(ing) + if n == "" { + return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing) + } + if first, ok := seen[n]; ok { + return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first) + } + seen[n] = ing + out[i] = ing + } + return out, nil +} + +// IdeaSummary is the line shown under an idea: how many of the household's +// total ingredients it uses, then what is missing, in the order given. +// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of +// 5 · missing: 1 lime, cumin" when some are. With nothing missing the +// second part is "nothing missing", so an idea using all 5 and needing +// nothing else prints "Uses all 5 · nothing missing". +func IdeaSummary(uses, missing []string, total int) string { + used := fmt.Sprintf("Uses %d of %d", len(uses), total) + if len(uses) == total { + used = fmt.Sprintf("Uses all %d", total) + } + if len(missing) == 0 { + return used + " · nothing missing" + } + return used + " · missing: " + strings.Join(missing, ", ") +} + +// ideasFromIngredients is the skill. It holds its input because +// post-processing compares the output with it. +type ideasFromIngredients struct { + in IdeasRequest +} + +var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{} + +func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" } + +func (ideasFromIngredients) Version() int { return 1 } + +func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout } + +// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl. +type ideasPrompt struct { + // Count is how many ideas to ask for. + Count int + QuickOnly bool +} + +// PromptInput puts the ingredients, already cleaned by +// IdeasFromIngredients, in the household context block. +func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) { + return harness.PromptInput{ + Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly}, + Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}}, + }, nil +} + +func (ideasFromIngredients) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "uses": {"type": "array", "items": {"type": "string"}}, + "missing": {"type": "array", "items": {"type": "string"}}, + "est_minutes": {"type": "integer"}, + "method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]}, + "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]}, + "search_query": {"type": "string"} + }, + "required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"], + "additionalProperties": false + } + } + }, + "required": ["ideas"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes an idea, requiring each of its fields as the +// schema does: encoding/json would otherwise read a missing or null one as +// empty. +func (i *Idea) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil { + return fmt.Errorf("idea %s", err) + } + type plain Idea + return json.Unmarshal(data, (*plain)(i)) +} + +// UnmarshalJSON decodes the output, requiring its one field as the schema +// does. +func (out *Ideas) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "ideas"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain Ideas + return json.Unmarshal(data, (*plain)(out)) +} + +// Validate finds what makes the reply unusable as a whole: no ideas, too +// many, or one that is malformed. A well-formed idea that breaks a rule is +// dropped by PostProcess instead. +func (ideasFromIngredients) Validate(out Ideas) []string { + var problems []string + switch n := len(out.Ideas); { + case n == 0 && len(out.Dropped) == 0: + problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested)) + case n > IdeasRequested: + problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested)) + } + for i, idea := range out.Ideas { + for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} { + if strings.TrimSpace(f.value) == "" { + problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field)) + } + } + if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" { + problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name)) + } + if !slices.Contains(methods, idea.Method) { + problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", "))) + } + if idea.EstMinutes <= 0 { + problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes)) + } + if !slices.Contains(difficulties, idea.Difficulty) { + problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", "))) + } + } + return problems +} + +// PostProcess corrects every idea against the household's ingredients and +// moves each that breaks a rule to Dropped. Uses keeps only entries whose +// normalized form (harness.Normalize) is that of a listed ingredient, +// written as the household wrote it, once each and in the household's +// order; anything else the model claimed is removed, never trusted. +// Missing loses blank entries, repeats and anything the household listed. +// An idea is dropped when it repeats an earlier idea or uses none of the +// household's ingredients. The summary line is computed from the +// corrected lists. +func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) { + listed := map[string]int{} + for i, ing := range s.in.Ingredients { + listed[harness.Normalize(ing)] = i + } + result := Ideas{Ideas: []Idea{}} + for _, idea := range out.Ideas { + idea = Idea{ + Name: oneLine(idea.Name), + Uses: s.uses(idea.Uses, listed), + Missing: missing(idea.Missing, listed), + EstMinutes: idea.EstMinutes, + Method: idea.Method, + Difficulty: idea.Difficulty, + SearchQuery: oneLine(idea.SearchQuery), + } + if why := dropIdea(idea, result.Ideas); why != "" { + result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why}) + continue + } + idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients)) + result.Ideas = append(result.Ideas, idea) + } + return result, nil +} + +// uses returns the household's ingredients that claimed names, as the +// household wrote them and in its order. A claimed entry that names none +// of them is left out. +func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string { + used := make([]bool, len(s.in.Ingredients)) + for _, c := range claimed { + if i, ok := listed[harness.Normalize(c)]; ok { + used[i] = true + } + } + out := []string{} + for i, ing := range s.in.Ingredients { + if used[i] { + out = append(out, ing) + } + } + return out +} + +// missing returns claimed on one line each, in order, without blank +// entries, repeats or any the household listed. +func missing(claimed []string, listed map[string]int) []string { + out := []string{} + seen := map[string]bool{} + for _, c := range claimed { + c = oneLine(c) + n := harness.Normalize(c) + if n == "" || seen[n] { + continue + } + if _, ok := listed[n]; ok { + continue + } + seen[n] = true + out = append(out, c) + } + return out +} + +// dropIdea returns why idea must be dropped, or "" when it may be kept. +func dropIdea(idea Idea, kept []Idea) string { + for _, k := range kept { + if harness.Similar(idea.Name, k.Name) { + return fmt.Sprintf("repeats the idea %q", k.Name) + } + } + if len(idea.Uses) == 0 { + return "uses none of the household's ingredients" + } + return "" +} diff --git a/internal/ai/skills/ideas_from_ingredients_test.go b/internal/ai/skills/ideas_from_ingredients_test.go new file mode 100644 index 0000000..cc0cab3 --- /dev/null +++ b/internal/ai/skills/ideas_from_ingredients_test.go @@ -0,0 +1,269 @@ +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) + } + } +} diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index 56c2739..22d188f 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -6,3 +6,5 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752 suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32 archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a +ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924 +quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05 diff --git a/internal/ai/skills/prompts/ideas_from_ingredients.v1.tmpl b/internal/ai/skills/prompts/ideas_from_ingredients.v1.tmpl new file mode 100644 index 0000000..6f82f36 --- /dev/null +++ b/internal/ai/skills/prompts/ideas_from_ingredients.v1.tmpl @@ -0,0 +1,25 @@ +{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}} +{{define "instructions" -}} +You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context. + +Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish. + +Give for each idea: +- name: the dinner's usual name, as a recipe site would title it. +- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold. +- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing. +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- difficulty: "easy", "medium" or "hard" for a home cook. +- search_query: a web search query that finds a recipe for this dinner. +{{- if .QuickOnly}} + +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. +{{- end}} + +One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household): +{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"} +{{- end}} +{{define "user" -}} +Suggest {{.Count}} dinner ideas from the ingredients on hand. +{{- end}} diff --git a/internal/ai/skills/prompts/quick_ideas.v1.tmpl b/internal/ai/skills/prompts/quick_ideas.v1.tmpl new file mode 100644 index 0000000..95fd954 --- /dev/null +++ b/internal/ai/skills/prompts/quick_ideas.v1.tmpl @@ -0,0 +1,34 @@ +{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}} +{{define "instructions" -}} +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. +{{- if .Crockpot}} + +Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot". +{{- else}} + +Every idea must take 30 minutes or less from starting prep to the table. +{{- end}} + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish. + +Give for each idea: +- name: the dinner's usual name, as a recipe site would title it. +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- difficulty: "easy", "medium" or "hard" for a home cook. +- search_query: a web search query that finds a recipe for this dinner. + +One valid idea (an example only, not about this household): +{{- if .Crockpot}} +{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"} +{{- else}} +{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"} +{{- end}} +{{- end}} +{{define "user" -}} +{{- if .Crockpot -}} +Suggest {{.Count}} crock pot dinner ideas. +{{- else -}} +Suggest {{.Count}} dinner ideas ready in 30 minutes or less. +{{- end}} +{{- end}} diff --git a/internal/ai/skills/quick_ideas.go b/internal/ai/skills/quick_ideas.go new file mode 100644 index 0000000..039ad97 --- /dev/null +++ b/internal/ai/skills/quick_ideas.go @@ -0,0 +1,297 @@ +package skills + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick +// dinners screen. +const ( + // QuickCrockpot is a dinner cooked in a crock pot (slow cooker). + QuickCrockpot = "crockpot" + // QuickUnder30 is a dinner 30 minutes or less from prep to table. + QuickUnder30 = "under30" +) + +// QuickKinds are every kind of Quick dinner, in the screen's tab order. +var QuickKinds = []string{QuickCrockpot, QuickUnder30} + +const ( + // QuickIdeasRequested is how many ideas quick_ideas asks the model + // for: more than a page holds, since validation, the exclude list, + // grounding and the kind's check drop some. + QuickIdeasRequested = 12 + // QuickIdeasKept is the most ideas one page of Quick dinners shows. + QuickIdeasKept = 6 + // MaxExclude is the most names already shown a request may exclude. + MaxExclude = 60 + // ExcludeLimit is the most characters one excluded name may have. + ExcludeLimit = 120 +) + +// QuickIdeasRequest is what quick_ideas reads. +type QuickIdeasRequest struct { + // Context is the household context (see harness.BuildContext): the + // ideas are picked from the household's tastes. + Context harness.HouseholdContext + // Kind is QuickCrockpot or QuickUnder30. It is a hint to the model: + // the caller enforces it after grounding, from the grounded recipe. + Kind string + // Exclude are the names already shown to the household, as + // CleanExclude returns them. No idea repeating one is returned. + Exclude []string +} + +// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty +// are the model's estimates until the idea is grounded in a recipe. +type QuickIdea struct { + Name string `json:"name"` + EstMinutes int `json:"est_minutes"` + // Method is one of stovetop, oven, crockpot, grill and other. + Method string `json:"method"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + // SearchQuery is a web search query that finds a recipe for the dinner. + SearchQuery string `json:"search_query"` +} + +// QuickIdeaList is quick_ideas' output. +type QuickIdeaList struct { + // Ideas are the ideas that passed every rule, in the model's order. + Ideas []QuickIdea `json:"ideas"` + // Dropped are the ideas the model gave that broke a rule, in the + // model's order. It is never part of the model's reply. + Dropped []DroppedIdea `json:"-"` +} + +// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no +// excluded name nor each other (harness.Similar, on the normalized +// names); the rest are in Dropped. Whether an idea is of in.Kind is not +// decided here: the model's estimates never decide it, the grounded +// recipe does. scope names the request (see harness.Run; QuickIdeasScope). +// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses, +// fails before any LLM call. +func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) { + if err := CheckQuickKind(in.Kind); err != nil { + return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err) + } + exclude, err := CleanExclude(in.Exclude) + if err != nil { + return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err) + } + in.Exclude = exclude + return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in) +} + +// QuickIdeasScope is the harness scope of a request: its kind and its +// normalized excluded names, in order, so two requests share a run only +// when they ask for the same page. +func QuickIdeasScope(in QuickIdeasRequest) string { + parts := make([]string, len(in.Exclude)) + for i, name := range in.Exclude { + parts[i] = harness.Normalize(name) + } + return "kind=" + in.Kind + ";" + strings.Join(parts, "|") +} + +// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There +// is no default kind. +func CheckQuickKind(kind string) error { + if !slices.Contains(QuickKinds, kind) { + return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", ")) + } + return nil +} + +// CleanExclude returns the excluded names with each on one line, never +// nil, or why they cannot be used: there are more than MaxExclude, or a +// name is blank, longer than ExcludeLimit characters, has nothing left +// once normalized (harness.Normalize), or normalizes the same as an +// earlier one. An empty list, the first page's, is valid. +func CleanExclude(list []string) ([]string, error) { + if len(list) > MaxExclude { + return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude) + } + out := make([]string, len(list)) + seen := map[string]string{} + for i, name := range list { + name = oneLine(name) + switch { + case name == "": + return nil, fmt.Errorf("excluded name %d is blank", i+1) + case utf8.RuneCountInString(name) > ExcludeLimit: + return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit) + } + n := harness.Normalize(name) + if n == "" { + return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name) + } + if first, ok := seen[n]; ok { + return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first) + } + seen[n] = name + out[i] = name + } + return out, nil +} + +// quickIdeas is the skill. It holds its input because post-processing +// compares the output with it. +type quickIdeas struct { + in QuickIdeasRequest +} + +var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{} + +func (quickIdeas) Name() string { return "quick_ideas" } + +func (quickIdeas) Version() int { return 1 } + +func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout } + +// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl. +type quickIdeasPrompt struct { + // Count is how many ideas to ask for. + Count int + // Crockpot is set for QuickCrockpot, unset for QuickUnder30. + Crockpot bool +} + +// PromptInput puts the household context, then the names already shown, +// in the household context block. +func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) { + if err := CheckQuickKind(in.Kind); err != nil { + return harness.PromptInput{}, err + } + sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude}) + return harness.PromptInput{ + Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot}, + Context: sections, + }, nil +} + +func (quickIdeas) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "est_minutes": {"type": "integer"}, + "method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]}, + "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]}, + "search_query": {"type": "string"} + }, + "required": ["name", "est_minutes", "method", "difficulty", "search_query"], + "additionalProperties": false + } + } + }, + "required": ["ideas"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes an idea, requiring each of its fields as the +// schema does: encoding/json would otherwise read a missing or null one as +// empty. +func (i *QuickIdea) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil { + return fmt.Errorf("idea %s", err) + } + type plain QuickIdea + return json.Unmarshal(data, (*plain)(i)) +} + +// UnmarshalJSON decodes the output, requiring its one field as the schema +// does. +func (out *QuickIdeaList) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "ideas"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain QuickIdeaList + return json.Unmarshal(data, (*plain)(out)) +} + +// Validate finds what makes the reply unusable as a whole: no ideas, too +// many, or one that is malformed. A well-formed idea that breaks a rule is +// dropped by PostProcess instead. +func (quickIdeas) Validate(out QuickIdeaList) []string { + var problems []string + switch n := len(out.Ideas); { + case n == 0 && len(out.Dropped) == 0: + problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested)) + case n > QuickIdeasRequested: + problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested)) + } + for i, idea := range out.Ideas { + for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} { + if strings.TrimSpace(f.value) == "" { + problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field)) + } + } + if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" { + problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name)) + } + if !slices.Contains(methods, idea.Method) { + problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", "))) + } + if idea.EstMinutes <= 0 { + problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes)) + } + if !slices.Contains(difficulties, idea.Difficulty) { + problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", "))) + } + } + return problems +} + +// PostProcess puts every idea's text on one line and moves each that +// repeats an excluded name or an earlier idea (harness.Similar, which +// compares normalized names) to Dropped. The model's method and minutes +// are kept as estimates and never decide the kind. +func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) { + result := QuickIdeaList{Ideas: []QuickIdea{}} + for _, idea := range out.Ideas { + idea = QuickIdea{ + Name: oneLine(idea.Name), + EstMinutes: idea.EstMinutes, + Method: idea.Method, + Difficulty: idea.Difficulty, + SearchQuery: oneLine(idea.SearchQuery), + } + if why := s.drop(idea, result.Ideas); why != "" { + result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why}) + continue + } + result.Ideas = append(result.Ideas, idea) + } + return result, nil +} + +// drop returns why idea must be dropped, or "" when it may be kept. +func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string { + for _, name := range s.in.Exclude { + if harness.Similar(idea.Name, name) { + return fmt.Sprintf("repeats %q, already shown", name) + } + } + for _, k := range kept { + if harness.Similar(idea.Name, k.Name) { + return fmt.Sprintf("repeats the idea %q", k.Name) + } + } + return "" +} diff --git a/internal/ai/skills/quick_ideas_test.go b/internal/ai/skills/quick_ideas_test.go new file mode 100644 index 0000000..42512c4 --- /dev/null +++ b/internal/ai/skills/quick_ideas_test.go @@ -0,0 +1,235 @@ +package skills + +import ( + "context" + "fmt" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// quickTastes is the household context of the quick_ideas tests. +var quickTastes = harness.HouseholdContext{ + Today: date(2026, 10, 8), + Timezone: "America/New_York", + Regulars: []harness.RegularDinner{ + {Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true}, + {Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30}, + }, + QuestLog: []string{"Pad Thai"}, +} + +// quickIdea renders one idea of a quick_ideas reply. +func quickIdea(name, method string, minutes int) string { + return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`, + name, minutes, method, strings.ToLower(name)+" recipe") +} + +// quick runs QuickIdeas on in with f and checks f was called exactly +// calls times, each at the household temperature. +func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) { + t.Helper() + out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(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 != "quick_ideas" { + t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema) + } + } + return out, reqs, err +} + +func TestQuickIdeasPrompt(t *testing.T) { + for _, tc := range []struct { + kind, golden, user, rule string + }{ + {QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.", + `Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`}, + {QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.", + "Every idea must take 30 minutes or less from starting prep to the table."}, + } { + t.Run(tc.kind, func(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25)))) + in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}} + _, reqs, err := quick(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, tc.golden+".system.golden", system) + golden(t, tc.golden+".user.golden", user) + for _, part := range []string{ + "## Household context", + "### Regulars\n- Beef Chili", + "### Quest log\n- Pad Thai", + "### Already shown\n- Beef Stew\n- Shrimp Tacos", + tc.rule, + "## Output contract", + } { + if !strings.Contains(system, part) { + t.Errorf("system prompt lacks %q", part) + } + } + if user != tc.user { + t.Errorf("user message = %q, want %q", user, tc.user) + } + }) + } +} + +func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply( + // The excluded names, spelled another way: the normalized name + // decides. + quickIdea("Easy Beef Stew!", "crockpot", 300), + quickIdea("shrimp-tacos", "stovetop", 20), + quickIdea("Pulled Pork", "crockpot", 480), + // A repeat of an idea already kept. + quickIdea("The Pulled Pork", "crockpot", 420), + quickIdea("Chicken Fried Rice", "stovetop", 25), + ))) + in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}} + out, _, err := quick(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, i := range out.Ideas { + kept = append(kept, i.Name) + } + // Chicken Fried Rice is not a crock pot dinner, but the grounded + // recipe, not the model's estimate, decides that later. + if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) { + t.Fatalf("ideas = %q, want %q", kept, want) + } + want := []DroppedIdea{ + {Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`}, + {Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`}, + {Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`}, + } + if !reflect.DeepEqual(out.Dropped, want) { + t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want) + } +} + +func TestQuickIdeasRepairsMalformedReply(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0)))) + f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480)))) + out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 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 TestQuickIdeasRefusesTooManyIdeas(t *testing.T) { + var f llm.Fake + var many []string + for i := range QuickIdeasRequested + 1 { + many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20)) + } + f.QueueGenerate(reply(ideasReply(many...))) + f.QueueGenerate(reply(ideasReply(many...))) + if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") { + t.Fatalf("err = %v, want a bad output naming the limit", err) + } +} + +func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) { + for _, in := range []QuickIdeasRequest{ + {Context: quickTastes, Kind: ""}, + {Context: quickTastes, Kind: "quick"}, + {Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}}, + } { + var f llm.Fake + if _, _, err := quick(t, &f, in, 0); err == nil { + t.Fatalf("%+v was accepted", in) + } + } +} + +func TestCleanExclude(t *testing.T) { + got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"}) + if err != nil { + t.Fatal(err) + } + if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) { + t.Fatalf("CleanExclude = %q, want %q", got, want) + } + if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 { + t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err) + } + tooMany := make([]string, MaxExclude+1) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("dish %d", i) + } + for _, tc := range []struct { + name string + in []string + want string + }{ + {"too many", tooMany, "exclude at most 60"}, + {"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"}, + {"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"}, + {"filler only", []string{"Easy Recipe"}, "names no dinner"}, + {"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} + +func TestCheckQuickKind(t *testing.T) { + for _, kind := range QuickKinds { + if err := CheckQuickKind(kind); err != nil { + t.Errorf("CheckQuickKind(%q) = %v", kind, err) + } + } + for _, kind := range []string{"", "Crockpot", "under_30", "quick"} { + if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") { + t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err) + } + } +} + +func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) { + a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}}) + if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b { + t.Errorf("scopes of the same request differ: %q, %q", a, b) + } + for _, other := range []QuickIdeasRequest{ + {Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}}, + {Kind: QuickUnder30}, + {Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}, + } { + if QuickIdeasScope(other) == a { + t.Errorf("%+v shares the scope %q", other, a) + } + } +} diff --git a/internal/ai/skills/testdata/ideas_from_ingredients_any.system.golden b/internal/ai/skills/testdata/ideas_from_ingredients_any.system.golden new file mode 100644 index 0000000..3f95791 --- /dev/null +++ b/internal/ai/skills/testdata/ideas_from_ingredients_any.system.golden @@ -0,0 +1,94 @@ +## Instructions +You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context. + +Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish. + +Give for each idea: +- name: the dinner's usual name, as a recipe site would title it. +- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold. +- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing. +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- difficulty: "easy", "medium" or "hard" for a home cook. +- search_query: a web search query that finds a recipe for this dinner. + +One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household): +{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"} + +## Household context +### Ingredients on hand +- Chicken thighs +- rice +- cilantro +- black beans +- red onion + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "uses": { + "type": "array", + "items": { + "type": "string" + } + }, + "missing": { + "type": "array", + "items": { + "type": "string" + } + }, + "est_minutes": { + "type": "integer" + }, + "method": { + "type": "string", + "enum": [ + "stovetop", + "oven", + "crockpot", + "grill", + "other" + ] + }, + "difficulty": { + "type": "string", + "enum": [ + "easy", + "medium", + "hard" + ] + }, + "search_query": { + "type": "string" + } + }, + "required": [ + "name", + "uses", + "missing", + "est_minutes", + "method", + "difficulty", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "ideas" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/ideas_from_ingredients_any.user.golden b/internal/ai/skills/testdata/ideas_from_ingredients_any.user.golden new file mode 100644 index 0000000..0c5d657 --- /dev/null +++ b/internal/ai/skills/testdata/ideas_from_ingredients_any.user.golden @@ -0,0 +1 @@ +Suggest 8 dinner ideas from the ingredients on hand. \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden b/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden new file mode 100644 index 0000000..a4aafcc --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_crockpot.system.golden @@ -0,0 +1,93 @@ +## Instructions +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. + +Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot". + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish. + +Give for each idea: +- name: the dinner's usual name, as a recipe site would title it. +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- difficulty: "easy", "medium" or "hard" for a home cook. +- search_query: a web search query that finds a recipe for this dinner. + +One valid idea (an example only, not about this household): +{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min, favorite) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +(none) + +### Already shown +- Beef Stew +- Shrimp Tacos + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "est_minutes": { + "type": "integer" + }, + "method": { + "type": "string", + "enum": [ + "stovetop", + "oven", + "crockpot", + "grill", + "other" + ] + }, + "difficulty": { + "type": "string", + "enum": [ + "easy", + "medium", + "hard" + ] + }, + "search_query": { + "type": "string" + } + }, + "required": [ + "name", + "est_minutes", + "method", + "difficulty", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "ideas" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden b/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden new file mode 100644 index 0000000..a60bedd --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_crockpot.user.golden @@ -0,0 +1 @@ +Suggest 12 crock pot dinner ideas. \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_under30.system.golden b/internal/ai/skills/testdata/quick_ideas_under30.system.golden new file mode 100644 index 0000000..28824f0 --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_under30.system.golden @@ -0,0 +1,93 @@ +## Instructions +You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log. + +Every idea must take 30 minutes or less from starting prep to the table. + +Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish. + +Give for each idea: +- name: the dinner's usual name, as a recipe site would title it. +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- difficulty: "easy", "medium" or "hard" for a home cook. +- search_query: a web search query that finds a recipe for this dinner. + +One valid idea (an example only, not about this household): +{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"} + +## Household context +### Today +- Thursday 2026-10-08 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min, favorite) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min) + +### Quest log +- Pad Thai + +### Passed, oldest first +(none) + +### Recent plans +(none) + +### Already shown +- Beef Stew +- Shrimp Tacos + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "ideas": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "est_minutes": { + "type": "integer" + }, + "method": { + "type": "string", + "enum": [ + "stovetop", + "oven", + "crockpot", + "grill", + "other" + ] + }, + "difficulty": { + "type": "string", + "enum": [ + "easy", + "medium", + "hard" + ] + }, + "search_query": { + "type": "string" + } + }, + "required": [ + "name", + "est_minutes", + "method", + "difficulty", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "ideas" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/quick_ideas_under30.user.golden b/internal/ai/skills/testdata/quick_ideas_under30.user.golden new file mode 100644 index 0000000..4d59f6a --- /dev/null +++ b/internal/ai/skills/testdata/quick_ideas_under30.user.golden @@ -0,0 +1 @@ +Suggest 12 dinner ideas ready in 30 minutes or less. \ No newline at end of file diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index 54f355c..b2eb82b 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -288,6 +288,89 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str return row, nil } +// IdeaOrigins are the origins of a dinner added from an idea: ingredients +// for Cook from what I have, quick for Quick dinners. +var IdeaOrigins = []string{"ingredients", "quick"} + +// NewQuestLog is a dinner to add to the Quest log from an idea: its name, +// the recipe the idea was already grounded in, and its origin, one of +// IdeaOrigins. +type NewQuestLog struct { + Name string + RecipeID string + Origin string +} + +// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner +// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID +// (its method, minutes, difficulty and photo), and returns it with +// created true. When an unarchived dinner already repeats it, by the same +// rule that refuses a duplicate Regular (a harness.Similar name, or the +// recipe's link), that dinner is returned as it is, with created false, +// so adding the same idea again never makes a second dinner: a Regular +// stays a Regular and a Quest log dinner stays in the Quest log. A +// repeated dinner still awaiting the household's choice (a suggested Side +// quest, or one tried and awaiting its Rotation check) is a +// *DuplicateError and changes nothing: adding it would make that choice +// for them. An unknown recipe is an error. +func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) { + if updatedBy == "" { + return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by") + } + if !slices.Contains(IdeaOrigins, n.Origin) { + return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", ")) + } + name, err := checkName(n.Name) + if err != nil { + return Dinner{}, false, err + } + var row db.Dinner + err = s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + recipe, err := q.GetRecipe(ctx, n.RecipeID) + if err != nil { + return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err) + } + existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true}) + if err != nil { + return err + } + if found { + if existing.Status != StatusRegular && existing.Status != StatusQuestLog { + return &DuplicateError{Existing: fromRow(existing)} + } + row = existing + return nil + } + now := store.Now().String() + row, err = q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: StatusQuestLog, + Origin: n.Origin, + Method: recipe.Method, + MinutesTotal: recipe.MinutesTotal, + Difficulty: recipe.Difficulty, + RecipeID: sql.NullString{String: recipe.ID, Valid: true}, + PhotoID: recipe.PhotoID, + GroundingStatus: GroundingGrounded, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("dinners: add to Quest log: %w", err) + } + created = true + return nil + }) + if err != nil { + return Dinner{}, false, err + } + return fromRow(row), created, nil +} + // List returns the dinners with status, which must be regular or // quest_log, ordered by normalized name. The Quest log is the dinners // accepted and not yet scheduled, so a quest_log dinner on the plan for the @@ -551,9 +634,23 @@ func checkRecipeURL(raw string) (*url.URL, error) { // source_url of the recipe it is grounded in, else the recipe_url still // awaiting grounding. func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error { + row, found, err := findRepeat(ctx, q, self, name, recipeURL) + if err != nil { + return err + } + if found { + return &DuplicateError{Existing: fromRow(row)} + } + return nil +} + +// findRepeat returns the first unarchived dinner other than self that +// name or recipeURL repeats, as checkUnique decides it, and whether there +// is one. +func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) { rows, err := q.ListUnarchivedDinners(ctx) if err != nil { - return fmt.Errorf("dinners: list for dedupe: %w", err) + return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err) } for _, row := range rows { if row.Dinner.ID == self { @@ -564,10 +661,10 @@ func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeUR link = row.RecipeSourceUrl } if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) { - return &DuplicateError{Existing: fromRow(row.Dinner)} + return row.Dinner, true, nil } } - return nil + return db.Dinner{}, false, nil } // PhotoURL is where the API serves a list-sized copy of photo id. diff --git a/internal/ideas/ideas.go b/internal/ideas/ideas.go new file mode 100644 index 0000000..41d1dc2 --- /dev/null +++ b/internal/ideas/ideas.go @@ -0,0 +1,662 @@ +// Package ideas finds dinner ideas for Cook from what I have and Quick +// dinners: from a list of ingredients on hand, ideas_from_ingredients +// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each +// is grounded with the recipe grounding pipeline (dropping those no recipe +// grounds, and Quick dinners whose grounded recipe is not of the kind), and +// the ideas kept are reported as a job's result, which fills as each idea +// grounds. A job that keeps no idea, or fails, shows no idea: nothing is +// made up instead (D-017). Ideas are not stored as dinners: the household +// acts on one later. +package ideas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "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/store" +) + +// Job kinds. +const ( + // KindFromIngredients is the job that finds ideas from ingredients. + KindFromIngredients = "ideas_from_ingredients" + // KindQuick is the job that finds a page of Quick dinner ideas. + KindQuick = "quick_ideas" +) + +// ContextBudget is the character budget of the household context +// quick_ideas is given (see harness.BuildContext). +const ContextBudget = 16000 + +// QuickMinutes is the most minutes from prep to table of a Quick dinner +// not cooked in a crock pot. +const QuickMinutes = 30 + +// Job stages, as the job's result reports them. +const ( + StageSuggesting = "suggesting" + StageGrounding = "grounding" + StageDone = "done" +) + +// Reasons the job failed, stored as its failure code. Any other failure, +// such as the database's, is the runner's internal_error. +const ( + // ReasonAIFailed: ideas_from_ingredients or quick_ideas, or + // normalize_recipe while grounding, failed; no idea is made up instead + // (D-017). + ReasonAIFailed = "ai_failed" + // ReasonSearchNotConfigured: recipe search is not set up. + ReasonSearchNotConfigured = "search_not_configured" + // ReasonSearchFailed: the search provider failed. + ReasonSearchFailed = "search_failed" + // ReasonNoIdeasFound: every idea was dropped, by the skill's rules, + // because no recipe grounds it, or because its grounded recipe is not + // a Quick dinner (a Quick only request) or not of the kind asked for (a + // Quick dinners request). + ReasonNoIdeasFound = "no_ideas_found" +) + +// Reasons are every failure reason of both jobs. +var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound} + +// InvalidError is a request the household must correct, such as an empty +// ingredient list or an unknown kind of Quick dinner. +type InvalidError struct { + Message string +} + +func (e *InvalidError) Error() string { return e.Message } + +// Generator runs ideas_from_ingredients on in; scope is +// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients +// with the household's harness.Config. +type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) + +// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in). +// In production it is skills.QuickIdeas with the household's +// harness.Config. +type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) + +// Service starts and runs idea jobs. +type Service struct { + st *store.Store + grounder *recipes.Grounder + runner *jobs.Runner + generate Generator + quick QuickGenerator + logger *slog.Logger + job *jobs.Kind[FromIngredientsJob] + quickJob *jobs.Kind[QuickJob] +} + +// NewService returns a service that reads the household context from st, +// finds ideas with generate and Quick dinner ideas with quick, grounds +// them with grounder and registers the KindFromIngredients and KindQuick +// jobs on runner, which must not have started yet. The jobs are started +// only by StartFromIngredients and StartQuick, which check the request +// first. +func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("ideas: nil store") + case grounder == nil: + return nil, errors.New("ideas: nil grounder") + case generate == nil: + return nil, errors.New("ideas: nil generator") + case quick == nil: + return nil, errors.New("ideas: nil quick generator") + case runner == nil: + return nil, errors.New("ideas: nil job runner") + case logger == nil: + return nil, errors.New("ideas: nil logger") + } + s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger} + var err error + if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil { + return nil, err + } + if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil { + return nil, err + } + return s, nil +} + +// register registers the service-only job name, run by run, whose failed +// jobs show their result through failed. +func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) { + kind, err := jobs.Register(runner, name, run, Reasons...) + if err != nil { + return nil, err + } + if err := kind.ServiceOnly(); err != nil { + return nil, err + } + if err := kind.FailedResult(failed); err != nil { + return nil, err + } + return kind, nil +} + +// FromIngredientsJob is the KindFromIngredients payload: the ingredients +// on hand, as skills.CleanIngredients returns them, and whether only +// Quick dinners are wanted. +type FromIngredientsJob struct { + Ingredients []string `json:"ingredients"` + QuickOnly bool `json:"quick_only"` +} + +// Started is the job a request started. +type Started struct { + JobID string `json:"jobId"` +} + +// StartFromIngredients enqueues a KindFromIngredients job for ingredients +// and returns its id. An ingredient list skills.CleanIngredients refuses +// is an *InvalidError, and starts nothing. +func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) { + ings, err := skills.CleanIngredients(ingredients) + if err != nil { + return Started{}, &InvalidError{Message: "ingredients: " + err.Error()} + } + id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}) + if err != nil { + return Started{}, fmt.Errorf("ideas: enqueue: %w", err) + } + return Started{JobID: id}, nil +} + +// Idea is a grounded idea as the job's result shows it. Method, +// MinutesTotal and Difficulty are the grounded recipe's, never the +// model's estimates. +type Idea struct { + Name string `json:"name"` + // Uses are the household's ingredients the idea uses, as the household + // wrote them and in its order. + Uses []string `json:"uses"` + // Missing are the ingredients the idea needs that the household does + // not have. + Missing []string `json:"missing"` + // Summary is the line shown under the idea, e.g. "Uses 3 of 5 · + // missing: 1 lime" (see skills.IdeaSummary). + Summary string `json:"summary"` + // Method is stovetop, oven, crockpot, grill or other. + Method string `json:"method"` + // MinutesTotal is prep to table, null when the recipe does not say. + MinutesTotal *int `json:"minutes_total"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + // Quick says whether the idea is a Quick dinner (see IsQuick). + Quick bool `json:"quick"` + RecipeID string `json:"recipe_id"` + PhotoURL string `json:"photo_url"` + SourceURL string `json:"source_url"` + SiteName string `json:"site_name"` +} + +// Result is the KindFromIngredients result, published as the job goes: +// its stage, the request, the ideas grounded so far in the model's order +// (an idea that grounds sooner than one before it is shown first, and the +// earlier one is put back in its place when it grounds), +// and how many ideas were dropped, by ideas_from_ingredients' rules, +// because no recipe grounds them, or, for a Quick only request, because +// the grounded recipe is not a Quick dinner. +type Result struct { + Stage string `json:"stage"` + Ingredients []string `json:"ingredients"` + QuickOnly bool `json:"quick_only"` + Ideas []Idea `json:"ideas"` + Dropped int `json:"dropped"` +} + +func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +// IsQuick reports whether a recipe cooked by method in minutes (nil when +// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less +// from prep to table. +func IsQuick(method string, minutes *int) bool { + return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes) +} + +// run runs a KindFromIngredients job: it asks ideas_from_ingredients for +// skills.IdeasRequested ideas, then grounds them in the model's order, at +// most the number still wanted at a time, until skills.IdeasKept are kept +// or none are left. An idea no recipe grounds is dropped, and so, for a +// Quick only request, is one whose grounded recipe is not a Quick dinner; +// when every idea is dropped the job fails with ReasonNoIdeasFound. Any +// other grounding failure fails the job, as does ideas_from_ingredients +// failing, with no idea made up instead. The result is published at each +// stage and again as each idea is kept, so it fills as the ideas ground. +// Before the job fails, its result is published again with no ideas (see +// finish). +func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) { + res, err := s.fill(ctx, p, publish) + return finish(ctx, s.logger, "ideas from ingredients", res, err, publish) +} + +// shownResult is a job result that shows ideas. +type shownResult[R any] interface { + jobs.Public + // shown is how many ideas the result shows. + shown() int + // cleared is the result showing no ideas. + cleared() R +} + +func (r Result) shown() int { return len(r.Ideas) } + +func (r Result) cleared() Result { + r.Ideas = []Idea{} + return r +} + +// finish ends a job whose work returned res and err. A job that failed +// has its result published again with no ideas, to tidy the stored +// result. That write is not what keeps a failed job from showing ideas, +// since it may fail and a job interrupted by restart never makes it: +// withoutIdeas, applied whenever a failed job is read, is. +func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) { + var zero R + if err == nil { + return res, nil + } + if ctx.Err() != nil { + return zero, err + } + var failure *jobs.Failure + if errors.As(err, &failure) { + // Only the code is logged: the error may quote a model's or + // search provider's response. + logger.InfoContext(ctx, what+" failed", "reason", failure.Code) + } + if res.shown() > 0 { + if perr := publish(res.cleared()); perr != nil { + return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr) + } + } + return zero, err +} + +// fill does run's work, returning a *jobs.Failure for a failure the +// household may see. On failure it returns the result last published with +// the error, so run can clear its ideas. +func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) { + res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}} + if err := publish(res); err != nil { + return res, err + } + in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly} + out, err := s.generate(ctx, skills.IdeasScope(in), in) + if err != nil { + if ctx.Err() != nil { + return res, ctx.Err() + } + return res, &jobs.Failure{Code: ReasonAIFailed} + } + res.Stage = StageGrounding + res.Dropped = len(out.Dropped) + if err := publish(res); err != nil { + return res, err + } + reqs := make([]recipes.GroundRequest, len(out.Ideas)) + for i, idea := range out.Ideas { + reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} + } + err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) { + idea := ideaOf(out.Ideas[i], gr) + return idea, !p.QuickOnly || idea.Quick + }, func() error { return publish(res) }) + if err != nil { + return res, err + } + if len(res.Ideas) == 0 { + return res, &jobs.Failure{Code: ReasonNoIdeasFound} + } + res.Stage = StageDone + return res, nil +} + +// keepGrounded grounds reqs with g in their order, at most the number +// still wanted at a time, until want ideas are shown or none are left. As +// each grounds, keep makes its idea from request i's grounding and says +// whether it is kept: a kept idea is put in *shown in its place in reqs' +// order (an idea that grounds sooner than one before it is shown first, +// and the earlier one is put back in its place when it grounds) and +// publish is called. An idea no recipe grounds, or that keep refuses, +// counts in *dropped. Any other grounding failure, or publish failing, is +// returned (see ground). +func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error { + // order[k] is the index in reqs of (*shown)[k], so each idea is put in + // its place in reqs' order as it grounds. + var order []int + for next := 0; len(*shown) < want && next < len(reqs); { + first := next + round := reqs[first:min(first+want-len(*shown), len(reqs))] + next += len(round) + err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error { + if gr == nil { + *dropped++ + return nil + } + idea, ok := keep(first+i, *gr) + if !ok { + *dropped++ + return nil + } + at := len(order) + for at > 0 && order[at-1] > first+i { + at-- + } + order = slices.Insert(order, at, first+i) + *shown = slices.Insert(*shown, at, idea) + return publish() + }) + if err != nil { + return err + } + } + return nil +} + +// ground grounds reqs with g and calls kept, one call at a time, as each +// request grounds, with its index in reqs and its grounding, or nil when +// no recipe grounds it. Any other grounding failure, or kept failing, +// stops the groundings still running and is returned, as a *jobs.Failure +// when the household may see it. +func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error { + groundCtx, cancel := context.WithCancel(ctx) + defer cancel() + var failed error + g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) { + if failed != nil { + return + } + var ungroundable *recipes.ErrUngroundable + switch { + case r.Err == nil: + failed = kept(i, &r.Grounded) + case errors.As(r.Err, &ungroundable): + failed = kept(i, nil) + case errors.Is(r.Err, recipes.ErrSearchNotConfigured): + failed = &jobs.Failure{Code: ReasonSearchNotConfigured} + case errors.Is(r.Err, recipes.ErrSearchFailed): + failed = &jobs.Failure{Code: ReasonSearchFailed} + case errors.Is(r.Err, recipes.ErrNormalizeFailed): + failed = &jobs.Failure{Code: ReasonAIFailed} + default: + failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err) + } + if failed != nil { + cancel() + } + }) + if err := ctx.Err(); err != nil { + return err + } + return failed +} + +// withoutIdeas is a failed job's stored result as the job shows it: with no +// ideas, however the job failed, so a failed job never shows ideas kept +// before the failure. +func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) { + var res R + if err := json.Unmarshal(stored, &res); err != nil { + return nil, fmt.Errorf("ideas: decode failed job's result: %w", err) + } + return res.cleared().PublicJSON() +} + +// ideaOf is idea as grounded by gr. +func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea { + return Idea{ + Name: idea.Name, + Uses: idea.Uses, + Missing: idea.Missing, + Summary: idea.Summary, + Method: gr.Method, + MinutesTotal: gr.MinutesTotal, + Difficulty: gr.Difficulty, + Quick: IsQuick(gr.Method, gr.MinutesTotal), + RecipeID: gr.RecipeID, + PhotoURL: dinners.PhotoURL(gr.PhotoID), + SourceURL: gr.SourceURL, + SiteName: gr.SiteName, + } +} + +// QuickJob is the KindQuick payload: the kind of Quick dinner wanted, +// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown +// to the household, as skills.CleanExclude returns them. +type QuickJob struct { + Kind string `json:"kind"` + Exclude []string `json:"exclude"` +} + +// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of +// kind, none repeating a name in exclude, and returns its id. A kind +// skills.CheckQuickKind refuses, with no default, or an exclude list +// skills.CleanExclude refuses, is an *InvalidError, and starts nothing. +func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) { + if err := skills.CheckQuickKind(kind); err != nil { + return Started{}, &InvalidError{Message: "kind: " + err.Error()} + } + names, err := skills.CleanExclude(exclude) + if err != nil { + return Started{}, &InvalidError{Message: "exclude: " + err.Error()} + } + id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names}) + if err != nil { + return Started{}, fmt.Errorf("ideas: enqueue: %w", err) + } + return Started{JobID: id}, nil +} + +// QuickIdea is a grounded Quick dinner idea as the job's result shows it. +// Method, MinutesTotal and Difficulty are the grounded recipe's, never the +// model's estimates, and always make it of the kind asked for (see +// OfQuickKind). +type QuickIdea struct { + Name string `json:"name"` + // Method is stovetop, oven, crockpot, grill or other: crockpot for + // skills.QuickCrockpot. + Method string `json:"method"` + // MinutesTotal is prep to table, null when the recipe does not say: + // never null, and at most QuickMinutes, for skills.QuickUnder30. + MinutesTotal *int `json:"minutes_total"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + RecipeID string `json:"recipe_id"` + PhotoURL string `json:"photo_url"` + SourceURL string `json:"source_url"` + SiteName string `json:"site_name"` +} + +// QuickResult is the KindQuick result, published as the job goes: its +// stage, the request, the ideas grounded so far in the model's order (as +// in Result), and how many ideas were dropped, by quick_ideas' rules +// (repeating a name already shown or an earlier idea), because no recipe +// grounds them, or because the grounded recipe is not of the kind. +type QuickResult struct { + Stage string `json:"stage"` + Kind string `json:"kind"` + Exclude []string `json:"exclude"` + Ideas []QuickIdea `json:"ideas"` + Dropped int `json:"dropped"` +} + +func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +func (r QuickResult) shown() int { return len(r.Ideas) } + +func (r QuickResult) cleared() QuickResult { + r.Ideas = []QuickIdea{} + return r +} + +// OfQuickKind reports whether a recipe cooked by method in minutes (nil +// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot, +// cooked in a crock pot (normalize_recipe writes a slow cooker as +// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from +// prep to table. Any other kind is none. +func OfQuickKind(kind, method string, minutes *int) bool { + switch kind { + case skills.QuickCrockpot: + return method == "crockpot" + case skills.QuickUnder30: + return minutes != nil && *minutes <= QuickMinutes + } + return false +} + +// runQuick runs a KindQuick job: from the household context, it asks +// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than +// a page holds, then grounds them in the model's order, at most the number +// still wanted at a time, until skills.QuickIdeasKept are kept or none are +// left. An idea no recipe grounds is dropped, and so is one whose grounded +// recipe is not of the kind (OfQuickKind): the model's estimate never +// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page +// shows only those: fewer is honest, and it is never padded with an idea +// not of the kind. When every idea is dropped the job fails with +// ReasonNoIdeasFound. Any other grounding failure fails the job, as does +// quick_ideas failing, with no idea made up instead. The result is +// published at each stage and again as each idea is kept, and a failed job +// shows no ideas, as for KindFromIngredients (see finish). +func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) { + res, err := s.fillQuick(ctx, p, publish) + return finish(ctx, s.logger, "quick ideas", res, err, publish) +} + +// fillQuick does runQuick's work as fill does run's. +func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) { + res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}} + if err := publish(res); err != nil { + return res, err + } + hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget) + if err != nil { + return res, fmt.Errorf("ideas: %w", err) + } + in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude} + out, err := s.quick(ctx, skills.QuickIdeasScope(in), in) + if err != nil { + if ctx.Err() != nil { + return res, ctx.Err() + } + return res, &jobs.Failure{Code: ReasonAIFailed} + } + res.Stage = StageGrounding + res.Dropped = len(out.Dropped) + if err := publish(res); err != nil { + return res, err + } + reqs := make([]recipes.GroundRequest, len(out.Ideas)) + for i, idea := range out.Ideas { + reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery} + } + err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) { + return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal) + }, func() error { return publish(res) }) + if err != nil { + return res, err + } + if len(res.Ideas) == 0 { + return res, &jobs.Failure{Code: ReasonNoIdeasFound} + } + res.Stage = StageDone + return res, nil +} + +// quickIdeaOf is idea as grounded by gr. +func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea { + return QuickIdea{ + Name: idea.Name, + Method: gr.Method, + MinutesTotal: gr.MinutesTotal, + Difficulty: gr.Difficulty, + RecipeID: gr.RecipeID, + PhotoURL: dinners.PhotoURL(gr.PhotoID), + SourceURL: gr.SourceURL, + SiteName: gr.SiteName, + } +} + +// Origins of a dinner made from an idea (the dinners.origin column). +const ( + // OriginIngredients is an idea from Cook from what I have. + OriginIngredients = "ingredients" + // OriginQuick is an idea from Quick dinners. + OriginQuick = "quick" +) + +// ErrIdeaNotFound is an idea the household cannot act on: an unknown job, +// a job that does not find ideas, a failed job (which shows no ideas), or +// an index past the job's ideas. +var ErrIdeaNotFound = errors.New("idea not found") + +// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas +// may still move: an idea that grounds later is put back in its place +// before those shown already, so an index names an idea only once the job +// has succeeded. +var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded") + +// Chosen is the idea the household acts on: its name, the recipe it was +// grounded in, and the origin of a dinner made from it. +type Chosen struct { + Name string + RecipeID string + Origin string +} + +// Idea returns idea i, counted from 0, of the succeeded job jobID, as its +// stored result shows it: a dinner made from it uses the recipe it was +// grounded in, never grounding it again. A job still queued or running is +// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients +// or KindQuick job is ErrIdeaNotFound. +func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) { + job, err := s.runner.Get(ctx, jobID) + if errors.Is(err, jobs.ErrNotFound) { + return Chosen{}, ErrIdeaNotFound + } + if err != nil { + return Chosen{}, fmt.Errorf("ideas: read job: %w", err) + } + if job.Kind != KindFromIngredients && job.Kind != KindQuick { + return Chosen{}, ErrIdeaNotFound + } + switch job.Status { + case jobs.StatusQueued, jobs.StatusRunning: + return Chosen{}, ErrIdeasNotReady + case jobs.StatusSucceeded: + default: + return Chosen{}, ErrIdeaNotFound + } + if job.Kind == KindQuick { + var res QuickResult + if err := json.Unmarshal(job.Result, &res); err != nil { + return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err) + } + if i < 0 || i >= len(res.Ideas) { + return Chosen{}, ErrIdeaNotFound + } + return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil + } + var res Result + if err := json.Unmarshal(job.Result, &res); err != nil { + return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err) + } + if i < 0 || i >= len(res.Ideas) { + return Chosen{}, ErrIdeaNotFound + } + return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil +} diff --git a/internal/ideas/ideas_test.go b/internal/ideas/ideas_test.go new file mode 100644 index 0000000..8524e35 --- /dev/null +++ b/internal/ideas/ideas_test.go @@ -0,0 +1,575 @@ +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) + } + } +} diff --git a/internal/ideas/quick_test.go b/internal/ideas/quick_test.go new file mode 100644 index 0000000..04bc7d2 --- /dev/null +++ b/internal/ideas/quick_test.go @@ -0,0 +1,400 @@ +package ideas + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" +) + +// quickIdea renders one idea of a quick_ideas reply whose search query is +// its name. +func quickIdea(name, method string, minutes int) string { + return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`, + name, minutes, method, name) +} + +// quickRecorder collects every result a quick job publishes. +type quickRecorder struct { + mu sync.Mutex + published []QuickResult +} + +func (r *quickRecorder) latest() (QuickResult, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.published) == 0 { + return QuickResult{}, false + } + return r.published[len(r.published)-1], true +} + +func (r *quickRecorder) publish(res QuickResult) error { + r.mu.Lock() + defer r.mu.Unlock() + res.Ideas = append([]QuickIdea(nil), res.Ideas...) + r.published = append(r.published, res) + return nil +} + +// runQuick runs the quick job's handler directly, recording each publish. +func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) { + t.Helper() + names, err := skills.CleanExclude(exclude) + if err != nil { + t.Fatal(err) + } + var rec quickRecorder + res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish) + return res, rec.published, err +} + +func quickNames(list []QuickIdea) []string { + out := []string{} + for _, i := range list { + out = append(out, i.Name) + } + return out +} + +func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) { + e := newEnv(t) + // The model estimates every idea under 30 minutes; the grounded + // recipes decide. + e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25) + e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45) + e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30) + e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + e.reply( + quickIdea("Chicken Fried Rice", "stovetop", 20), + quickIdea("Beef Stroganoff", "stovetop", 25), + quickIdea("Shrimp Scampi", "stovetop", 20), + quickIdea("Chicken Chili", "crockpot", 25), + ) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want) + } + if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 { + t.Fatalf("result = %+v, want done with 2 dropped", res) + } + for _, i := range res.Ideas { + if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" { + t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i) + } + } +} + +func TestCrockpotDropsStovetopIdeas(t *testing.T) { + e := newEnv(t) + // The model calls every idea a crock pot dinner; only the recipes + // cooked in a slow cooker are. + e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480) + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375) + e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25) + e.reply( + quickIdea("Pulled Pork", "crockpot", 480), + quickIdea("Beef Stew", "crockpot", 360), + quickIdea("Chicken Tortilla Soup", "crockpot", 375), + quickIdea("Chicken Fried Rice", "crockpot", 240), + ) + res, _, err := e.runQuick(t, skills.QuickCrockpot) + if err != nil { + t.Fatal(err) + } + if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) { + t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want) + } + if res.Dropped != 2 { + t.Fatalf("dropped %d, want 2", res.Dropped) + } + for _, i := range res.Ideas { + if i.Method != "crockpot" { + t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method) + } + } + if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 { + t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m) + } +} + +func TestExcludedNamesAreNeverReturned(t *testing.T) { + e := newEnv(t) + // Every idea has a recipe that would ground it as a Quick dinner. + for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} { + e.recipe(t, name, name, 20) + } + e.reply( + quickIdea("Easy Chicken Fried Rice", "stovetop", 20), + quickIdea("shrimp-tacos", "stovetop", 15), + quickIdea("Garlic Shrimp Pasta", "stovetop", 25), + ) + res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos") + if err != nil { + t.Fatal(err) + } + if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 { + t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped) + } + if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) { + t.Errorf("searched %q, want the excluded names never grounded", got) + } + if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) { + t.Errorf("result exclude = %q", res.Exclude) + } + // The model was told what was already shown. + if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") { + t.Errorf("prompt does not list the names already shown:\n%s", system) + } +} + +func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) { + t.Run("twelve ideas, six shown", func(t *testing.T) { + e := newEnv(t) + var reply []string + for i := range skills.QuickIdeasRequested { + name := fmt.Sprintf("Skillet Dish %c", 'A'+i) + e.recipe(t, name, name, 20) + reply = append(reply, quickIdea(name, "stovetop", 20)) + } + e.reply(reply...) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 { + t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept) + } + if got := e.search.Queries(); len(got) != skills.QuickIdeasKept { + t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept) + } + }) + t.Run("over-generated ideas replace dropped ones", func(t *testing.T) { + e := newEnv(t) + var reply []string + // The first six ground too slow, so the next six are grounded to + // fill the page; only three of those are quick. + for i := range skills.QuickIdeasRequested { + name := fmt.Sprintf("Skillet Dish %c", 'A'+i) + minutes := 20 + if i < 6 || i%2 == 1 { + minutes = 50 + } + e.recipe(t, name, name, minutes) + reply = append(reply, quickIdea(name, "stovetop", 20)) + } + e.reply(reply...) + res, _, err := e.runQuick(t, skills.QuickUnder30) + if err != nil { + t.Fatal(err) + } + // Fewer than six survive: the page shows three, never padded. + if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone { + t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want) + } + }) +} + +func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) { + e := newEnv(t) + for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} { + e.recipe(t, name, name, 25) + } + // The first idea's search waits, so the two after it ground first. + gate := make(chan struct{}) + e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate} + e.reply( + quickIdea("Chicken Fried Rice", "stovetop", 20), + quickIdea("Shrimp Scampi", "stovetop", 20), + quickIdea("Garlic Shrimp Pasta", "stovetop", 25), + ) + var rec quickRecorder + type outcome struct { + res QuickResult + err error + } + done := make(chan outcome, 1) + go func() { + res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, 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 := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) { + 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{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} + if got := quickNames(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) + } + + var stages []string + var counts []int + for _, p := range rec.published { + stages = append(stages, p.Stage) + counts = append(counts, len(p.Ideas)) + } + if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) { + t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts) + } +} + +func TestQuickFailures(t *testing.T) { + for _, tc := range []struct { + name string + setup func(t *testing.T, e *env) + code string + }{ + {"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed}, + {"no idea is of the kind", func(t *testing.T, e *env) { + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.reply(quickIdea("Beef Stew", "crockpot", 360)) + }, ReasonNoIdeasFound}, + {"every idea already shown", func(t *testing.T, e *env) { + e.reply(quickIdea("Pulled Pork", "crockpot", 480)) + }, ReasonNoIdeasFound}, + {"a later round fails after ideas were shown", func(t *testing.T, e *env) { + e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + e.recipe(t, "Beef Stew", "Beef Stew", 150) + e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")} + reply := []string{quickIdea("Chicken Chili", "crockpot", 240)} + for i := range 5 { + name := fmt.Sprintf("Mystery Stew %d", i+1) + reply = append(reply, quickIdea(name, "crockpot", 300)) + } + e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...) + }, ReasonSearchFailed}, + } { + t.Run(tc.name, func(t *testing.T) { + e := newEnv(t) + tc.setup(t, e) + _, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork") + var failure *jobs.Failure + if !errors.As(err, &failure) || failure.Code != tc.code { + t.Fatalf("err = %v, want failure %s", err, tc.code) + } + if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone { + t.Fatalf("last publish of a failed job = %+v, want no ideas", last) + } + }) + } +} + +func TestStartQuickRunsTheJob(t *testing.T) { + e := newEnv(t) + e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480) + e.reply(quickIdea("Pulled Pork", "crockpot", 480)) + 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() + }) + + for _, tc := range []struct { + kind string + exclude []string + want string + }{ + {"", nil, `kind: kind "" is not one of crockpot, under30`}, + {"quick", nil, `kind: kind "quick" is not one of crockpot, under30`}, + {skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`}, + } { + _, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude) + var invalid *InvalidError + if !errors.As(err, &invalid) || invalid.Message != tc.want { + t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want) + } + } + + started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "}) + 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 != KindQuick { + t.Fatalf("job = %+v", job) + } + var res QuickResult + if err := json.Unmarshal(job.Result, &res); err != nil { + t.Fatal(err) + } + if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 { + t.Fatalf("result = %+v", res) + } + if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" { + t.Errorf("idea = %+v", got) + } +} + +func TestOfQuickKind(t *testing.T) { + m := func(n int) *int { return &n } + for _, tc := range []struct { + kind, method string + minutes *int + want bool + }{ + {skills.QuickCrockpot, "crockpot", m(480), true}, + {skills.QuickCrockpot, "crockpot", nil, true}, + {skills.QuickCrockpot, "stovetop", m(20), false}, + {skills.QuickUnder30, "stovetop", m(30), true}, + {skills.QuickUnder30, "oven", m(31), false}, + {skills.QuickUnder30, "stovetop", nil, false}, + {skills.QuickUnder30, "crockpot", m(240), false}, + {"quick", "crockpot", m(20), false}, + } { + if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want { + t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want) + } + } + if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) { + t.Errorf("kinds = %q", skills.QuickKinds) + } +} diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index b98e61a..e6b6561 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -135,12 +135,14 @@ type Job struct { type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string) // handler is a registered kind: run runs a job, check reports whether a -// payload decodes into the kind's payload type, and serviceOnly is set when -// Runner.Enqueue refuses the kind. +// payload decodes into the kind's payload type, serviceOnly is set when +// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get +// shows of a failed job's stored result (see Kind.FailedResult). type handler struct { - run runFunc - check func(payload []byte) bool - serviceOnly bool + run runFunc + check func(payload []byte) bool + serviceOnly bool + failedResult func(stored json.RawMessage) (json.RawMessage, error) } // Runner owns the job handlers and the worker pool. @@ -307,6 +309,27 @@ func (k *Kind[P]) ServiceOnly() error { return nil } +// FailedResult makes Get return, for a failed job of the kind with a +// stored result, show's view of that result instead of the result itself. +// It is applied on every read, so it holds however the job failed: by its +// handler, as interrupted by restart, or after a write meant to tidy the +// result failed. A show error fails Get. A kind that never calls it shows a +// failed job's last result as stored. It must be called before Start. +func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error { + if show == nil { + return fmt.Errorf("jobs: failed result %q: nil func", k.name) + } + k.r.mu.Lock() + defer k.r.mu.Unlock() + if k.r.started { + return fmt.Errorf("jobs: failed result %q: runner already started", k.name) + } + h := k.r.handlers[k.name] + h.failedResult = show + k.r.handlers[k.name] = h + return nil +} + // Wake tells an idle worker to look for a job, as Enqueue does itself: // call it after committing the transaction a job was enqueued in. func (k *Kind[P]) Wake() { k.r.wakeOne() } @@ -402,7 +425,8 @@ func (r *Runner) Wait() { r.wg.Wait() } -// Get returns the job with id, or ErrNotFound. +// Get returns the job with id, or ErrNotFound. A failed job's result is +// shown through its kind's FailedResult, when the kind set one. func (r *Runner) Get(ctx context.Context, id string) (Job, error) { row, err := db.New(r.st.Reader).GetJob(ctx, id) if errors.Is(err, sql.ErrNoRows) { @@ -418,6 +442,21 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) { if row.Error.Valid { job.Error = &row.Error.String } + if job.Status == StatusFailed && job.Result != nil { + r.mu.Lock() + show := r.handlers[job.Kind].failedResult + r.mu.Unlock() + if show != nil { + shown, err := show(job.Result) + if err != nil { + return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err) + } + if !json.Valid(shown) { + return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind) + } + job.Result = shown + } + } return job, nil } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index ee7e152..79cb103 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -772,3 +772,47 @@ func TestServiceOnlyKind(t *testing.T) { t.Error("ServiceOnly after Start succeeded, want an error") } } + +func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + if err := publish(countResult{Done: 5}); err != nil { + return countResult{}, err + } + if p.Steps == 0 { + return countResult{}, &Failure{Code: "stopped"} + } + return countResult{Done: p.Steps}, nil + } + hidden := register(t, r, "hidden", h, "stopped") + plain := register(t, r, "plain", h, "stopped") + if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) { + return json.RawMessage(`{"done":0}`), nil + }); err != nil { + t.Fatal(err) + } + if err := hidden.FailedResult(nil); err == nil { + t.Error("FailedResult(nil) succeeded, want an error") + } + start(t, r) + if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil { + t.Error("FailedResult after Start succeeded, want an error") + } + + for _, tc := range []struct { + kind *Kind[countPayload] + steps int + want string + }{ + {hidden, 0, `{"done":0}`}, // failed: shown through FailedResult + {hidden, 3, `{"done":3}`}, // succeeded: as stored + {plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored + } { + job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished) + if string(job.Result) != tc.want { + t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want) + } + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index c016212..a3d0576 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -423,6 +423,40 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din return p, nil } +// Tonight returns today, the household-local date now in the household's +// zone, and the Monday starting its week, when that week has a plan: Swap +// of that week and day sets tonight's dinner. A week with no plan is +// ErrNotFound; no plan is made for it, since a plan holds all seven days. +// The week is always today's own, so Swap never writes a different day. +func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + settings, err := q.GetHouseholdSettings(ctx) + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err) + } + zone, err := household.LoadZone(settings.Timezone) + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err) + } + today = household.Today(time.Now(), zone) + week = household.WeekStart(today) + _, err = q.GetWeekPlan(ctx, week.String()) + if errors.Is(err, sql.ErrNoRows) { + return store.LocalDate{}, store.LocalDate{}, ErrNotFound + } + if err != nil { + return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err) + } + return week, today, nil +} + // isNew is plan_days.is_new for a dinner with status: 1 for the Quest log. func isNew(status string) int64 { if status == dinners.StatusQuestLog { diff --git a/internal/plan/rotation_test.go b/internal/plan/rotation_test.go index f5b1f03..2684932 100644 --- a/internal/plan/rotation_test.go +++ b/internal/plan/rotation_test.go @@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) { // 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 + "dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is "plan/plan.go": 1, // plannable "plan/rotation.go": 1, // decisionStatus[DecisionKeep] "suggest/batch.go": 1, // reads which dinners are Regulars to cite diff --git a/internal/recipes/ground.go b/internal/recipes/ground.go index 6b14436..3a69404 100644 --- a/internal/recipes/ground.go +++ b/internal/recipes/ground.go @@ -292,7 +292,22 @@ type GroundResult struct { // affect the others. func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult { results := make([]GroundResult, len(reqs)) + g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r }) + return results +} + +// GroundEach grounds each request as GroundMany does, but calls done with +// each item's index and result as soon as that item finishes, one call at +// a time. It returns once every item has finished and every call has +// returned. +func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) { slots := make(chan struct{}, GroundConcurrency) + var mu sync.Mutex + report := func(i int, r GroundResult) { + mu.Lock() + defer mu.Unlock() + done(i, r) + } var wg sync.WaitGroup for i, req := range reqs { wg.Add(1) @@ -301,16 +316,15 @@ func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []Groun select { case slots <- struct{}{}: case <-ctx.Done(): - results[i] = GroundResult{Err: ctx.Err()} + report(i, GroundResult{Err: ctx.Err()}) return } - defer func() { <-slots }() gr, err := g.Ground(ctx, req.DishName, req.Query) - results[i] = GroundResult{Grounded: gr, Err: err} + <-slots + report(i, GroundResult{Grounded: gr, Err: err}) }() } wg.Wait() - return results } // stored returns the grounding of the recipe stored for sourceURL, and diff --git a/internal/recipes/recipestest/recipestest.go b/internal/recipes/recipestest/recipestest.go index 9d22dfa..061651f 100644 --- a/internal/recipes/recipestest/recipestest.go +++ b/internal/recipes/recipestest/recipestest.go @@ -23,12 +23,16 @@ import ( ) // Search is a recipes.SearchSource and recipes.SearchProvider answering -// each query from Results. Err, when set, fails every search. Gate, when -// set, makes every search wait for it to close. +// each query from Results. Err, when set, fails every search, and Fail +// fails the searches for its queries. Gate, when set, makes every search +// wait for it to close, and Gates makes the searches for its queries wait +// for theirs. type Search struct { Results map[string][]recipes.Candidate Err error + Fail map[string]error Gate chan struct{} + Gates map[string]chan struct{} mu sync.Mutex queries []string @@ -40,9 +44,12 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can s.mu.Lock() s.queries = append(s.queries, query) s.mu.Unlock() - if s.Gate != nil { + for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} { + if gate == nil { + continue + } select { - case <-s.Gate: + case <-gate: case <-ctx.Done(): return nil, ctx.Err() } @@ -50,6 +57,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can if s.Err != nil { return nil, s.Err } + if err := s.Fail[query]; err != nil { + return nil, err + } c := s.Results[query] return c[:min(n, len(c))], nil } diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index a4d9ed7..3bd80fb 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), - newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 229b355..61faead 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/google_test.go b/internal/server/google_test.go index bb9f7e4..ec9f787 100644 --- a/internal/server/google_test.go +++ b/internal/server/google_test.go @@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv { } logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug})) e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/ideaactions_test.go b/internal/server/ideaactions_test.go new file mode 100644 index 0000000..3f1f299 --- /dev/null +++ b/internal/server/ideaactions_test.go @@ -0,0 +1,416 @@ +package server + +import ( + "context" + "database/sql" + "io" + "log/slog" + "net/http" + "net/url" + "testing" + "testing/fstest" + "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/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// actionsZone is the household zone of newIdeaActionsRouter. +const actionsZone = "America/New_York" + +// newIdeaActionsRouter returns a started router whose ideas, dinners and +// plan services share one store, whose household settings are seeded, so +// an idea's actions read the job, make the dinner and swap the plan of the +// same household. The ideas service runs both skills through the real +// harness with the scripted model and grounds with the scripted fakes. +func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, 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 model, nil }, + Store: st, + Guard: guard, + } + generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) { + return skills.IdeasFromIngredients(ctx, cfg, scope, in) + } + quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeas(ctx, cfg, scope, in) + } + ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger) + if err != nil { + t.Fatal(err) + } + dinnerSvc, err := dinners.NewService(st, g, runner, logger) + if err != nil { + t.Fatal(err) + } + draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) { + t.Error("draft_week ran: an idea action must never draft a plan") + return skills.Week{}, context.Canceled + } + planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + startRunner(t, runner) + return h, st +} + +// finishedIdeasJob runs an ideas from ingredients job that keeps names, in +// order, and returns the router, store, job id and its ideas' recipe ids. +func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) { + t.Helper() + model, normalize, search, pages, images := scriptIdeas(t, names, names, nil) + h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images) + id := startIdeasJob(t, h) + job := waitJob(t, h, id) + if job["status"] != "succeeded" { + t.Fatalf("ideas job = %v", job) + } + var recipeIDs []string + for _, i := range job["result"].(map[string]any)["ideas"].([]any) { + recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string)) + } + if len(recipeIDs) != len(names) { + t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names)) + } + return h, st, id, recipeIDs +} + +func ideaPath(jobID, i, action string) string { + return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action +} + +// dinnerCount is how many dinners the store holds, archived or not. +func dinnerCount(t *testing.T, st *store.Store) int { + t.Helper() + var n int + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +// thisWeek is today in the household's zone and the Monday of its week. +func thisWeek(t *testing.T) (week, today store.LocalDate) { + t.Helper() + zone, err := household.LoadZone(actionsZone) + if err != nil { + t.Fatal(err) + } + today = household.Today(time.Now(), zone) + return household.WeekStart(today), today +} + +// planThisWeek stores a plan of this week with a Regular on every day and +// the given status, and returns the week's Monday and today. +func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) { + t.Helper() + ctx := t.Context() + week, today = thisWeek(t) + days, err := household.WeekDays(week) + if err != nil { + t.Fatal(err) + } + q := db.New(st.Writer) + now := store.Now().String() + planID := store.NewID() + if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + for _, date := range days { + name := "Regular " + date.String() + d, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular", + GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(), + DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + } + if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil { + t.Fatal(err) + } + return week, today +} + +// planRevision is the revision of the plan of week. +func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 { + t.Helper() + row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String()) + if err != nil { + t.Fatal(err) + } + return row.Revision +} + +// Adding an idea to the Quest log makes a quest_log dinner grounded in the +// idea's own recipe, with origin ingredients, without grounding it again; +// asking again answers the same dinner and makes no second one. +func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) { + t.Parallel() + h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding") + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusCreated { + t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body) + } + if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" || + d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil || + d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject { + t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1]) + } + + rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" { + t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"]) + } + if n := dinnerCount(t, st); n != 1 { + t.Fatalf("dinners = %d, want 1", n) + } + if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" { + t.Fatalf("Quest log = %q", names) + } +} + +// A Quick dinners idea makes a dinner with origin quick. +func TestPostIdeaQuestLogQuickOrigin(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25) + quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20}) + h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images) + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body) + } + id := body["jobId"].(string) + if job := waitJob(t, h, id); job["status"] != "succeeded" { + t.Fatalf("quick job = %v", job) + } + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "") + if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" { + t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d) + } +} + +// An idea repeating a dinner the household already has is that dinner, as +// it is: a Regular stays a Regular. One awaiting the household's choice +// is refused and nothing changes. +func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding") + regular := createDinner(t, h, `{"name": "chicken fried rice"}`) + + rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "") + if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" { + t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular) + } + + now := store.Now().String() + if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested", + Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "") + if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" { + t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body) + } + if n := dinnerCount(t, st); n != 2 { + t.Fatalf("dinners = %d, want the Regular and the Side quest only", n) + } +} + +// Cook tonight puts the idea's dinner on today of this week's plan through +// the plan's swap: the day is new, the plan's revision rises and a synced +// plan becomes changed. Asking again plans the same dinner. +func TestPostIdeaCookTonightSwapsToday(t *testing.T) { + t.Parallel() + h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice") + week, today := planThisWeek(t, st, plan.StatusSynced) + before := planRevision(t, st, week) + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK { + t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body) + } + d := body["dinner"].(map[string]any) + if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] { + t.Fatalf("dinner = %v", d) + } + assertTonight := func(body map[string]any) { + t.Helper() + p := body["plan"].(map[string]any) + if p["week_start"] != week.String() || p["status"] != plan.StatusChanged { + t.Fatalf("plan = %v, want week %s changed", p, week) + } + found := false + for _, day := range p["days"].([]any) { + day := day.(map[string]any) + planned := day["dinner"].(map[string]any)["id"] == d["id"] + if day["date"] == today.String() { + found = true + if !planned || day["is_new"] != true { + t.Fatalf("today %s = %v, want %v new", today, day, d["id"]) + } + } else if planned { + t.Fatalf("dinner also on %v", day["date"]) + } + } + if !found { + t.Fatalf("plan has no day %s", today) + } + } + assertTonight(body) + if after := planRevision(t, st, week); after != before+1 { + t.Fatalf("plan revision = %d, want %d", after, before+1) + } + + rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] { + t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body) + } + assertTonight(body) + if n := dinnerCount(t, st); n != 8 { + t.Fatalf("dinners = %d, want the seven Regulars and one idea", n) + } +} + +// Cook tonight reuses a dinner the household already has. +func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + _, today := planThisWeek(t, st, plan.StatusDraft) + regular := createDinner(t, h, `{"name": "Chicken fried rice"}`) + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular { + t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular) + } + for _, day := range body["plan"].(map[string]any)["days"].([]any) { + day := day.(map[string]any) + if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) { + t.Fatalf("today = %v, want Regular %s", day, regular) + } + } +} + +// With no plan for this week, cook tonight answers 409 no_plan and changes +// nothing: no dinner is made and no plan is drafted or stored. +func TestPostIdeaCookTonightWithoutPlan(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "") + if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" { + t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body) + } + if n := dinnerCount(t, st); n != 0 { + t.Fatalf("dinners = %d, want none", n) + } + var plans, drafts int + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil { + t.Fatal(err) + } + if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil { + t.Fatal(err) + } + if plans != 0 || drafts != 0 { + t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts) + } +} + +// An idea that is not an index of a succeeded ideas job is refused. +func TestPostIdeaActionsRefuse(t *testing.T) { + t.Parallel() + h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice") + planThisWeek(t, st, plan.StatusDraft) + // A Regular's grounding job is a job, but finds no ideas. + rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body) + } + other := body["grounding_job_id"].(string) + + for _, action := range []string{"quest-log", "cook-tonight"} { + for _, tc := range []struct { + job, i string + status int + code string + }{ + {id, "x", http.StatusBadRequest, "invalid_request"}, + {id, "-1", http.StatusBadRequest, "invalid_request"}, + {id, "01", http.StatusBadRequest, "invalid_request"}, + {id, "1", http.StatusNotFound, "not_found"}, + {"unknown", "0", http.StatusNotFound, "not_found"}, + {other, "0", http.StatusNotFound, "not_found"}, + } { + rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "") + if rec.Code != tc.status || errorCode(body) != tc.code { + t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code) + } + } + } + if n := dinnerCount(t, st); n != 8 { + t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n) + } +} + +// While the job still finds ideas, an idea is not acted on: its index may +// still move. +func TestPostIdeaActionsWhileJobRuns(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil) + gate := make(chan struct{}) + search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate} + h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images) + id := startIdeasJob(t, h) + + for _, action := range []string{"quest-log", "cook-tonight"} { + rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "") + if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" { + t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body) + } + } + close(gate) + waitJob(t, h, id) + if n := dinnerCount(t, st); n != 0 { + t.Fatalf("dinners = %d, want none", n) + } +} diff --git a/internal/server/ideas.go b/internal/server/ideas.go new file mode 100644 index 0000000..c6f2145 --- /dev/null +++ b/internal/server/ideas.go @@ -0,0 +1,224 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" +) + +// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body. +const maxIdeasBody = 16 << 10 + +// ideasFromIngredients is the POST /ideas/from-ingredients body. Both +// fields are required. +type ideasFromIngredients struct { + Ingredients []string `json:"ingredients"` + QuickOnly *bool `json:"quick_only"` +} + +// postIdeasFromIngredients starts finding ideas from the ingredients on +// hand and answers 202 with the jobId that GET /jobs/{id} reports on, +// without waiting for the job. An ingredient list the household must +// correct is 400. +func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + switch { + case body.Ingredients == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required") + return + case body.QuickOnly == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required") + return + } + started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly) + var invalid *ideas.InvalidError + switch { + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message) + return + case err != nil: + logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusAccepted, started) + } +} + +// ideasQuick is the POST /ideas/quick body. Both fields are required: +// exclude is [] on a first page. +type ideasQuick struct { + Kind *string `json:"kind"` + Exclude []string `json:"exclude"` +} + +// postIdeasQuick starts finding a page of Quick dinner ideas of a kind, +// crockpot or under30, none named in exclude, and answers 202 with the +// jobId that GET /jobs/{id} reports on, without waiting for the job. A +// missing or unknown kind, or an exclude list the household must correct, +// is 400. +func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody)) + if err != nil { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + switch { + case body.Kind == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required") + return + case body.Exclude == nil: + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required") + return + } + started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude) + var invalid *ideas.InvalidError + switch { + case errors.As(err, &invalid): + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message) + return + case err != nil: + logger.ErrorContext(r.Context(), "start quick ideas", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return + } + writeJSON(w, r, logger, http.StatusAccepted, started) + } +} + +// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers +// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found +// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while +// the job is still finding ideas. +func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) { + raw := chi.URLParam(r, "i") + i, err := strconv.Atoi(raw) + if err != nil || i < 0 || strconv.Itoa(i) != raw { + writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more") + return ideas.Chosen{}, false + } + idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i) + switch { + case errors.Is(err, ideas.ErrIdeaNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found") + return ideas.Chosen{}, false + case errors.Is(err, ideas.ErrIdeasNotReady): + writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error()) + return ideas.Chosen{}, false + case err != nil: + logger.ErrorContext(r.Context(), "read idea", "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + return ideas.Chosen{}, false + } + return idea, true +} + +// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log, +// recording the session's actor as updated_by, as a dinner grounded in +// the recipe the idea was grounded in, with origin ingredients or quick. +// It answers 201 with the dinner made, or 200 with the dinner the idea +// repeats (see dinners.Service.AddToQuestLog), so asking again answers the +// same dinner. A repeated dinner awaiting the household's choice is 409 +// duplicate_dinner. +func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc) + if !ok { + return + } + d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "add idea to Quest log", err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, r, logger, status, d) + } +} + +// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer: +// the idea's dinner and the week's plan with it tonight. +type cookedTonight struct { + Dinner dinners.Dinner `json:"dinner"` + Plan plan.Plan `json:"plan"` +} + +// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today, +// in the household's zone, of the plan of today's week. The idea's dinner +// is added or found as postIdeaQuestLog does, then swapped onto today +// through plan.Service.Swap, exactly as a swap on the Draft board, so the +// plan is marked edited and a synced plan becomes changed. It answers 200 +// with the dinner and the plan as swapped; asking again plans the same +// dinner again. +// +// When today's week has no plan it answers 409 no_plan and changes +// nothing: no dinner is added and no plan is made, since a plan holds all +// seven days; the household plans the week first. The week is always +// today's own, so no other day is ever written. A dinner already on +// another day of the week is 409 dinner_already_planned, after the dinner +// is added or found. +func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc) + if !ok { + return + } + week, today, err := planSvc.Tonight(r.Context()) + if errors.Is(err, plan.ErrNotFound) { + writeNoPlan(w, r, logger) + return + } + if err != nil { + writePlanError(w, r, logger, "find tonight's plan", err) + return + } + d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject) + if err != nil { + writeDinnerError(w, r, logger, "add idea to cook tonight", err) + return + } + p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID) + if errors.Is(err, plan.ErrNotFound) { + // Plans are never deleted, so the plan Tonight found is still + // there; this is said loudly rather than answered as success. + logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String()) + writeNoPlan(w, r, logger) + return + } + if err != nil { + writePlanError(w, r, logger, "cook idea tonight", err) + return + } + writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p}) + } +} + +// writeNoPlan answers 409 no_plan: today's week has no plan to put +// tonight's dinner on, so the household plans the week first. +func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) { + writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first") +} diff --git a/internal/server/ideas_test.go b/internal/server/ideas_test.go new file mode 100644 index 0000000..c08a88c --- /dev/null +++ b/internal/server/ideas_test.go @@ -0,0 +1,350 @@ +package server + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "testing" + "testing/fstest" + "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/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" + "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" +) + +const ideasPath = "/api/v1/ideas/from-ingredients" + +// newIdeasRouter returns a router whose ideas service runs +// ideas_from_ingredients and quick_ideas through the real harness with the +// scripted model and grounds with fake search, pages and images and the +// scripted normalize, with its job runner and store, whose household +// settings are seeded. The runner is started when start is set. +func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) { + t.Helper() + st := newTestStore(t) + if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil { + t.Fatal(err) + } + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, 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 model, nil }, + Store: st, + Guard: guard, + } + generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) { + return skills.IdeasFromIngredients(ctx, cfg, scope, in) + } + quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeas(ctx, cfg, scope, in) + } + svc, err := ideas.NewService(st, g, generate, quick, runner, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + if start { + startRunner(t, runner) + } + return h, runner, st +} + +// startRunner starts runner until the test ends. +func startRunner(t *testing.T, runner *jobs.Runner) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + if err := runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + t.Cleanup(func() { + cancel() + runner.Wait() + }) +} + +// scriptIdeas scripts ideas_from_ingredients to propose names, in order; +// a recipe grounds those in grounds, the others ground none, and search +// fails as fail says. It returns the router's fakes. +func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) { + t.Helper() + model, normalize := &llm.Fake{}, &llm.Fake{} + search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail} + pages := &recipestest.Pages{Bodies: map[string][]byte{}} + images := &recipestest.Images{JPEGs: map[string][]byte{}} + for i, name := range grounds { + page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i) + search.Results[name] = []recipes.Candidate{{URL: page}} + pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25) + images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1)) + normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + } + list := make([]string, len(names)) + for i, name := range names { + list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name) + } + js := `{"ideas":[` + strings.Join(list, ",") + `]}` + model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) + return model, normalize, search, pages, images +} + +// startIdeasJob posts a request for ideas from rice and returns the job id. +func startIdeasJob(t *testing.T, h http.Handler) string { + t.Helper() + rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + return body["jobId"].(string) +} + +// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed +// with code and a result showing no ideas. +func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) { + t.Helper() + if job["status"] != "failed" || job["error"] != code { + t.Fatalf("GET job = %v, want failed with %s", job, code) + } + res, _ := job["result"].(map[string]any) + if res == nil { + t.Fatalf("failed job result = %v, want one showing no ideas", job["result"]) + } + if list, ok := res["ideas"].([]any); !ok || len(list) != 0 { + t.Fatalf("failed job ideas = %v, want none", res["ideas"]) + } +} + +// storedIdeas is how many ideas job id's stored result holds. +func storedIdeas(t *testing.T, st *store.Store, id string) int { + t.Helper() + row, err := db.New(st.Reader).GetJob(t.Context(), id) + if err != nil { + t.Fatal(err) + } + var res ideas.Result + if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil { + t.Fatal(err) + } + return len(res.Ideas) +} + +func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) { + t.Parallel() + h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false) + + for _, tc := range []struct{ body, message string }{ + {`{"quick_only": false}`, "ingredients: required"}, + {`{"ingredients": ["rice"]}`, "quick_only: required"}, + {`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"}, + {`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"}, + {`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`}, + {`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"}, + {`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`}, + } { + rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" { + t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body) + continue + } + if msg := body["error"].(map[string]any)["message"]; msg != tc.message { + t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message) + } + } +} + +func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) { + t.Parallel() + model, normalize := &llm.Fake{}, &llm.Fake{} + search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}} + pages := &recipestest.Pages{Bodies: map[string][]byte{ + "https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25), + }} + images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}} + js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}` + model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) + normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + + rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + id, _ := body["jobId"].(string) + if id == "" || len(body) != 1 { + t.Fatalf("POST answered %v, want only a jobId", body) + } + + deadline := time.Now().Add(10 * time.Second) + for { + job, err := runner.Get(t.Context(), id) + 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) + } + rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "") + if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" { + t.Fatalf("GET job = %d %v", rec.Code, job) + } + res := job["result"].(map[string]any) + if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 { + t.Fatalf("result = %v", res) + } + list := res["ideas"].([]any) + if len(list) != 1 { + t.Fatalf("ideas = %v, want 1", list) + } + got := list[0].(map[string]any) + for key, want := range map[string]any{ + "name": "Chicken Fried Rice", + "summary": "Uses 2 of 3 · missing: 2 eggs", + "method": "stovetop", + "minutes_total": 25.0, + "quick": true, + "site_name": "R Kitchen", + "source_url": "https://r.example/fried-rice", + } { + if got[key] != want { + t.Errorf("idea %s = %v, want %v", key, got[key], want) + } + } + if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` { + t.Errorf("idea uses = %s, want the household's two listed ingredients", uses) + } + if got["recipe_id"] == "" || got["photo_url"] == "" { + t.Errorf("idea = %v, want a recipe and photo", got) + } +} + +func TestFailedIdeasJobShowsNoIdeas(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + // grounds are the ideas a recipe grounds; the others ground none. + grounds []string + ideas []string + fail map[string]error + code string + }{ + {"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"}, + {"a later round fails after ideas were shown", + []string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"}, + []string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"}, + map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail) + h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code) + }) + } +} + +// A job left running with ideas shown is failed at the next start as +// interrupted, without its handler running again; it still shows no ideas. +func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) { + t.Parallel() + h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false) + id := startIdeasJob(t, h) + + // The previous process claimed the job and showed an idea, then stopped. + q := db.New(st.Writer) + now := store.Now().String() + if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil { + t.Fatal(err) + } + partial, err := ideas.Result{ + Stage: ideas.StageGrounding, + Ingredients: []string{"rice"}, + Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}}, + }.PublicJSON() + if err != nil { + t.Fatal(err) + } + if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{ + Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id, + }); err != nil || n != 1 { + t.Fatalf("publish partial result = %d, %v", n, err) + } + + startRunner(t, runner) + _, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "") + assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted) + if res := job["result"].(map[string]any); res["stage"] != "grounding" { + t.Errorf("result = %v, want the rest of the partial result kept", res) + } + if n := storedIdeas(t, st, id); n != 1 { + t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n) + } +} + +// When the write clearing a failed job's ideas itself fails, the job fails +// with the runner's internal_error and its stored result keeps the ideas; +// it still shows none. +func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := scriptIdeas(t, + []string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"}, + []string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"}, + map[string]error{"Rice Pilaf": errors.New("boom")}) + h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false) + // Refuse any write that empties a running job's shown ideas. + if _, err := st.Writer.ExecContext(t.Context(), ` + CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs + WHEN NEW.status = 'running' + AND json_array_length(OLD.result, '$.ideas') > 0 + AND json_array_length(NEW.result, '$.ideas') = 0 + BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil { + t.Fatal(err) + } + startRunner(t, runner) + + id := startIdeasJob(t, h) + assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal) + if n := storedIdeas(t, st, id); n != 4 { + t.Fatalf("stored ideas = %d, want the 4 the refused write left", n) + } +} + +func TestJobsEndpointRefusesIdeasKind(t *testing.T) { + t.Parallel() + h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false) + rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`) + if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" { + t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body) + } +} diff --git a/internal/server/login_test.go b/internal/server/login_test.go index 9fa481a..dffa26a 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index c95c55a..6859164 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index a141938..34cd573 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/plans_test.go b/internal/server/plans_test.go index 5d6ed74..1cf8ea3 100644 --- a/internal/server/plans_test.go +++ b/internal/server/plans_test.go @@ -94,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv { t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/quickideas_test.go b/internal/server/quickideas_test.go new file mode 100644 index 0000000..61fce61 --- /dev/null +++ b/internal/server/quickideas_test.go @@ -0,0 +1,181 @@ +package server + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" +) + +const quickPath = "/api/v1/ideas/quick" + +// quickRecipe makes the search for name find a page titled title with a +// total time of minutes, and scripts the normalize_recipe reply grounding +// it takes. "Slow Cooker" in title grounds the method as crockpot. +func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) { + t.Helper() + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + search.Results[name] = []recipes.Candidate{{URL: page}} + pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes) + images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1)) + normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes)) +} + +// quickReply scripts one quick_ideas reply holding the ideas named, each +// searched by its name. +func quickReply(model *llm.Fake, ideas ...[3]any) { + list := make([]string, len(ideas)) + for i, idea := range ideas { + list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0]) + } + js := `{"ideas":[` + strings.Join(list, ",") + `]}` + model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) { + return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}}, + &recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}} +} + +func TestPostIdeasQuickRefusesBadRequests(t *testing.T) { + t.Parallel() + h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false) + + for _, tc := range []struct{ body, message string }{ + {`{"exclude": []}`, "kind: required"}, + {`{"kind": "crockpot"}`, "exclude: required"}, + {`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`}, + {`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`}, + {`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`}, + {`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"}, + {`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`}, + {`{"kind": 30, "exclude": []}`, "kind: want a JSON string"}, + {`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`}, + } { + rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body) + if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" { + t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body) + continue + } + if msg := body["error"].(map[string]any)["message"]; msg != tc.message { + t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message) + } + } +} + +// The job answers 202 at once and its result fills idea by idea: one +// idea is shown while another still grounds. The kind is checked against +// the grounded recipe and excluded names never come back. +func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25) + quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45) + quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30) + gate := make(chan struct{}) + search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate} + quickReply(model, + [3]any{"Easy Shrimp Tacos", "stovetop", 15}, + [3]any{"Chicken Fried Rice", "stovetop", 20}, + [3]any{"Beef Stroganoff", "stovetop", 25}, + [3]any{"Garlic Shrimp Pasta", "stovetop", 20}, + ) + h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + id, _ := body["jobId"].(string) + if id == "" || len(body) != 1 { + t.Fatalf("POST answered %v, want only a jobId", body) + } + + // While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows. + deadline := time.Now().Add(10 * time.Second) + for { + _, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "") + if res, _ := job["result"].(map[string]any); res != nil { + if list, _ := res["ideas"].([]any); len(list) == 1 { + if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" { + t.Fatalf("partial job = %v", job) + } + break + } + } + if time.Now().After(deadline) { + t.Fatal("no idea was shown while another still grounded") + } + time.Sleep(5 * time.Millisecond) + } + close(gate) + + job := waitJob(t, h, id) + if job["status"] != "succeeded" || job["kind"] != "quick_ideas" { + t.Fatalf("GET job = %v", job) + } + res := job["result"].(map[string]any) + if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 { + t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res) + } + if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` { + t.Errorf("result exclude = %s", ex) + } + var names []string + for _, i := range res["ideas"].([]any) { + names = append(names, i.(map[string]any)["name"].(string)) + } + if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) { + t.Fatalf("ideas = %q, want %q", names, want) + } + got := res["ideas"].([]any)[1].(map[string]any) + for key, want := range map[string]any{ + "method": "stovetop", + "minutes_total": 30.0, + "site_name": "R Kitchen", + "source_url": "https://r.example/garlic-shrimp-pasta", + } { + if got[key] != want { + t.Errorf("idea %s = %v, want %v", key, got[key], want) + } + } + if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil { + t.Errorf("idea = %v, want a recipe, photo and difficulty", got) + } +} + +func TestFailedQuickJobShowsNoIdeas(t *testing.T) { + t.Parallel() + model, normalize, search, pages, images := newQuickFakes() + quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240) + search.Fail = map[string]error{"Lentil Soup": errors.New("boom")} + ideas := [][3]any{{"Chicken Chili", "crockpot", 240}} + for i := range 5 { + ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300}) + } + quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...) + h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true) + rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`) + if rec.Code != http.StatusAccepted { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed") +} + +func TestJobsEndpointRefusesQuickKind(t *testing.T) { + t.Parallel() + h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false) + rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`) + if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" { + t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body) + } +} diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index 126a67e..ebe5dae 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 931c1e3..5ec718c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -16,6 +16,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -55,12 +56,15 @@ type RouteMounter interface { // /api/v1/shopping/items/{id}; googleSvc serves GET // /api/v1/integrations/google and its /connect, /callback and /calendars, // PUT /api/v1/integrations/google/calendar and DELETE -// /api/v1/integrations/google; runner serves +// /api/v1/integrations/google; ideasSvc serves POST +// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with +// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with +// planSvc too, /cook-tonight; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -85,6 +89,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if googleSvc == nil { return nil, errors.New("server: nil Google Calendar service") } + if ideasSvc == nil { + return nil, errors.New("server: nil ideas service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -158,6 +165,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc)) apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc)) apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc)) + apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc)) + apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc)) + apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc)) + apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index c735e54..859342f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -25,6 +25,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" @@ -54,7 +55,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -190,6 +191,30 @@ func newTestSuggest(t *testing.T) *suggest.Service { return svc } +// newTestIdeas returns an ideas service with its own unstarted job runner +// and generators that always fail. +func newTestIdeas(t *testing.T) *ideas.Service { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) { + return skills.Ideas{}, errors.New("no model in this test") + } + failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) { + return skills.QuickIdeaList{}, errors.New("no model in this test") + } + svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger) + if err != nil { + t.Fatal(err) + } + return svc +} + // newTestPlan returns a plan service over a migrated temp database, with // its own unstarted job runner and a drafter that always fails. func newTestPlan(t *testing.T) *plan.Service { @@ -287,7 +312,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -296,7 +321,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -305,7 +330,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -314,7 +339,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -323,7 +348,7 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } @@ -332,7 +357,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil suggestions service") } } @@ -341,7 +366,7 @@ func TestNewRouterRequiresPlan(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil plan service") } } @@ -350,16 +375,25 @@ func TestNewRouterRequiresShopping(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil shopping service") } } +func TestNewRouterRequiresIdeas(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil ideas service") + } +} + func TestNewRouterRequiresGoogle(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil Google Calendar service") } } @@ -368,7 +402,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 45b6d7c..32c421d 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go index 7e4bb5a..b251798 100644 --- a/internal/server/suggestions_test.go +++ b/internal/server/suggestions_test.go @@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S t.Fatal(err) } h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 35fcc2c..e9be839 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -35,6 +35,8 @@ const SCREENS = { '/plan/shopping': true, '/plan/shopping/archive': true, '/discover': true, + '/quick': true, + '/cook-from-pantry': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -205,19 +207,19 @@ describe('App first-run redirect', () => { it('opens the routes once onboarding is finished', async () => { mockViewport(390) stubFetchByPath(SIGNED_IN) - renderAt('/quick') - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + renderAt('/settings/notifications') + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() }) it('reports a household settings failure with a retry instead of rendering the routes', async () => { mockViewport(390) stubFetch(jsonResponse(200, ME), errorResponse(500, 'internal'), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/quick') + renderAt('/settings/notifications') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: internal message') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() screen.getByRole('button', { name: 'Retry' }).click() - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() }) }) @@ -225,12 +227,12 @@ describe('App bootstrap', () => { it('shows the skeleton until /me loads, then the routes', async () => { mockViewport(390) const fetchMock = stubFetch(jsonResponse(200, ME), jsonResponse(200, HOUSEHOLD_SETTINGS)) - renderAt('/quick') + renderAt('/settings/notifications') expect(screen.getByRole('main', { name: 'Loading MealQuest' })).toHaveAttribute('aria-busy', 'true') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(request(fetchMock, 0)).toMatchObject({ path: '/api/v1/me', method: 'GET', credentials: 'same-origin' }) }) @@ -298,7 +300,7 @@ describe('App offline bootstrap', () => { }) it.each([ - ['/quick', 'Quick dinners'], + ['/settings/notifications', 'Notifications'], ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() @@ -322,7 +324,7 @@ describe('App offline bootstrap', () => { () => new Promise((_, reject) => (failMe = () => reject(new TypeError('Failed to fetch')))), ).mockRejectedValue(new TypeError('Failed to fetch')), ) - renderAt('/quick') + renderAt('/settings/notifications') await vi.waitFor(() => expect(failMe).toBeDefined()) onLine = false act(() => { @@ -330,7 +332,7 @@ describe('App offline bootstrap', () => { }) act(() => failMe()) - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() expect(screen.queryByRole('main', { name: 'Loading MealQuest' })).not.toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') expect(screen.queryByRole('alert')).not.toBeInTheDocument() @@ -339,15 +341,15 @@ describe('App offline bootstrap', () => { it('opens the saved pages when the server is unreachable but the browser reports online', async () => { await visitOnline() vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) - renderAt('/quick') + renderAt('/settings/notifications') - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') }) it('reports the failure offline when this device never verified a session', async () => { goOffline() - renderAt('/quick') + renderAt('/settings/notifications') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() @@ -356,12 +358,12 @@ describe('App offline bootstrap', () => { it('reports the failure offline after the session was refused', async () => { await visitOnline() stubFetch(errorResponse(401, 'unauthenticated')) - const { location, unmount } = renderAt('/quick') + const { location, unmount } = renderAt('/settings/notifications') await vi.waitFor(() => expect(location.assign).toHaveBeenCalled()) unmount() goOffline() - renderAt('/quick') + renderAt('/settings/notifications') expect(await screen.findByRole('alert')).toHaveTextContent('MealQuest could not load: Failed to fetch') expect(screen.queryByRole('navigation', { name: 'Primary' })).not.toBeInTheDocument() }) @@ -369,13 +371,13 @@ describe('App offline bootstrap', () => { it('loads /me again on reconnect and refuses writes until it has', async () => { await visitOnline() goOffline() - const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage)) + const api = createApiClient(fakeLocation('/settings/notifications'), createNetworkStatus(window), createVerifiedSession(localStorage)) render( - + , ) - expect(await screen.findByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(await screen.findByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() await expect(api.apiFetch('/api/v1/plans', { method: 'POST', body: {} })).rejects.toThrow(/no CSRF token/) const fetchMock = stubFetchByPath(SIGNED_IN) @@ -390,7 +392,7 @@ describe('App offline bootstrap', () => { ]), ) await vi.waitFor(() => expect(screen.queryByRole('status')).toBeNull()) - expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Notifications' })).toBeInTheDocument() }) it('/settings/calendar renders the Google Calendar screen inside the shell', async () => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 59c2b85..8fb3ea3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,6 +4,8 @@ import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' +import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' +import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' @@ -45,6 +47,8 @@ const screens: Partial> = { '/plan/shopping': , '/plan/shopping/archive': , '/discover': , + '/quick': , + '/cook-from-pantry': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/ideas/CookFromPantryPage.module.css b/web/src/ideas/CookFromPantryPage.module.css new file mode 100644 index 0000000..fb6465e --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.module.css @@ -0,0 +1,226 @@ +/* Cook from what I have, handoff mock 1i. */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 12px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.1; +} + +/* The ingredient chip field: chips, then the trailing input, wrapping. */ +.field { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 4px; + padding: 12px; + border-radius: 26px; + background: var(--color-surface); + cursor: text; +} + +.field:focus-within { + box-shadow: inset 0 0 0 2px var(--color-accent-300); +} + +.chips { + display: contents; + margin: 0; + padding: 0; + list-style: none; +} + +.chip { + background: var(--color-bg); + color: var(--color-text); + font-size: 13px; +} + +.entry { + flex: 1 1 120px; + min-width: 120px; +} + +.input { + width: 100%; + height: 44px; + padding: 0 8px; + border: 0; + background: transparent; + font-size: 15px; +} + +.input:focus { + outline: none; +} + +.input::placeholder { + color: var(--color-neutral-600); +} + +.notice { + margin: 0 4px; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + color: var(--color-accent-700); +} + +.controls { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; +} + +/* Any time | Quick only shares the row with Find ideas, as in the mock. */ +.when { + flex: none; +} + +.when > button { + padding: 0 12px; + font-size: 13px; + white-space: nowrap; +} + +.find { + padding: 0 18px; + white-space: nowrap; +} + +.results { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 8px; +} + +.count { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px; + border-radius: 24px; +} + +.cardHead { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 17px; + font-weight: 400; + line-height: 1.2; +} + +.time { + flex: none; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.summary { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.action { + min-height: 44px; + padding: 0 16px; + font-size: 14px; + white-space: nowrap; +} + +/* An action done: what it did, in place of its button. */ +.done { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 44px; + padding: 0 4px; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + color: var(--color-accent-2-800); +} + +.failure { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-accent-700); +} + +.noPlan { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px 10px; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-text); +} + +/* "Plan this week": a 44 px tall grape link to the Draft board. */ +.planLink { + display: inline-flex; + align-items: center; + min-height: 44px; + font-weight: 800; + color: var(--color-accent-700); + text-decoration: underline; +} + +.planLink:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.skeletonCard { + height: 128px; + border-radius: 24px; +} diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx new file mode 100644 index 0000000..cb892e0 --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -0,0 +1,512 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { dinnerBody } from '../test/dinners.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { CookFromPantryPage } from './CookFromPantryPage.tsx' +import styles from './CookFromPantryPage.module.css' + +const FIND = 'POST /api/v1/ideas/from-ingredients' + +const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic'] + +// idea is a grounded idea as the job's result carries it. +function idea(name: string, summary: string, minutes: number | null = 30) { + return { + name, + uses: INGREDIENTS, + missing: ['1 lime'], + summary, + method: 'stovetop', + minutes_total: minutes, + difficulty: 'easy', + quick: minutes !== null && minutes <= 30, + recipe_id: `r-${name}`, + photo_url: '', + source_url: 'https://recipes.example/x', + site_name: 'Recipes', + } +} + +// The ideas of mock 1i. +const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime') +const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25) +const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion') + +type JobBody = { status: string; result: unknown; error: string | null } + +// running is the ideas_from_ingredients job's state at stage with ideas. +function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody { + return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error } +} + +const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11'] + +// tonightPlan is the week's plan as cook-tonight answers it, the idea's +// dinner on Thursday. +function tonightPlan() { + return { + id: 'plan-1', + week_start: '2026-10-05', + status: 'draft', + ai_note: null, + calendar_synced_at: null, + days: DAYS.map((date, i) => ({ + date, + dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' }, + is_new: i === 3, + start_by: null, + })), + updated_at: '2026-10-08T15:00:00Z', + } +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. jobs holds each job's state, which every +// poll of it answers, so a test moves a job on by changing it. +function stubServer(responders: Record) { + const jobs: Record = {} + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key) + if (job !== null && !Object.hasOwn(responders, key)) { + const id = job[1]! + const body = jobs[id] + if (body === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body })) + } + const respond = Object.hasOwn(responders, key) ? responders[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return { fetchMock, jobs } +} + +// calls are the requests sent as "METHOD path", in order. +function calls(fetchMock: ReturnType['fetchMock'], key: string) { + return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key) +} + +function bodyOf(call: Parameters) { + return JSON.parse(String(call[1]?.body)) as unknown +} + +async function renderPage() { + const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + } /> + Plan screen

} /> + Home screen

} /> +
+
+
+
, + ) +} + +function input() { + return screen.getByRole('textbox', { name: 'Add ingredient' }) +} + +function chips() { + return within(screen.getByRole('list', { name: 'Ingredients on hand' })) + .queryAllByRole('listitem') + .map((item) => item.textContent) +} + +function findButton() { + return screen.getByRole('button', { name: 'Find ideas' }) +} + +// enter types each ingredient and presses Enter. +function enter(...ingredients: string[]) { + for (const ingredient of ingredients) { + fireEvent.change(input(), { target: { value: ingredient } }) + fireEvent.keyDown(input(), { key: 'Enter' }) + } +} + +function card(name: string) { + return screen.getByRole('article', { name }) +} + +const SLOW = { timeout: 3000 } + +// searched lists the mock's ingredients, presses Find ideas and waits for +// the job's first poll. +async function searched(fetchMock: ReturnType['fetchMock'], jobId = 'j1') { + enter(...INGREDIENTS) + fireEvent.click(findButton()) + await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0)) +} + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('CookFromPantryPage', () => { + it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => { + stubServer({}) + await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument() + expect(input()).toHaveAttribute('placeholder', 'Add ingredient…') + expect(input().closest(`.${styles.field}`)).not.toBeNull() + const filter = screen.getByRole('radiogroup', { name: 'Which dinners' }) + expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked() + expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked() + expect(findButton()).toBeDisabled() + + fireEvent.click(screen.getByRole('button', { name: 'Back to Home' })) + expect(screen.getByText('Home screen')).toBeInTheDocument() + }) + + describe('chip entry', () => { + it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => { + stubServer({}) + await renderPage() + + enter('Chicken thighs') + expect(chips()).toEqual(['Chicken thighs']) + expect(input()).toHaveValue('') + expect(findButton()).toBeEnabled() + + fireEvent.change(input(), { target: { value: 'Rice,' } }) + expect(chips()).toEqual(['Chicken thighs', 'Rice']) + expect(input()).toHaveValue('') + + // Pasted text is split at its commas; what follows the last is still + // being typed. + fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } }) + expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk']) + expect(input()).toHaveValue(' Gar') + + // Enter on nothing adds nothing. + fireEvent.change(input(), { target: { value: ' ' } }) + fireEvent.keyDown(input(), { key: 'Enter' }) + expect(chips()).toHaveLength(4) + }) + + it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => { + stubServer({}) + await renderPage() + enter('Chicken thighs', 'Rice', 'Spinach') + + fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' })) + expect(chips()).toEqual(['Chicken thighs', 'Spinach']) + expect(input()).toHaveFocus() + + fireEvent.keyDown(input(), { key: 'Backspace' }) + expect(chips()).toEqual(['Chicken thighs']) + + fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' })) + expect(chips()).toEqual([]) + expect(findButton()).toBeDisabled() + }) + + it('says why it leaves out a repeated, overlong or 21st ingredient', async () => { + stubServer({}) + await renderPage() + enter('Rice', ' rice ') + expect(chips()).toEqual(['Rice']) + expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.') + + enter('x'.repeat(81)) + expect(chips()).toEqual(['Rice']) + expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.') + + enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`)) + expect(chips()).toHaveLength(20) + expect(screen.queryByRole('status')).toBeNull() + enter('One too many') + expect(chips()).toHaveLength(20) + expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.') + }) + }) + + it('fills the results progressively as the server grounds ideas, then enables the actions', async () => { + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + jobs.j1 = state('running', 'suggesting', []) + await renderPage() + + enter(...INGREDIENTS) + fireEvent.change(input(), { target: { value: 'lime' } }) + fireEvent.click(findButton()) + // What is still typed is listed before the search. + expect(chips()).toEqual([...INGREDIENTS, 'lime']) + await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1)) + expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false }) + + // Suggesting: five skeleton cards, no idea yet. + expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5) + expect(screen.queryAllByRole('article')).toHaveLength(0) + + // Grounding: the idea grounded so far, and skeletons for the rest; its + // actions wait for the job to end. + jobs.j1 = state('running', 'grounding', [CURRY]) + await screen.findByRole('article', { name: CURRY.name }, SLOW) + expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4) + expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled() + expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled() + + jobs.j1 = state('running', 'grounding', [CURRY, BOWLS]) + await screen.findByRole('article', { name: BOWLS.name }, SLOW) + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + + jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN]) + await screen.findByText('3 ideas', {}, SLOW) + expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0) + expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name]) + const curry = within(card(CURRY.name)) + // The server's summary line, as it is. + expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!) + expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min') + expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min') + expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled() + expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled() + }) + + it('leaves the time off an idea whose recipe does not say it', async () => { + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)]) + await renderPage() + await searched(fetchMock) + const fried = await screen.findByRole('article', { name: 'Fried rice' }) + expect(within(fried).queryByText(/min/)).toBeNull() + }) + + describe('Any time | Quick only', () => { + it('sends quick_only, and asks again when changed after a search', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) }) + jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true) + jobs.j2 = state('succeeded', 'done', [ONE_PAN], false) + await renderPage() + + // Before a search it only sets what Find ideas asks for. + fireEvent.click(screen.getByRole('radio', { name: 'Quick only' })) + expect(calls(fetchMock, FIND)).toHaveLength(0) + + await searched(fetchMock, 'j1') + expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true }) + await screen.findByText('2 ideas') + + fireEvent.click(screen.getByRole('radio', { name: 'Any time' })) + await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2)) + expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false }) + await screen.findByText('1 idea') + expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name]) + }) + + it('refuses a result that is not for the filter asked for', async () => { + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + jobs.j1 = state('succeeded', 'done', [CURRY], true) + await renderPage() + await searched(fetchMock) + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.") + expect(screen.queryAllByRole('article')).toHaveLength(0) + }) + }) + + describe('Add to Quest log', () => { + it('adds the idea and says so on its card', async () => { + const added = Promise.withResolvers() + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise, + }) + jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS]) + await renderPage() + await searched(fetchMock) + await screen.findByText('2 ideas') + + const bowls = within(card(BOWLS.name)) + fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' })) + expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled() + added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' }))) + expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log') + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1) + // The other card is untouched. + expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled() + }) + + it('shows its failure on its card and lets it be pressed again', async () => { + let fail = true + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/quest-log': () => + fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message") + + fail = false + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log') + expect(curry.queryByRole('alert')).toBeNull() + }) + }) + + describe('Cook tonight', () => { + it("puts the idea on tonight's plan", async () => { + const planned = Promise.withResolvers() + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise, + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' })) + expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled() + planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() })) + expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan") + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1) + }) + + it('offers Plan this week, not an error, when the week has no plan', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' })) + const link = await curry.findByRole('link', { name: 'Plan this week' }) + expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet') + expect(screen.queryByRole('alert')).toBeNull() + fireEvent.click(link) + expect(screen.getByText('Plan screen')).toBeInTheDocument() + }) + + it('shows any other failure on its card', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' })) + expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message") + expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull() + expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled() + }) + }) + + describe('errors', () => { + it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ + [FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + enter(...INGREDIENTS) + fireEvent.click(findButton()) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: CURRY.name }) + expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false }) + }) + + it('shows a failed job with no cards, and Retry searches again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) }) + jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed') + jobs.j2 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.") + expect(screen.queryAllByRole('article')).toHaveLength(0) + expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: CURRY.name }) + expect(calls(fetchMock, FIND)).toHaveLength(2) + }) + + it.each([ + ['search_not_configured', 'Recipe search is not set up.'], + ['search_failed', "The recipe search couldn't be reached."], + ])('explains %s', async (code, message) => { + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + jobs.j1 = state('failed', 'grounding', [], false, code) + await renderPage() + await searched(fetchMock) + expect(await screen.findByRole('alert')).toHaveTextContent(message) + }) + + it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) }) + jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found') + jobs.j2 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.") + expect(screen.queryAllByRole('article')).toHaveLength(0) + expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0) + expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: CURRY.name }) + expect(calls(fetchMock, FIND)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }), + 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'), + }) + jobs.j2 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: CURRY.name }) + expect(calls(fetchMock, FIND)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + }) +}) diff --git a/web/src/ideas/CookFromPantryPage.tsx b/web/src/ideas/CookFromPantryPage.tsx new file mode 100644 index 0000000..e8d1dc1 --- /dev/null +++ b/web/src/ideas/CookFromPantryPage.tsx @@ -0,0 +1,312 @@ +import { Check, ChevronLeft } from 'lucide-react' +import { useMemo, useRef, useState, type KeyboardEvent } from 'react' +import { Link, useNavigate } from 'react-router' +import { ApiError, useJob } from '../api/index.ts' +import { duration } from '../plan/PlanPage.tsx' +import { Button } from '../ui/Button.tsx' +import { Card } from '../ui/Card.tsx' +import { Chip } from '../ui/Chip.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { TextInput } from '../ui/TextInput.tsx' +import { + FROM_INGREDIENTS_JOB_KIND, + IDEAS_KEPT, + ideasJobFailure, + ideasJobLost, + INGREDIENT_LIMIT, + MAX_INGREDIENTS, + parseIdeasResult, + useAddIdeaToQuestLog, + useCookIdeaTonight, + useFindIdeas, + type Idea, + type IdeaRef, + type IdeaStage, + type Search, +} from './api.ts' +import styles from './CookFromPantryPage.module.css' + +type When = 'any' | 'quick' + +const WHEN = [ + { value: 'any', label: 'Any time' }, + { value: 'quick', label: 'Quick only' }, +] as const satisfies readonly { value: When; label: string }[] + +// findingLines says what a running search is doing, by its stage. +const findingLines: Record, string> = { + suggesting: 'Thinking up ideas…', + grounding: 'Finding recipes for them…', +} + +// fold is an ingredient as the chips compare it, so "Rice" repeats " rice". +function fold(ingredient: string): string { + return ingredient.trim().replace(/\s+/g, ' ').toLowerCase() +} + +// IdeaCard is one idea (mock 1i): its name and time, the server's line on +// what it uses and what is missing, and its two actions, each showing its +// own progress and failure. ref is the idea in its job, null while the job +// runs: the server takes an idea only once the job has succeeded, as the +// ideas may still be reordered until then. +function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) { + const questLog = useAddIdeaToQuestLog() + const tonight = useCookIdeaTonight() + const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan' + return ( + +
+

{idea.name}

+ {idea.minutes_total !== null && {duration(idea.minutes_total)}} +
+

{idea.summary}

+
+ {tonight.isSuccess ? ( + + On tonight's plan + + ) : ( + + )} + {questLog.isSuccess ? ( + + In the Quest log + + ) : ( + + )} +
+ {noPlan ? ( +
+ This week has no plan yet, so there's no tonight to put it on. + + Plan this week + +
+ ) : ( + tonight.isError && ( +

+ Couldn't put it on tonight's plan. {tonight.error.message} +

+ ) + )} + {questLog.isError && ( +

+ Couldn't add it to the Quest log. {questLog.error.message} +

+ )} +
+ ) +} + +// Skeletons stand in for the count ideas still to come. +function Skeletons({ count }: { count: number }) { + return Array.from({ length: count }, (_, i) => ) +} + +// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on +// hand as chips (Enter or a comma adds one), Any time | Quick only, and +// Find ideas, which starts an ideas_from_ingredients job and shows its +// ideas as the server grounds them, skeleton cards holding the places of +// those still to come. Changing Any time | Quick only after a search asks +// again: the server, not the screen, says what is quick. +export function CookFromPantryPage() { + const navigate = useNavigate() + const inputRef = useRef(null) + const [ingredients, setIngredients] = useState([]) + const [draft, setDraft] = useState('') + const [notice, setNotice] = useState(null) + const [when, setWhen] = useState('any') + // The search last asked for, which Retry asks again, and its job. + const [asked, setAsked] = useState(null) + const [jobId, setJobId] = useState(null) + const findIdeas = useFindIdeas() + const job = useJob(FROM_INGREDIENTS_JOB_KIND, jobId) + + // add lists entries after the chips there are, saying why it leaves out + // any it cannot list: the server would refuse a blank, overlong or + // repeated ingredient, or one past the most it takes. + function add(list: string[], entries: string[]): string[] { + const next = [...list] + for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) { + if (entry === '') continue + if (next.some((ingredient) => fold(ingredient) === fold(entry))) { + setNotice(`${entry} is listed already.`) + } else if (entry.length > INGREDIENT_LIMIT) { + setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`) + } else if (next.length === MAX_INGREDIENTS) { + setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`) + } else { + next.push(entry) + } + } + return next + } + + function onDraftChange(value: string) { + setNotice(null) + // A comma ends an ingredient, also within pasted text. + const parts = value.split(',') + const rest = parts.pop()! + if (parts.length > 0) setIngredients(add(ingredients, parts)) + setDraft(rest) + } + + function onDraftKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault() + setNotice(null) + setIngredients(add(ingredients, [draft])) + setDraft('') + } else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) { + setNotice(null) + setIngredients(ingredients.slice(0, -1)) + } + } + + // search asks for ideas. Only the latest search's job is followed: mutate + // calls back for the latest call only. + function search(next: Search) { + setAsked(next) + setJobId(null) + findIdeas.mutate(next, { onSuccess: setJobId }) + } + + // find lists what is still typed, then searches the chips. + function find() { + setNotice(null) + const list = add(ingredients, [draft]) + setIngredients(list) + setDraft('') + if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' }) + } + + function onWhenChange(value: When) { + setWhen(value) + if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' }) + } + + const retry = () => search(asked!) + + // The job's result as far as it has gone, or why it cannot be read. + const found = useMemo(() => { + if (job.result === null || asked === null) return { result: null, error: null } + try { + return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null } + } catch (error) { + return { result: null, error: error as Error } + } + }, [job.result, jobId, asked]) + + let results = null + if (findIdeas.isPending) { + results = ( +
+

{findingLines.suggesting}

+ +
+ ) + } else if (findIdeas.isError) { + results = Couldn't start looking for ideas. {findIdeas.error.message} + } else if (jobId !== null && job.status === 'failed') { + results = Couldn't find ideas. {ideasJobFailure(job.error!.message)} + } else if (found.error !== null) { + results = Couldn't read the ideas. {found.error.message} + } else if (jobId !== null) { + const ideas = found.result?.ideas ?? [] + const done = job.status === 'succeeded' + // A job not found will not be found by asking again: Retry searches + // again. Any other failed poll is asked again. + const lost = ideasJobLost(job.error) + results = ( + <> + {job.error !== null && ( + Couldn't check on the ideas. {job.error.message} + )} +
+

+ {done + ? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}` + : findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']} +

+ {ideas.map((idea, index) => ( + + ))} + {!done && } +
+ + ) + } + + return ( +
+
+ void navigate('/')}> + + +

Cook from what I have

+
+ {/* A tap anywhere on the field types in it. */} +
inputRef.current?.focus()}> +
    + {ingredients.map((ingredient, i) => ( +
  • + { + setNotice(null) + setIngredients(ingredients.filter((_, j) => j !== i)) + inputRef.current?.focus() + }} + /> +
  • + ))} +
+
+ onDraftChange(event.target.value)} + onKeyDown={onDraftKeyDown} + /> +
+
+ {notice !== null && ( +

+ {notice} +

+ )} +
+ + +
+ {results} +
+ ) +} diff --git a/web/src/ideas/QuickDinnersPage.module.css b/web/src/ideas/QuickDinnersPage.module.css new file mode 100644 index 0000000..071709f --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.module.css @@ -0,0 +1,166 @@ +/* Quick dinners, handoff mock 1j. */ +.page { + box-sizing: border-box; + max-width: 560px; + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 6px; +} + +.header { + display: flex; + align-items: center; + gap: 10px; +} + +/* The 36 px grape circle holding the zap. */ +.zap { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: var(--radius-pill); + background: var(--color-accent); + color: var(--on-accent); +} + +.zapIcon { + width: 18px; + height: 18px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.1; +} + +.panel { + display: flex; + flex-direction: column; + gap: 14px; +} + +.helper { + margin: 0; + font-family: var(--font-body); + font-size: 13px; + color: var(--color-neutral-800); +} + +.rows { + display: flex; + flex-direction: column; + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + border-radius: 24px; + background: var(--color-neutral-100); +} + +.photo { + flex: none; + width: 64px; + height: 64px; +} + +.text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 16px; + font-weight: 400; + line-height: 1.15; +} + +.meta { + font-family: var(--font-body); + font-size: 12px; + color: var(--color-neutral-700); +} + +.failure { + margin: 0; + font-family: var(--font-body); + font-size: 12px; + color: var(--color-accent-700); +} + +/* The teal "+": a 36 px circle in the IconButton's 44 px hit box (scoped + to the rows to outrank the IconButton's 40 px face). */ +.rows .add > span { + width: 36px; + height: 36px; +} + +/* An idea added: a check in the "+"'s place, of the same size. */ +.added { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin: 4px; + border-radius: var(--radius-pill); + background: var(--color-accent-2-100); + color: var(--color-accent-2-800); +} + +/* "↻ Show me more": a grape-700 text button, 44 px tall. */ +.more { + align-self: center; + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 44px; + min-height: 44px; + padding: 0 12px; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + color: var(--color-accent-700); + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.more:focus-visible { + outline: 3px solid var(--color-accent); + outline-offset: 2px; +} + +.end { + margin: 0; + text-align: center; + font-family: var(--font-body); + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.skeletonRow { + height: 84px; + border-radius: 24px; +} diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx new file mode 100644 index 0000000..891e13e --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -0,0 +1,514 @@ +import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { declaredStyle } from '../test/cssRules.ts' +import { dinnerBody } from '../test/dinners.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { QuickDinnersPage } from './QuickDinnersPage.tsx' +import styles from './QuickDinnersPage.module.css' + +const QUICK = 'POST /api/v1/ideas/quick' + +// idea is a grounded Quick dinner idea as the job's result carries it. +function idea(name: string, method: string, minutes: number | null, difficulty: string) { + return { + name, + method, + minutes_total: minutes, + difficulty, + quick: true, + recipe_id: `r-${name}`, + photo_url: `/media/photos/p-${name.length}-400.jpg`, + source_url: 'https://recipes.example/x', + site_name: 'Recipes', + } +} + +// The ideas of mock 1j, and more for later pages and the Crock pot tab. +const ORZO = idea('Lemon garlic shrimp orzo', 'stovetop', 25, 'medium') +const QUESADILLAS = idea('Chicken quesadillas', 'stovetop', 20, 'easy') +const BROCCOLI = idea('Beef & broccoli', 'stovetop', 30, 'easy') +const GNOCCHI = idea('Pesto gnocchi & peas', 'stovetop', 15, 'easy') +const CHILI = idea('Crock pot chili', 'crockpot', 360, 'easy') +const CARNITAS = idea('Pork carnitas', 'crockpot', null, 'medium') + +type JobBody = { status: string; result: unknown; error: string | null } + +// state is a quick_ideas job's state at stage with ideas. +function state(status: string, stage: string, kind: string, ideas: unknown[], error: string | null = null): JobBody { + return { status, result: { stage, kind, exclude: [], ideas, dropped: 0 }, error } +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me; any +// other request fails the test. jobs holds each job's state, which every +// poll of it answers, so a test moves a job on by changing it. +function stubServer(responders: Record) { + const jobs: Record = {} + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME)) + const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key) + if (job !== null && !Object.hasOwn(responders, key)) { + const id = job[1]! + const body = jobs[id] + if (body === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(jsonResponse(200, { id, kind: 'quick_ideas', ...body })) + } + const respond = Object.hasOwn(responders, key) ? responders[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return { fetchMock, jobs } +} + +// quickServer answers each POST /ideas/quick with the next of jobIds. +function quickServer(jobIds: string[], responders: Record = {}) { + let n = 0 + return stubServer({ + [QUICK]: () => { + const jobId = jobIds[n++] + if (jobId === undefined) throw new Error('unexpected quick search') + return jsonResponse(202, { jobId }) + }, + ...responders, + }) +} + +// calls are the requests sent as "METHOD path", in order. +function calls(fetchMock: ReturnType['fetchMock'], key: string) { + return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key) +} + +// searches are the POST /ideas/quick bodies sent, in order. +function searches(fetchMock: ReturnType['fetchMock']) { + return calls(fetchMock, QUICK).map((call) => JSON.parse(String(call[1]?.body)) as unknown) +} + +async function renderPage(queryClient: QueryClient = createQueryClient()) { + const api = createApiClient(fakeLocation('/quick'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + return render( + + + + + } /> + + + + , + ) +} + +function tab(name: string) { + return within(screen.getByRole('radiogroup', { name: 'Kind of Quick dinner' })).getByRole('radio', { name }) +} + +function row(name: string) { + return screen.getByRole('listitem', { name }) +} + +function rowNames() { + return within(screen.getByRole('list', { name: 'Quick dinners' })) + .queryAllByRole('heading', { level: 2 }) + .map((heading) => heading.textContent) +} + +function addButton(name: string) { + return within(row(name)).getByRole('button', { name: `Add ${name} to the Quest log` }) +} + +// showMore waits for Show me more, shown once a page is kept. +function showMore() { + return screen.findByRole('button', { name: 'Show me more' }) +} + +const SLOW = { timeout: 3000 } + +beforeEach(() => { + localStorage.clear() +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('QuickDinnersPage', () => { + it('renders mock 1j: zap header, tabs with 30 min or less chosen, its helper, and its first page', async () => { + const { fetchMock, jobs } = quickServer(['j1']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS, BROCCOLI, GNOCCHI]) + const { container } = await renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Quick dinners' })).toBeInTheDocument() + const zap = container.querySelector(`.${styles.zap}`)! + expect(declaredStyle(zap).width).toBe('36px') + expect(declaredStyle(zap).background).toBe('var(--color-accent)') + expect(zap.querySelector('svg')).toHaveAttribute('stroke-width', '2.75') + expect(tab('30 min or less')).toBeChecked() + expect(tab('Crock pot')).not.toBeChecked() + expect(screen.getByText('Prep to table in 30 minutes or less, picked from your tastes.')).toBeInTheDocument() + + await screen.findByRole('listitem', { name: ORZO.name }) + expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name]) + expect(searches(fetchMock)).toEqual([{ kind: 'under30', exclude: [] }]) + + const orzo = within(row(ORZO.name)) + expect(orzo.getByText('25 min · Medium')).toHaveClass(styles.meta!) + expect(orzo.getByRole('img', { name: ORZO.name })).toHaveAttribute('src', '/media/photos/p-24-400.jpg') + expect(declaredStyle(row(ORZO.name)).background).toBe('var(--color-neutral-100)') + expect(declaredStyle(row(ORZO.name)).borderRadius).toBe('24px') + expectMinHitArea(addButton(ORZO.name)) + expect(addButton(ORZO.name)).toBeEnabled() + expectMinHitArea(await showMore()) + expect(declaredStyle(await showMore()).color).toBe('var(--color-accent-700)') + }) + + it('fills the rows progressively as the server grounds ideas, adding only once the page is found', async () => { + const { jobs } = quickServer(['j1']) + jobs.j1 = state('running', 'suggesting', 'under30', []) + await renderPage() + + await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6)) + expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'true') + + jobs.j1 = state('running', 'grounding', 'under30', [ORZO]) + await screen.findByRole('listitem', { name: ORZO.name }, SLOW) + expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5) + expect(addButton(ORZO.name)).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + await screen.findByRole('listitem', { name: QUESADILLAS.name }, SLOW) + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(addButton(ORZO.name)).toBeEnabled() + expect(await showMore()).toBeInTheDocument() + expect(screen.getByRole('list', { name: 'Quick dinners' })).toHaveAttribute('aria-busy', 'false') + }) + + describe('tabs', () => { + it('searches each tab when first shown, keeps what each found, and says what each finds', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(tab('Crock pot')) + expect(tab('Crock pot')).toBeChecked() + expect(screen.getByText(/in the crock pot.*picked from your tastes\./)).toBeInTheDocument() + await screen.findByRole('listitem', { name: CHILI.name }) + expect(rowNames()).toEqual([CHILI.name, CARNITAS.name]) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'crockpot', exclude: [] }, + ]) + + // Each tab's ideas are kept: going back asks for nothing. + fireEvent.click(tab('30 min or less')) + expect(rowNames()).toEqual([ORZO.name]) + fireEvent.click(tab('Crock pot')) + expect(rowNames()).toEqual([CHILI.name, CARNITAS.name]) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + }) + + it('shows a crock-pot idea as "Crock pot · 6 hr · Easy", without a time when the recipe has none', async () => { + const { jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI, CARNITAS]) + await renderPage() + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + + expect(within(row(CHILI.name)).getByText('Crock pot · 6 hr · Easy')).toBeInTheDocument() + expect(within(row(CARNITAS.name)).getByText('Crock pot · Medium')).toBeInTheDocument() + }) + + it('keeps a tab for the session, also when the screen is left and opened again', async () => { + const { fetchMock, jobs } = quickServer(['j1']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + const queryClient = createQueryClient() + const { unmount } = await renderPage(queryClient) + await screen.findByRole('listitem', { name: ORZO.name }) + unmount() + + await renderPage(queryClient) + expect(rowNames()).toEqual([ORZO.name]) + expect(calls(fetchMock, QUICK)).toHaveLength(1) + }) + + it('refuses a result that is not of the tab asked for', async () => { + const { jobs } = quickServer(['j1']) + jobs.j1 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't read the Quick dinners. GET /api/v1/jobs/j1: result is not Quick dinner ideas of kind under30", + ) + expect(screen.queryByRole('listitem', { name: CHILI.name })).toBeNull() + }) + }) + + describe('Add to Quest log', () => { + it('adds the idea with its "+", which becomes a check, kept across tabs', async () => { + const added = Promise.withResolvers() + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + + fireEvent.click(addButton(QUESADILLAS.name)) + await waitFor(() => expect(addButton(QUESADILLAS.name)).toBeDisabled()) + added.resolve(jsonResponse(201, dinnerBody('dn-1', QUESADILLAS.name, { status: 'quest_log', origin: 'quick' }))) + const quesadillas = within(row(QUESADILLAS.name)) + expect(await quesadillas.findByRole('status')).toContainElement(quesadillas.getByRole('img', { name: 'In the Quest log' })) + expect(quesadillas.queryByRole('button')).toBeNull() + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1) + // The other row is untouched. + expect(addButton(ORZO.name)).toBeEnabled() + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + }) + + it('marks an add that settles while another tab is shown', async () => { + const added = Promise.withResolvers() + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(addButton(ORZO.name)) + await waitFor(() => expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1)) + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + added.resolve(jsonResponse(201, dinnerBody('dn-1', ORZO.name, { status: 'quest_log', origin: 'quick' }))) + await waitFor(() => expect(queryClient.isMutating()).toBe(0)) + + fireEvent.click(tab('30 min or less')) + expect(within(row(ORZO.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + expect(addButton(QUESADILLAS.name)).toBeEnabled() + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1) + }) + + it('shows its failure in its row and lets it be pressed again', async () => { + let fail = true + const { jobs } = quickServer(['j1'], { + 'POST /api/v1/ideas/j1/items/0/quest-log': () => + fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', ORZO.name, { status: 'quest_log' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + const orzo = within(row(ORZO.name)) + + fireEvent.click(addButton(ORZO.name)) + expect(await orzo.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message") + + fail = false + fireEvent.click(addButton(ORZO.name)) + expect(await orzo.findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + expect(orzo.queryByRole('alert')).toBeNull() + }) + }) + + describe('Show me more', () => { + it('asks for the same kind excluding every name shown, and adds from the page that found it', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3'], { + 'POST /api/v1/ideas/j2/items/0/quest-log': () => jsonResponse(201, dinnerBody('dn-1', BROCCOLI.name, { status: 'quest_log' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('running', 'grounding', 'under30', [BROCCOLI]) + jobs.j3 = state('succeeded', 'done', 'under30', [GNOCCHI]) + await renderPage() + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + + fireEvent.click(await showMore()) + await screen.findByRole('listitem', { name: BROCCOLI.name }, SLOW) + // The first page stays while the next fills. + expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name]) + expect(screen.getAllByTestId('skeleton-row')).toHaveLength(5) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + + jobs.j2 = state('succeeded', 'done', 'under30', [BROCCOLI]) + await screen.findByRole('button', { name: 'Show me more' }, SLOW) + fireEvent.click(addButton(BROCCOLI.name)) + expect(await within(row(BROCCOLI.name)).findByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() + + fireEvent.click(await showMore()) + await screen.findByRole('listitem', { name: GNOCCHI.name }) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name] }, + { kind: 'under30', exclude: [ORZO.name, QUESADILLAS.name, BROCCOLI.name] }, + ]) + expect(rowNames()).toEqual([ORZO.name, QUESADILLAS.name, BROCCOLI.name, GNOCCHI.name]) + }) + + it('says every idea has been shown when a later page finds none', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('failed', 'grounding', 'under30', [], 'no_ideas_found') + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(await showMore()) + expect(await screen.findByRole('status')).toHaveTextContent("That's every Quick dinner for now.") + expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + expect(rowNames()).toEqual([ORZO.name]) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + }) + + it('shows a later page that fails with the ErrorBanner, keeping the rows, and Retry asks again', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2', 'j3']) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('failed', 'grounding', 'under30', [], 'search_failed') + jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(await showMore()) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find Quick dinners. The recipe search couldn't be reached.") + expect(rowNames()).toEqual([ORZO.name]) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + expect(searches(fetchMock)[2]).toEqual({ kind: 'under30', exclude: [ORZO.name] }) + expect(screen.queryByRole('alert')).toBeNull() + }) + }) + + describe('errors', () => { + it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => { + let n = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: () => (++n === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'under30', exclude: [] }, + ]) + }) + + it('keeps a first page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + return ++under30 === 1 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: 'j1' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(searches(fetchMock)).toEqual([ + { kind: 'under30', exclude: [] }, + { kind: 'crockpot', exclude: [] }, + ]) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(screen.queryByRole('alert')).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + }) + + it('keeps a later page that cannot start across a tab switch, asking again only on Retry', async () => { + let under30 = 0 + const { fetchMock, jobs } = stubServer({ + [QUICK]: (init) => { + const { kind } = JSON.parse(String(init?.body)) as { kind: string } + if (kind === 'crockpot') return jsonResponse(202, { jobId: 'j2' }) + under30++ + return under30 === 2 ? errorResponse(500, 'internal') : jsonResponse(202, { jobId: under30 === 1 ? 'j1' : 'j3' }) + }, + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + jobs.j3 = state('succeeded', 'done', 'under30', [QUESADILLAS]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + fireEvent.click(await showMore()) + await screen.findByRole('alert') + + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't start looking for Quick dinners. internal message") + expect(rowNames()).toEqual([ORZO.name]) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + expect(calls(fetchMock, QUICK)).toHaveLength(3) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: QUESADILLAS.name }) + expect(searches(fetchMock)[3]).toEqual({ kind: 'under30', exclude: [ORZO.name] }) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it.each([ + ['ai_failed', "The AI assistant couldn't come up with ideas."], + ['no_ideas_found', 'No ideas came back with a recipe to match.'], + ['search_not_configured', 'Recipe search is not set up.'], + ['internal_error', 'Something went wrong on the server.'], + ])('shows a first page failed with %s with the ErrorBanner and no rows, and Retry searches again', async (code, message) => { + const { fetchMock, jobs } = quickServer(['j1', 'j2']) + jobs.j1 = state('failed', 'grounding', 'under30', [], code) + jobs.j2 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(`Couldn't find Quick dinners. ${message}`) + expect(screen.queryAllByRole('listitem', { name: /./ })).toHaveLength(0) + expect(screen.queryAllByTestId('skeleton-row')).toHaveLength(0) + expect(screen.queryByRole('button', { name: 'Show me more' })).toBeNull() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found') }) + jobs.j2 = state('succeeded', 'done', 'under30', [ORZO]) + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on the Quick dinners. not_found message") + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('listitem', { name: ORZO.name }) + expect(calls(fetchMock, QUICK)).toHaveLength(2) + expect(screen.queryByRole('alert')).toBeNull() + }) + }) +}) diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx new file mode 100644 index 0000000..5fe4c96 --- /dev/null +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -0,0 +1,261 @@ +import { Check, Plus, RefreshCw, Zap } from 'lucide-react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useIsMutating } from '@tanstack/react-query' +import { useJob } from '../api/index.ts' +import { DIFFICULTY_LABEL } from '../discover/DeckCard.tsx' +import { duration } from '../plan/PlanPage.tsx' +import { methodLabels } from '../settings/RegularsSettingsPage.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { + ideasJobFailure, + ideasJobLost, + MAX_EXCLUDE, + parseQuickResult, + QUICK_IDEAS_KEPT, + QUICK_JOB_KIND, + quickTabKey, + shownNames, + useAddIdeaToQuestLog, + useFindQuickIdeas, + useQuickTab, + type IdeaRef, + type QuickIdea, + type QuickKind, +} from './api.ts' +import styles from './QuickDinnersPage.module.css' + +const TABS = [ + { value: 'crockpot', label: 'Crock pot' }, + { value: 'under30', label: '30 min or less' }, +] as const satisfies readonly { value: QuickKind; label: string }[] + +// helpers says what each tab finds. +const helpers: Record = { + crockpot: 'Set it going in the crock pot and come back to dinner, picked from your tastes.', + under30: 'Prep to table in 30 minutes or less, picked from your tastes.', +} + +// The server's code for a search that found nothing: on a later page, it +// means the tab has shown every idea there is. +const NO_IDEAS_FOUND = 'no_ideas_found' + +// meta is a row's "25 min · Medium" line: "Crock pot" first for a crock-pot +// idea, and the time only when the recipe says it. +function meta(idea: QuickIdea): string { + return [ + idea.method === 'crockpot' && methodLabels.crockpot, + idea.minutes_total !== null && duration(idea.minutes_total), + DIFFICULTY_LABEL[idea.difficulty], + ] + .filter((part): part is string => typeof part === 'string') + .join(' · ') +} + +// QuickRow is one idea (mock 1j): its washed photo, name and meta line, and +// the teal "+" that adds it to the Quest log, a check once it is there. +// ideaRef is null while its job runs: the server takes an idea only once +// the job has succeeded. onAdded marks the idea added, also if the row has +// unmounted before the add settles. +function QuickRow({ + idea, + ideaRef, + added, + onAdded, +}: { + idea: QuickIdea + ideaRef: IdeaRef | null + added: boolean + onAdded: (idea: IdeaRef) => void +}) { + const questLog = useAddIdeaToQuestLog(onAdded) + return ( +
  • + +
    +

    {idea.name}

    + {meta(idea)} + {questLog.isError && ( +

    + Couldn't add it to the Quest log. {questLog.error.message} +

    + )} +
    + {added ? ( + + + + ) : ( + questLog.mutate(ideaRef!)} + > + + + )} +
  • + ) +} + +// Skeletons stand in for the count ideas still to come. +function Skeletons({ count }: { count: number }) { + return Array.from({ length: count }, (_, i) => ( + + )) +} + +// QuickTabPanel is one tab's ideas: its pages so far, the page being +// found, filling as the server grounds it, and Show me more, which asks for +// a page excluding every name shown. The tab's first page is asked for when +// the tab is first shown; what it finds is kept for the session. +function QuickTabPanel({ kind }: { kind: QuickKind }) { + const [tab, update] = useQuickTab(kind) + const { mutate } = useFindQuickIdeas(kind) + // A page being asked for, also by this tab before it was last shown. + const starting = useIsMutating({ mutationKey: quickTabKey(kind) }) > 0 + const job = useJob(QUICK_JOB_KIND, tab.jobId) + const names = shownNames(tab) + + // ask asks for the next page, excluding every name shown; Retry asks + // again for the page that failed. A page that could not start stays the + // tab's startError until Retry: nothing asks again by itself. + const ask = () => mutate(names) + + // The first page, asked for once. + const asked = useRef(false) + const fresh = tab.pages.length === 0 && tab.jobId === null && !tab.ended && tab.startError === null && !starting + useEffect(() => { + if (!fresh || asked.current) return + asked.current = true + mutate([]) + }, [fresh, mutate]) + + // The job's result as far as it has gone, or why it cannot be read. + const found = useMemo(() => { + if (job.result === null || tab.jobId === null) return { result: null, error: null } + try { + return { result: parseQuickResult(`GET /api/v1/jobs/${tab.jobId}`, kind, job.result), error: null } + } catch (error) { + return { result: null, error: error as Error } + } + }, [job.result, tab.jobId, kind]) + + const failure = job.status === 'failed' ? job.error!.message : null + // A later page that finds nothing means there is nothing more to show. + const exhausted = failure === NO_IDEAS_FOUND && tab.pages.length > 0 + + // A page found is kept with the tab's pages; a page that ends the tab + // ends it. + const jobId = tab.jobId + const ideas = job.status === 'succeeded' ? (found.result?.ideas ?? null) : null + useEffect(() => { + if (jobId === null) return + if (ideas !== null) update((t) => (t.jobId === jobId ? { ...t, pages: [...t.pages, { jobId, ideas }], jobId: null } : t)) + else if (exhausted) update((t) => (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) + }, [jobId, ideas, exhausted, update]) + + const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] })) + + // The rows of every page found, then of the page being found, in one + // keyed list: a page kept with the tab's pages keeps its rows, and with + // them any add still under way. + const rows: { idea: QuickIdea; ref: IdeaRef | null; key: string }[] = tab.pages.flatMap((page) => + page.ideas.map((idea, index) => ({ idea, ref: { jobId: page.jobId, index }, key: `${page.jobId}:${index}` })), + ) + + let next = null + if (starting) { + next = + } else if (tab.startError !== null) { + next = ( +
  • + Couldn't start looking for Quick dinners. {tab.startError.message} +
  • + ) + } else if (jobId !== null && failure !== null && !exhausted) { + next = ( +
  • + Couldn't find Quick dinners. {ideasJobFailure(failure)} +
  • + ) + } else if (found.error !== null) { + next = ( +
  • + Couldn't read the Quick dinners. {found.error.message} +
  • + ) + } else if (jobId !== null && !exhausted) { + const shown = found.result?.ideas ?? [] + const done = job.status === 'succeeded' + shown.forEach((idea, index) => rows.push({ idea, ref: done ? { jobId, index } : null, key: `${jobId}:${index}` })) + // A job not found will not be found by asking again: Retry asks for the + // page again. Any other failed poll is asked again. + const lost = ideasJobLost(job.error) + next = ( + <> + {!done && } + {job.error !== null && ( +
  • + Couldn't check on the Quick dinners. {job.error.message} +
  • + )} + + ) + } + + // Show me more once the tab is between pages; the server takes at most + // MAX_EXCLUDE names to leave out, so past that there is no more to ask. + const between = !starting && tab.startError === null && jobId === null && tab.pages.length > 0 + const allShown = tab.ended || names.length > MAX_EXCLUDE + const busy = starting || (jobId !== null && failure === null && found.error === null && job.status !== 'succeeded') + + return ( +
    t.value === kind)!.label} className={styles.panel}> +

    {helpers[kind]}

    +
      + {rows.map(({ idea, ref, key }) => ( + + ))} + {next} +
    + {between && + (allShown ? ( +

    + That's every Quick dinner for now. +

    + ) : ( + + ))} +
    + ) +} + +// QuickDinnersPage is mock 1j, Quick dinners: a Crock pot | 30 min or less +// tab for each kind of Quick dinner, each a quick_ideas search of its own +// whose ideas fill as the server grounds them, kept for the session. +export function QuickDinnersPage() { + const [kind, setKind] = useState('under30') + return ( +
    +
    + + + +

    Quick dinners

    +
    + + +
    + ) +} diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts new file mode 100644 index 0000000..0963f3d --- /dev/null +++ b/web/src/ideas/api.ts @@ -0,0 +1,332 @@ +import { useCallback } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ApiError, useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts' +import { QUEST_LOG_KEY } from '../discover/api.ts' +import { parsePlan, planKey } from '../plan/api.ts' + +export const IDEAS_PATH = '/api/v1/ideas' +export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients` + +// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the +// ingredients on hand. +export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients' + +// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most +// cards a search fills. +export const IDEAS_KEPT = 5 + +// MAX_INGREDIENTS is the most ingredients the household may list, and +// INGREDIENT_LIMIT the most characters one may have +// (skills.MaxIngredients, skills.IngredientLimit). +export const MAX_INGREDIENTS = 20 +export const INGREDIENT_LIMIT = 80 + +// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them +// in recipes, and done. +export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const +export type IdeaStage = (typeof IDEA_STAGES)[number] + +// Idea is a grounded idea as a result card shows it. summary is the +// server's "Uses all 5 · missing: 1 lime" line, shown as it is; +// minutes_total is the grounded recipe's, null when it does not say. +export type Idea = { + name: string + uses: string[] + missing: string[] + summary: string + minutes_total: number | null + quick: boolean +} + +// IdeasResult is an ideas_from_ingredients job's result, published as it +// goes: the ideas grounded so far, in the order the server shows them. +export type IdeasResult = { + stage: IdeaStage + ingredients: string[] + quick_only: boolean + ideas: Idea[] + dropped: number +} + +// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code. +// no_ideas_found is no fault to fix, but the search still came back empty. +const IDEAS_JOB_FAILURES: Record = { + no_ideas_found: 'No ideas came back with a recipe to match.', + ai_failed: "The AI assistant couldn't come up with ideas.", + search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.', + search_failed: "The recipe search couldn't be reached.", + internal_error: 'Something went wrong on the server.', +} + +// ideasJobFailure says why an ideas job failed; an unknown code is shown +// as it is. +export function ideasJobFailure(code: string): string { + return IDEAS_JOB_FAILURES[code] ?? code +} + +// ideasJobLost says a poll of an ideas job failed because the server no +// longer has the job (404), so asking for it again will not find it: the +// search must be started again. +export function ideasJobLost(error: Error | null): boolean { + return error instanceof ApiError && error.status === 404 +} + +// Search is what Find ideas asks for. +export type Search = { ingredients: string[]; quickOnly: boolean } + +function isStrings(value: unknown): value is string[] { + return Array.isArray(value) && value.every((v) => typeof v === 'string') +} + +function parseIdea(where: string, body: unknown): Idea { + if ( + !isRecord(body) || + typeof body.name !== 'string' || + !isStrings(body.uses) || + !isStrings(body.missing) || + typeof body.summary !== 'string' || + typeof body.quick !== 'boolean' || + (body.minutes_total !== null && !Number.isInteger(body.minutes_total)) + ) { + throw new Error(`${where}: result has an idea that is not a grounded idea`) + } + return { + name: body.name, + uses: body.uses, + missing: body.missing, + summary: body.summary, + minutes_total: body.minutes_total as number | null, + quick: body.quick, + } +} + +// parseIdeasResult checks an ideas_from_ingredients job's result, which +// useJob passes on unread, answers the search asked for. +export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult { + if ( + !isRecord(body) || + typeof body.stage !== 'string' || + !(IDEA_STAGES as readonly string[]).includes(body.stage) || + !isStrings(body.ingredients) || + body.quick_only !== quickOnly || + !Array.isArray(body.ideas) || + typeof body.dropped !== 'number' + ) { + throw new Error(`${where}: result is not ideas from ingredients`) + } + return { + stage: body.stage as IdeaStage, + ingredients: body.ingredients, + quick_only: quickOnly, + ideas: body.ideas.map((idea) => parseIdea(where, idea)), + dropped: body.dropped, + } +} + +// useFindIdeas starts finding ideas from the ingredients on hand (POST +// /api/v1/ideas/from-ingredients) and resolves with the id of the job, +// which GET /api/v1/jobs/{id} reports on. +export function useFindIdeas() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async ({ ingredients, quickOnly }: Search): Promise => { + const body = await apiFetch(FROM_INGREDIENTS_PATH, { + method: 'POST', + body: { ingredients, quick_only: quickOnly }, + }) + if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`) + return body.jobId + }, + }) +} + +// QUICK_PATH starts finding a page of Quick dinner ideas, and +// QUICK_JOB_KIND is the kind of the job that finds it. +export const QUICK_PATH = `${IDEAS_PATH}/quick` +export const QUICK_JOB_KIND = 'quick_ideas' + +// QUICK_KINDS are the kinds of Quick dinner, the tabs of Quick dinners in +// order (skills.QuickKinds): cooked in a crock pot, or 30 minutes or less +// from prep to table. +export const QUICK_KINDS = ['crockpot', 'under30'] as const +export type QuickKind = (typeof QUICK_KINDS)[number] + +// QUICK_IDEAS_KEPT is the most ideas one page of Quick dinners shows +// (skills.QuickIdeasKept), and MAX_EXCLUDE the most names a request may +// exclude (skills.MaxExclude). +export const QUICK_IDEAS_KEPT = 6 +export const MAX_EXCLUDE = 60 + +// QuickIdea is a grounded Quick dinner idea as a row shows it. method, +// minutes_total and difficulty are the grounded recipe's; minutes_total is +// null when the recipe does not say, which only a crock-pot idea may be. +// photo_id is the stored photo's, from photo_url. +export type QuickIdea = { + name: string + method: Method + minutes_total: number | null + difficulty: Difficulty + photo_id: string | undefined +} + +// QuickResult is a quick_ideas job's result, published as it goes: the +// ideas grounded so far, in the order the server shows them. +export type QuickResult = { + stage: IdeaStage + kind: QuickKind + ideas: QuickIdea[] +} + +function parseQuickIdea(where: string, body: unknown): QuickIdea { + if ( + !isRecord(body) || + typeof body.name !== 'string' || + !(METHODS as readonly unknown[]).includes(body.method) || + (body.minutes_total !== null && !Number.isInteger(body.minutes_total)) || + !(DIFFICULTIES as readonly unknown[]).includes(body.difficulty) + ) { + throw new Error(`${where}: result has an idea that is not a grounded Quick dinner`) + } + return { + name: body.name, + method: body.method as Method, + minutes_total: body.minutes_total as number | null, + difficulty: body.difficulty as Difficulty, + photo_id: photoId(where, body.photo_url), + } +} + +// parseQuickResult checks a quick_ideas job's result, which useJob passes +// on unread, is a page of the kind asked for. +export function parseQuickResult(where: string, kind: QuickKind, body: unknown): QuickResult { + if ( + !isRecord(body) || + typeof body.stage !== 'string' || + !(IDEA_STAGES as readonly string[]).includes(body.stage) || + body.kind !== kind || + !isStrings(body.exclude) || + !Array.isArray(body.ideas) || + typeof body.dropped !== 'number' + ) { + throw new Error(`${where}: result is not Quick dinner ideas of kind ${kind}`) + } + return { stage: body.stage as IdeaStage, kind, ideas: body.ideas.map((idea) => parseQuickIdea(where, idea)) } +} + +// QuickPage is a page of Quick dinner ideas its job found. +export type QuickPage = { jobId: string; ideas: QuickIdea[] } + +// QuickTab is what one tab of Quick dinners has found this session: the +// pages found, the job finding the next one, whether the server has said +// there are no more, why the last page asked for could not start, kept +// until Retry asks again, and the ideas added to the Quest log, as +// "jobId:index". +export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] } + +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } + +export function quickTabKey(kind: QuickKind) { + return ['ideas', 'quick', kind] as const +} + +// shownNames are the names of every idea a tab shows, which its next page +// excludes so that nothing repeats. +export function shownNames(tab: QuickTab): string[] { + return tab.pages.flatMap((page) => page.ideas.map((idea) => idea.name)) +} + +// useQuickTab is a tab's QuickTab, held in the query cache for the session +// (never stale, never collected) so that leaving the tab or the screen +// keeps it, and update, which changes it. Nothing is fetched: a refetch +// reads back what the tab holds. +export function useQuickTab(kind: QuickKind) { + const queryClient = useQueryClient() + const { data } = useQuery({ + queryKey: quickTabKey(kind), + queryFn: () => queryClient.getQueryData(quickTabKey(kind)) ?? NEW_TAB, + initialData: NEW_TAB, + staleTime: Infinity, + gcTime: Infinity, + }) + const update = useCallback( + (change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData(quickTabKey(kind), (tab) => change(tab ?? NEW_TAB)), + [queryClient, kind], + ) + return [data, update] as const +} + +// useFindQuickIdeas starts finding a page of Quick dinner ideas of kind, +// none named in exclude (POST /api/v1/ideas/quick), and makes its job the +// tab's job, or its failure the tab's startError, also if the screen has +// been left meanwhile. Its mutationKey is the tab's, so a tab shown again +// knows a page is still being asked for. +export function useFindQuickIdeas(kind: QuickKind) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationKey: quickTabKey(kind), + mutationFn: async (exclude: string[]): Promise => { + queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null })) + const body = await apiFetch(QUICK_PATH, { method: 'POST', body: { kind, exclude } }) + if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${QUICK_PATH}: response has no jobId`) + const jobId = body.jobId + queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId })) + return jobId + }, + onError: (startError: Error) => queryClient.setQueryData(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError })), + }) +} + +// IdeaRef is idea index of the succeeded ideas job jobId. +export type IdeaRef = { jobId: string; index: number } + +function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string { + return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}` +} + +// useAddIdeaToQuestLog adds an idea to the Quest log (POST +// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the +// dinner's name. Asking again answers the same dinner. The Quest log is +// read again after it, and onAdded told, also if the caller has unmounted +// meanwhile. +export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (idea: IdeaRef): Promise => { + const path = ideaPath(idea, 'quest-log') + return parseDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })).name + }, + onSuccess: (_name, idea) => { + onAdded?.(idea) + return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + }, + }) +} + +// useCookIdeaTonight puts an idea on today's plan (POST +// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the +// dinner's name. The plan it answers with is cached as its week's plan, +// and the Quest log is read again. With no plan for the current week the +// server answers 409 no_plan, the mutation's ApiError, and changes nothing. +export function useCookIdeaTonight() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (idea: IdeaRef): Promise => { + const path = ideaPath(idea, 'cook-tonight') + const where = `POST ${path}` + const body = await apiFetch(path, { method: 'POST' }) + if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') { + throw new Error(`${where}: response has no plan`) + } + const dinner = parseDinner(where, body.dinner) + const plan = parsePlan(where, body.plan.week_start, body.plan, false) + queryClient.setQueryData(planKey(plan.week_start), plan) + return dinner.name + }, + onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), + }) +} diff --git a/web/src/plan/api.ts b/web/src/plan/api.ts index e6094fc..cef2928 100644 --- a/web/src/plan/api.ts +++ b/web/src/plan/api.ts @@ -93,7 +93,9 @@ function syncedAt(where: string, value: unknown): Date | null { return at } -function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan { +// parsePlan checks a body the plans API sends is the plan of the week +// starting on weekStart; cached says the service worker answered it. +export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan { if ( !isRecord(body) || typeof body.id !== 'string' || From 14f7141e4445c8392c478650a639c9861ced05a7 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 12:13:57 -0400 Subject: [PATCH 2/3] Feature 18 release-review fixes: true Quest-log state and Find ideas with a draft (#160) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- web/src/ideas/CookFromPantryPage.test.tsx | 49 +++++++++++++++++++ web/src/ideas/CookFromPantryPage.tsx | 27 +++++++---- web/src/ideas/QuickDinnersPage.test.tsx | 22 +++++++++ web/src/ideas/QuickDinnersPage.tsx | 19 +++++--- web/src/ideas/api.ts | 59 +++++++++++++++++------ 5 files changed, 146 insertions(+), 30 deletions(-) diff --git a/web/src/ideas/CookFromPantryPage.test.tsx b/web/src/ideas/CookFromPantryPage.test.tsx index cb892e0..6733bf8 100644 --- a/web/src/ideas/CookFromPantryPage.test.tsx +++ b/web/src/ideas/CookFromPantryPage.test.tsx @@ -203,6 +203,22 @@ describe('CookFromPantryPage', () => { expect(chips()).toHaveLength(4) }) + it('enables Find ideas for text typed and not yet added, and searches it', async () => { + const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) }) + await renderPage() + + fireEvent.change(input(), { target: { value: ' ' } }) + expect(findButton()).toBeDisabled() + fireEvent.change(input(), { target: { value: 'Chicken thighs' } }) + expect(chips()).toEqual([]) + expect(findButton()).toBeEnabled() + + fireEvent.click(findButton()) + await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1)) + expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false }) + expect(chips()).toEqual(['Chicken thighs']) + }) + it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => { stubServer({}) await renderPage() @@ -348,6 +364,39 @@ describe('CookFromPantryPage', () => { expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled() }) + it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/quest-log': () => + jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular') + expect(curry.queryByText('In the Quest log')).toBeNull() + }) + + it('fails on a dinner whose status is neither quest_log nor regular', async () => { + const { fetchMock, jobs } = stubServer({ + [FIND]: () => jsonResponse(202, { jobId: 'j1' }), + 'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })), + }) + jobs.j1 = state('succeeded', 'done', [CURRY]) + await renderPage() + await searched(fetchMock) + const curry = within(await screen.findByRole('article', { name: CURRY.name })) + + fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' })) + expect(await curry.findByRole('alert')).toHaveTextContent( + 'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular', + ) + expect(curry.queryByRole('status')).toBeNull() + }) + it('shows its failure on its card and lets it be pressed again', async () => { let fail = true const { fetchMock, jobs } = stubServer({ diff --git a/web/src/ideas/CookFromPantryPage.tsx b/web/src/ideas/CookFromPantryPage.tsx index e8d1dc1..7af6849 100644 --- a/web/src/ideas/CookFromPantryPage.tsx +++ b/web/src/ideas/CookFromPantryPage.tsx @@ -13,6 +13,7 @@ import { Segmented } from '../ui/Segmented.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { TextInput } from '../ui/TextInput.tsx' import { + ADDED_LABEL, FROM_INGREDIENTS_JOB_KIND, IDEAS_KEPT, ideasJobFailure, @@ -50,9 +51,10 @@ function fold(ingredient: string): string { // IdeaCard is one idea (mock 1i): its name and time, the server's line on // what it uses and what is missing, and its two actions, each showing its -// own progress and failure. ref is the idea in its job, null while the job -// runs: the server takes an idea only once the job has succeeded, as the -// ideas may still be reordered until then. +// own progress and failure; an idea added says whether its dinner is in +// the Quest log or is already a Regular. ref is the idea in its job, null +// while the job runs: the server takes an idea only once the job has +// succeeded, as the ideas may still be reordered until then. function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) { const questLog = useAddIdeaToQuestLog() const tonight = useCookIdeaTonight() @@ -80,7 +82,7 @@ function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) { )} {questLog.isSuccess ? ( - In the Quest log + {ADDED_LABEL[questLog.data.status]} ) : ( diff --git a/web/src/ideas/QuickDinnersPage.test.tsx b/web/src/ideas/QuickDinnersPage.test.tsx index 891e13e..fe41e8c 100644 --- a/web/src/ideas/QuickDinnersPage.test.tsx +++ b/web/src/ideas/QuickDinnersPage.test.tsx @@ -279,6 +279,28 @@ describe('QuickDinnersPage', () => { expect(within(row(QUESADILLAS.name)).getByRole('img', { name: 'In the Quest log' })).toBeInTheDocument() }) + it('labels the check Already a Regular when the idea came back as an existing Regular', async () => { + const { fetchMock, jobs } = quickServer(['j1', 'j2'], { + 'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-reg', ORZO.name, { status: 'regular' })), + }) + jobs.j1 = state('succeeded', 'done', 'under30', [ORZO, QUESADILLAS]) + jobs.j2 = state('succeeded', 'done', 'crockpot', [CHILI]) + await renderPage() + await screen.findByRole('listitem', { name: ORZO.name }) + + fireEvent.click(addButton(ORZO.name)) + const orzo = within(row(ORZO.name)) + expect(await orzo.findByRole('status')).toContainElement(orzo.getByRole('img', { name: 'Already a Regular' })) + expect(orzo.queryByRole('img', { name: 'In the Quest log' })).toBeNull() + expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/quest-log')).toHaveLength(1) + + // Kept across tabs as what it is. + fireEvent.click(tab('Crock pot')) + await screen.findByRole('listitem', { name: CHILI.name }) + fireEvent.click(tab('30 min or less')) + expect(within(row(ORZO.name)).getByRole('img', { name: 'Already a Regular' })).toBeInTheDocument() + }) + it('marks an add that settles while another tab is shown', async () => { const added = Promise.withResolvers() const { fetchMock, jobs } = quickServer(['j1', 'j2'], { 'POST /api/v1/ideas/j1/items/0/quest-log': () => added.promise }) diff --git a/web/src/ideas/QuickDinnersPage.tsx b/web/src/ideas/QuickDinnersPage.tsx index 5fe4c96..4432442 100644 --- a/web/src/ideas/QuickDinnersPage.tsx +++ b/web/src/ideas/QuickDinnersPage.tsx @@ -12,6 +12,7 @@ import { Photo } from '../ui/Photo.tsx' import { Segmented } from '../ui/Segmented.tsx' import { Skeleton } from '../ui/Skeleton.tsx' import { + ADDED_LABEL, ideasJobFailure, ideasJobLost, MAX_EXCLUDE, @@ -23,6 +24,8 @@ import { useAddIdeaToQuestLog, useFindQuickIdeas, useQuickTab, + type AddedDinner, + type AddedStatus, type IdeaRef, type QuickIdea, type QuickKind, @@ -57,7 +60,8 @@ function meta(idea: QuickIdea): string { } // QuickRow is one idea (mock 1j): its washed photo, name and meta line, and -// the teal "+" that adds it to the Quest log, a check once it is there. +// the teal "+" that adds it to the Quest log, then a check labelled with +// where it went: added is the status of the dinner it came back as. // ideaRef is null while its job runs: the server takes an idea only once // the job has succeeded. onAdded marks the idea added, also if the row has // unmounted before the add settles. @@ -69,8 +73,8 @@ function QuickRow({ }: { idea: QuickIdea ideaRef: IdeaRef | null - added: boolean - onAdded: (idea: IdeaRef) => void + added: AddedStatus | undefined + onAdded: (idea: IdeaRef, dinner: AddedDinner) => void }) { const questLog = useAddIdeaToQuestLog(onAdded) return ( @@ -85,9 +89,9 @@ function QuickRow({

    )} - {added ? ( + {added !== undefined ? ( - + ) : ( (t.jobId === jobId ? { ...t, jobId: null, ended: true } : t)) }, [jobId, ideas, exhausted, update]) - const markAdded = ({ jobId, index }: IdeaRef) => update((t) => ({ ...t, added: [...t.added, `${jobId}:${index}`] })) + const markAdded = ({ jobId, index }: IdeaRef, { status }: AddedDinner) => + update((t) => ({ ...t, added: { ...t.added, [`${jobId}:${index}`]: status } })) // The rows of every page found, then of the page being found, in one // keyed list: a page kept with the tab's pages keeps its rows, and with @@ -223,7 +228,7 @@ function QuickTabPanel({ kind }: { kind: QuickKind }) {

    {helpers[kind]}

      {rows.map(({ idea, ref, key }) => ( - + ))} {next}
    diff --git a/web/src/ideas/api.ts b/web/src/ideas/api.ts index 0963f3d..b50eaaf 100644 --- a/web/src/ideas/api.ts +++ b/web/src/ideas/api.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { ApiError, useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' -import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method } from '../dinners/api.ts' +import { DIFFICULTIES, METHODS, parseDinner, photoId, type Difficulty, type Method, type Regular } from '../dinners/api.ts' import { QUEST_LOG_KEY } from '../discover/api.ts' import { parsePlan, planKey } from '../plan/api.ts' @@ -221,11 +221,17 @@ export type QuickPage = { jobId: string; ideas: QuickIdea[] } // QuickTab is what one tab of Quick dinners has found this session: the // pages found, the job finding the next one, whether the server has said // there are no more, why the last page asked for could not start, kept -// until Retry asks again, and the ideas added to the Quest log, as -// "jobId:index". -export type QuickTab = { pages: QuickPage[]; jobId: string | null; ended: boolean; startError: Error | null; added: string[] } +// until Retry asks again, and the status of the dinner each idea added +// came back as, by "jobId:index". +export type QuickTab = { + pages: QuickPage[] + jobId: string | null + ended: boolean + startError: Error | null + added: Record +} -const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: [] } +const NEW_TAB: QuickTab = { pages: [], jobId: null, ended: false, startError: null, added: {} } export function quickTabKey(kind: QuickKind) { return ['ideas', 'quick', kind] as const @@ -286,21 +292,46 @@ function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}` } +// ADDED_STATUSES are the statuses a dinner an idea is added as may have: +// quest_log for a dinner made or already in the Quest log, regular for a +// Regular the idea repeats, which the server answers as it is. +export const ADDED_STATUSES = ['quest_log', 'regular'] as const +export type AddedStatus = (typeof ADDED_STATUSES)[number] + +// ADDED_LABEL says where an idea added went, by its dinner's status. +export const ADDED_LABEL: Record = { + quest_log: 'In the Quest log', + regular: 'Already a Regular', +} + +// AddedDinner is the dinner an idea was added as, with its status. +export type AddedDinner = Regular & { status: AddedStatus } + +function parseAddedDinner(where: string, body: unknown): AddedDinner { + const dinner = parseDinner(where, body) + const status = isRecord(body) ? body.status : undefined + if (!(ADDED_STATUSES as readonly unknown[]).includes(status)) { + throw new Error(`${where}: status ${JSON.stringify(status)} is not one of ${ADDED_STATUSES.join(', ')}`) + } + return { ...dinner, status: status as AddedStatus } +} + // useAddIdeaToQuestLog adds an idea to the Quest log (POST -// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the -// dinner's name. Asking again answers the same dinner. The Quest log is -// read again after it, and onAdded told, also if the caller has unmounted -// meanwhile. -export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef) => void) { +// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the dinner +// it came back as: a dinner the idea repeats is answered as it is, so a +// Regular stays a Regular, and its status says which. Asking again answers +// the same dinner. The Quest log is read again after it, and onAdded told +// with the dinner, also if the caller has unmounted meanwhile. +export function useAddIdeaToQuestLog(onAdded?: (idea: IdeaRef, dinner: AddedDinner) => void) { const { apiFetch } = useApi() const queryClient = useQueryClient() return useMutation({ - mutationFn: async (idea: IdeaRef): Promise => { + mutationFn: async (idea: IdeaRef): Promise => { const path = ideaPath(idea, 'quest-log') - return parseDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })).name + return parseAddedDinner(`POST ${path}`, await apiFetch(path, { method: 'POST' })) }, - onSuccess: (_name, idea) => { - onAdded?.(idea) + onSuccess: (dinner, idea) => { + onAdded?.(idea, dinner) return queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) }, }) From 4b592fdbbb5628cd13c72d4c16076347d1536612 Mon Sep 17 00:00:00 2001 From: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> Date: Fri, 9 Oct 2026 14:01:14 -0400 Subject: [PATCH 3/3] Feature 15: Recipe Detail and Kitchen Reading Mode (#162) Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com> --- docs/ARCHITECTURE.md | 24 + internal/db/querier.go | 7 + internal/db/suggestions.sql.go | 35 + internal/dinners/detail.go | 283 ++++++++ internal/dinners/dinners.go | 7 +- internal/dinners/dinners_test.go | 101 +++ internal/dinners/export_test.go | 6 + internal/recipes/extract.go | 10 +- internal/server/dinnerdetail_test.go | 469 ++++++++++++++ internal/server/dinners.go | 18 + internal/server/dinners_test.go | 10 +- internal/server/server.go | 3 +- migrations/queries/suggestions.sql | 11 + web/src/App.test.tsx | 3 +- web/src/App.tsx | 2 + web/src/dinners/AddToWeekSheet.module.css | 7 + web/src/dinners/AddToWeekSheet.tsx | 173 +++++ web/src/dinners/DinnerDetailActions.test.tsx | 406 ++++++++++++ web/src/dinners/DinnerDetailOffline.test.tsx | 184 ++++++ web/src/dinners/DinnerDetailPage.module.css | 547 ++++++++++++++++ web/src/dinners/DinnerDetailPage.test.tsx | 473 ++++++++++++++ web/src/dinners/DinnerDetailPage.tsx | 582 +++++++++++++++++ web/src/dinners/KitchenReading.test.tsx | 604 ++++++++++++++++++ web/src/dinners/api.ts | 49 +- web/src/dinners/detail.ts | 178 ++++++ web/src/dinners/keepScreenOn.ts | 86 +++ .../dinners/testdata/source_url_cases.json | 52 ++ web/src/dinners/textSize.ts | 63 ++ web/src/plan/PlanPage.tsx | 29 +- web/src/plan/api.ts | 36 +- web/src/plan/week.test.ts | 7 +- web/src/plan/week.ts | 7 + web/src/sw/apiStrategy.test.ts | 67 +- web/src/test/serviceWorker.ts | 79 +++ web/src/ui/IconButton.module.css | 10 + web/src/ui/IconButton.test.tsx | 7 + web/src/ui/IconButton.tsx | 5 +- web/src/ui/Segmented.test.tsx | 10 + web/src/ui/Segmented.tsx | 4 + 39 files changed, 4549 insertions(+), 105 deletions(-) create mode 100644 internal/dinners/detail.go create mode 100644 internal/server/dinnerdetail_test.go create mode 100644 web/src/dinners/AddToWeekSheet.module.css create mode 100644 web/src/dinners/AddToWeekSheet.tsx create mode 100644 web/src/dinners/DinnerDetailActions.test.tsx create mode 100644 web/src/dinners/DinnerDetailOffline.test.tsx create mode 100644 web/src/dinners/DinnerDetailPage.module.css create mode 100644 web/src/dinners/DinnerDetailPage.test.tsx create mode 100644 web/src/dinners/DinnerDetailPage.tsx create mode 100644 web/src/dinners/KitchenReading.test.tsx create mode 100644 web/src/dinners/detail.ts create mode 100644 web/src/dinners/keepScreenOn.ts create mode 100644 web/src/dinners/testdata/source_url_cases.json create mode 100644 web/src/dinners/textSize.ts create mode 100644 web/src/test/serviceWorker.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 05db94d..a4fe3df 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -204,6 +204,30 @@ automatic** (D-021). sets status `archived` and keeps the row; `POST /api/v1/dinners/{id}/favorite` toggles `is_favorite`. Every write records the session's subject as `updated_by`; an unknown id is 404. + `GET /api/v1/dinners/{id}` returns the dinner with its recipe, read in + one transaction: `{id, name, status, origin, method, minutes_total, + difficulty, cuisine, is_favorite, photo_urls, ingredients, steps, + source, grounding_status, grounding_error, why_picked}`. `photo_urls` is + `{"400": url, "1200": url}` (`/media/photos/{photo_id}-{size}.jpg`) or + null with no photo; `ingredients` is `[{name, qty, unit, note, + raw_text}]` and `steps` the step texts, both in recipe position order and + empty (`[]`) with no recipe, as for a dinner whose grounding is + `pending` or `failed`; a recipe entered by hand has only `raw_text`. + Two items at one position leave recipe order unknown, so such a recipe + is 500 rather than shown out of order. `source` is `{site_name}` of the + page the recipe was fetched from, null for a manual recipe or none; it + has no `url`, as the page's link may carry a personal token. + `site_name` is the name the page gives its site (publisher or + `og:site_name`) and null otherwise: a page naming no site is stored + under its host, which may be the pasted link's — a token in a + subdomain — or a redirect's, so any stored name that could be a host + (the link's host, an IP address or a dotted host name, even a real + name like "Food.com") is withheld. The detail screen shows "Recipe + from {site}" only when `site_name` is not null. `why_picked` is the reason of the + suggestion that created the dinner — its pending or accepted one, as + declining a suggestion archives its dinner — null for one no suggestion + created; a dinner with more than one such suggestion is 500, as nothing + records which created it. An unknown or archived dinner is 404. - Dinner grounding. `POST /api/v1/dinners` also enqueues a `ground_dinner` job and answers with its `grounding_job_id` without waiting for it (`GET /api/v1/jobs/{id}` reports it); if the job cannot be enqueued it diff --git a/internal/db/querier.go b/internal/db/querier.go index 00e2e22..66b6610 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -103,6 +103,13 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetChatSettings(ctx context.Context) (ChatSetting, error) GetDinner(ctx context.Context, id string) (Dinner, error) + // The reason of each suggestion of dinner_id that can have created it, + // none for a dinner no suggestion created. A suggestion is only ever + // created with a new dinner, and declining one archives its dinner, so a + // live dinner's creating suggestion is pending (the dinner still + // suggested) or accepted; a declined one did not create it. More than one + // row means nothing records which did. + GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) // Returns sql.ErrNoRows until the row exists. GetHouseholdSettings(ctx context.Context) (HouseholdSetting, error) // Returns sql.ErrNoRows until the row exists. diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go index e7f12d7..879e606 100644 --- a/internal/db/suggestions.sql.go +++ b/internal/db/suggestions.sql.go @@ -187,6 +187,41 @@ func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch return i, err } +const getDinnerSuggestionReasons = `-- name: GetDinnerSuggestionReasons :many +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id +` + +// The reason of each suggestion of dinner_id that can have created it, +// none for a dinner no suggestion created. A suggestion is only ever +// created with a new dinner, and declining one archives its dinner, so a +// live dinner's creating suggestion is pending (the dinner still +// suggested) or accepted; a declined one did not create it. More than one +// row means nothing records which did. +func (q *Queries) GetDinnerSuggestionReasons(ctx context.Context, dinnerID string) ([]string, error) { + rows, err := q.db.QueryContext(ctx, getDinnerSuggestionReasons, dinnerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var reason string + if err := rows.Scan(&reason); err != nil { + return nil, err + } + items = append(items, reason) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches ORDER BY id DESC diff --git a/internal/dinners/detail.go b/internal/dinners/detail.go new file mode 100644 index 0000000..dfaf779 --- /dev/null +++ b/internal/dinners/detail.go @@ -0,0 +1,283 @@ +package dinners + +import ( + "context" + "database/sql" + "errors" + "fmt" + "net" + "net/url" + "strconv" + "strings" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" +) + +// Detail is a dinner with its recipe, as the recipe detail screen and +// Kitchen reading mode show it. +type Detail struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Origin string `json:"origin"` + Method *string `json:"method"` + MinutesTotal *int64 `json:"minutes_total"` + Difficulty *string `json:"difficulty"` + Cuisine *string `json:"cuisine"` + IsFavorite bool `json:"is_favorite"` + // PhotoURLs is the cached photo at each stored width, keyed by width + // ("400", "1200"); null when the dinner has no photo. + PhotoURLs map[string]string `json:"photo_urls"` + // Ingredients and Steps are the recipe's, in recipe order; empty when + // the dinner has no recipe. + Ingredients []Ingredient `json:"ingredients"` + Steps []string `json:"steps"` + // Source is the site of the recipe page the dinner is grounded in; + // null for a recipe entered by hand or a dinner with no recipe. + Source *Source `json:"source"` + GroundingStatus string `json:"grounding_status"` + // GroundingError is why grounding failed, one of the Reason codes; + // null unless GroundingStatus is failed. + GroundingError *string `json:"grounding_error"` + // WhyPicked is the reason of the suggestion that created the dinner + // (see GetDinnerSuggestionReasons); null for a dinner no suggestion + // created. + WhyPicked *string `json:"why_picked"` +} + +// Ingredient is one recipe line. RawText is the line as the recipe gives +// it; Name, Qty, Unit and Note are normalize_recipe's reading of it, each +// null when it has none, and Name, Qty and Unit always null for a recipe +// entered by hand (see IsManualRecipe). +type Ingredient struct { + Name *string `json:"name"` + Qty *string `json:"qty"` + Unit *string `json:"unit"` + Note *string `json:"note"` + RawText string `json:"raw_text"` +} + +// Source is the site a recipe was fetched from. SiteName is the name the +// page itself gives its site, and null when it gives none or the stored +// name could have come from a link (see pageSiteName). URL is the page's +// link only when it is verifiably free of secrets (see safeSourceURL), and +// null otherwise: a link pasted or offered as a candidate may carry a +// personal token, so a client links the credit only when URL is not null. +// Source is set for every fetched recipe, so a client always credits it: +// by SiteName, else by URL's host, else without naming the site. +type Source struct { + SiteName *string `json:"site_name"` + URL *string `json:"url"` +} + +// Get returns dinner id with its recipe and the reason it was suggested, +// read in one read transaction. An unknown or archived dinner is +// ErrNotFound: an archived dinner has no detail to show. +func (s *Service) Get(ctx context.Context, id string) (d Detail, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return Detail{}, fmt.Errorf("dinners: begin read: %w", err) + } + defer func() { + err = errors.Join(err, tx.Rollback()) + }() + q := db.New(tx) + row, err := q.GetDinner(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return Detail{}, ErrNotFound + } + if err != nil { + return Detail{}, fmt.Errorf("dinners: read %s: %w", id, err) + } + if row.Status == StatusArchived { + return Detail{}, ErrNotFound + } + d = Detail{ + ID: row.ID, + Name: row.Name, + Status: row.Status, + Origin: row.Origin, + Method: nullString(row.Method), + Difficulty: nullString(row.Difficulty), + Cuisine: nullString(row.Cuisine), + IsFavorite: row.IsFavorite == 1, + Ingredients: []Ingredient{}, + Steps: []string{}, + GroundingStatus: row.GroundingStatus, + GroundingError: nullString(row.GroundingError), + } + if row.MinutesTotal.Valid { + d.MinutesTotal = &row.MinutesTotal.Int64 + } + if row.PhotoID.Valid { + d.PhotoURLs = make(map[string]string, len(recipes.PhotoSizes)) + for _, size := range recipes.PhotoSizes { + d.PhotoURLs[strconv.Itoa(size)] = photoURL(row.PhotoID.String, size) + } + } + if (row.GroundingStatus == GroundingGrounded || row.GroundingStatus == GroundingManual) && !row.RecipeID.Valid { + return Detail{}, fmt.Errorf("dinners: %s is %s with no recipe", id, row.GroundingStatus) + } + if row.RecipeID.Valid { + if err := readRecipe(ctx, q, row.RecipeID.String, &d); err != nil { + return Detail{}, fmt.Errorf("dinners: read recipe of %s: %w", id, err) + } + } + reasons, err := q.GetDinnerSuggestionReasons(ctx, id) + if err != nil { + return Detail{}, fmt.Errorf("dinners: read suggestion reason of %s: %w", id, err) + } + switch len(reasons) { + case 0: + case 1: + d.WhyPicked = &reasons[0] + default: + // Nothing records which of them created the dinner, and any + // one picked would be a guess. + return Detail{}, fmt.Errorf("dinners: %s has %d suggestions that could have created it", id, len(reasons)) + } + return d, nil +} + +// readRecipe sets d's Source, Ingredients and Steps from recipe recipeID. +func readRecipe(ctx context.Context, q *db.Queries, recipeID string, d *Detail) error { + rec, err := q.GetRecipe(ctx, recipeID) + if err != nil { + return fmt.Errorf("recipe %s: %w", recipeID, err) + } + if !IsManualRecipe(rec.SourceUrl) { + site, err := pageSiteName(rec.SiteName, rec.SourceUrl) + if err != nil { + return err + } + d.Source = &Source{SiteName: site, URL: safeSourceURL(rec.SourceUrl)} + } + ings, err := q.ListRecipeIngredients(ctx, recipeID) + if err != nil { + return fmt.Errorf("ingredients: %w", err) + } + for i, ing := range ings { + if i > 0 && ing.Position == ings[i-1].Position { + return duplicatePosition("ingredient", ing.Position) + } + d.Ingredients = append(d.Ingredients, Ingredient{ + Name: nullString(ing.Name), + Qty: nullString(ing.Qty), + Unit: nullString(ing.Unit), + Note: nullString(ing.Note), + RawText: ing.RawText, + }) + } + steps, err := q.ListRecipeSteps(ctx, recipeID) + if err != nil { + return fmt.Errorf("steps: %w", err) + } + for i, step := range steps { + if i > 0 && step.Position == steps[i-1].Position { + return duplicatePosition("step", step.Position) + } + d.Steps = append(d.Steps, step.Text) + } + return nil +} + +// pageSiteName is the stored site name of a recipe fetched from sourceURL +// when it is one the page gave, else nil. A page that names no site is +// stored under recipes.HostSiteName of the URL it was served from: the +// pasted link's host, which may hold a personal token (a private +// subdomain), or that of a page the link redirected to, which is not +// stored. Nothing records which a stored name is, so any name that could +// be a host — the link's own host, an IP address, or a dotted host name — +// is treated as one and withheld. A site that names itself as a domain, +// such as "Food.com", loses its name with them: a credit that does not +// name the site is better than one that leaks a link. +func pageSiteName(stored sql.NullString, sourceURL string) (*string, error) { + if !stored.Valid { + return nil, nil + } + link, err := url.Parse(sourceURL) + if err != nil { + // Not wrapped: url.Parse's error quotes the link. + return nil, errors.New("source url does not parse") + } + name := stored.String + if strings.EqualFold(name, recipes.HostSiteName(link)) || net.ParseIP(name) != nil { + return nil, nil + } + if u, err := url.Parse("https://" + name + "/"); err == nil && u.Host == name && strings.Contains(name, ".") { + return nil, nil + } + return &name, nil +} + +// safeSourceURL is sourceURL when it is verifiably free of secrets — an +// https link to a host, with no user or password, no query (not even a +// bare "?") and no fragment (not even a bare "#") — else nil. Those are +// the places a link carries a token; a link failing any check, or not +// parsing, is withheld rather than trimmed, since a trimmed link is not the +// page the recipe came from. The client (sourceUrl in +// web/src/dinners/detail.ts) parses the link with the browser's URL parser +// and refuses the whole recipe when it fails, so the host and port must +// also be ones it parses: see browserHost. +func safeSourceURL(sourceURL string) *string { + u, err := url.Parse(sourceURL) + if err != nil || u.Scheme != "https" || u.Opaque != "" || u.Host == "" || u.User != nil || + u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(sourceURL, "#") { + return nil + } + // url.Parse decodes a percent-encoded host; the link must spell its + // host out as it is checked. + authority, _, _ := strings.Cut(sourceURL[len(u.Scheme)+len("://"):], "/") + if authority != u.Host || !browserHost(u) { + return nil + } + if port := u.Port(); port != "" { + if n, err := strconv.ParseUint(port, 10, 16); err != nil || n == 0 { + return nil + } + } + return &sourceURL +} + +// browserHost says whether u's host is one the browser's URL parser (the +// WHATWG URL Standard) accepts as it stands, where url.Parse accepts more, +// such as "[bad]". The rule is narrower than the browser's: a bracketed +// host is an IPv6 address without a zone; any other is ASCII letters, +// digits and hyphens in dot-separated labels, none empty but a trailing +// one, none punycode ("xn--"), since the browser refuses invalid punycode +// and checking it is not worth a decoder; and a host whose last label is +// a number, which the browser reads as an IPv4 address, must be a dotted +// quad. A link to any other host is withheld. +func browserHost(u *url.URL) bool { + host := u.Hostname() + if strings.HasPrefix(u.Host, "[") { + ip := net.ParseIP(host) + return ip != nil && strings.Contains(host, ":") + } + labels := strings.Split(strings.TrimSuffix(host, "."), ".") + for _, label := range labels { + if label == "" || len(label) >= 4 && strings.EqualFold(label[:4], "xn--") { + return false + } + for _, c := range label { + if !('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '-') { + return false + } + } + } + last := labels[len(labels)-1] + if strings.Trim(last, "0123456789") == "" || len(last) >= 2 && strings.EqualFold(last[:2], "0x") { + ip := net.ParseIP(host) + return ip != nil && ip.To4() != nil && !strings.Contains(host, ":") + } + return true +} + +// duplicatePosition is the error for two recipe items of kind at position. +// Position is the only record of recipe order, and the id that breaks the +// tie says nothing about it, so a recipe with a duplicate is refused rather +// than shown in an order nobody can trust. +func duplicatePosition(kind string, position int64) error { + return fmt.Errorf("two %ss at position %d: recipe order is unknown", kind, position) +} diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index b2eb82b..02d96bd 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -669,7 +669,12 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL // PhotoURL is where the API serves a list-sized copy of photo id. func PhotoURL(id string) string { - return fmt.Sprintf("/media/photos/%s-%d.jpg", id, photoListWidth) + return photoURL(id, photoListWidth) +} + +// photoURL is where the API serves photo id at stored width size. +func photoURL(id string, size int) string { + return fmt.Sprintf("/media/photos/%s-%d.jpg", id, size) } func fromRow(r db.Dinner) Dinner { diff --git a/internal/dinners/dinners_test.go b/internal/dinners/dinners_test.go index 764f6cb..dbd8bce 100644 --- a/internal/dinners/dinners_test.go +++ b/internal/dinners/dinners_test.go @@ -1,6 +1,7 @@ package dinners_test import ( + "database/sql" "encoding/json" "errors" "strings" @@ -74,3 +75,103 @@ func TestCreateRegularNeverNamesFromLink(t *testing.T) { } check("duplicate error", dup.Error()) } + +// TestPageSiteNameWithholdsHosts: a stored site name that could be a host — +// the link's own, in any case and without "www.", an IP address or a dotted +// host name — is withheld; a name the page gave, dotless or with spaces, is +// kept. +func TestPageSiteNameWithholdsHosts(t *testing.T) { + t.Parallel() + const link = "https://www.TokenHost/recipes/stew" + for _, tc := range []struct { + stored string + valid bool + want *string + }{ + {"", false, nil}, + {"tokenhost", true, nil}, + {"TOKENHOST", true, nil}, + {"private-token.example.com", true, nil}, + {"Food.com", true, nil}, + {"203.0.113.7", true, nil}, + {"2001:db8::1", true, nil}, + {"Delish", true, ptr("Delish")}, + {"Bon Appétit", true, ptr("Bon Appétit")}, + {"Mr. Food Test Kitchen", true, ptr("Mr. Food Test Kitchen")}, + } { + got, err := dinners.PageSiteName(sql.NullString{String: tc.stored, Valid: tc.valid}, link) + if err != nil { + t.Fatalf("PageSiteName(%q): %v", tc.stored, err) + } + if (got == nil) != (tc.want == nil) || (got != nil && *got != *tc.want) { + t.Errorf("PageSiteName(%q) = %v, want %v", tc.stored, deref(got), deref(tc.want)) + } + } + if _, err := dinners.PageSiteName(sql.NullString{String: "Delish", Valid: true}, "https://secret-token\x7f.example/%zz"); err == nil || strings.Contains(err.Error(), "secret-token") { + t.Errorf("PageSiteName of an unparseable link: err = %v, want one not quoting the link", err) + } +} + +// TestSafeSourceURLOnlyWithoutSecrets: a recipe link is shown only when it +// is https to a host with no user, password, query or fragment, each of +// which can carry a token; any other link, or one that does not parse, is +// withheld whole. +func TestSafeSourceURLOnlyWithoutSecrets(t *testing.T) { + t.Parallel() + for _, link := range []string{ + "https://www.example.com/recipes/chicken-curry", + "https://example.com", + "https://example.com:8443/stew/", + "https://[2001:db8::1]/curry", + "https://1.2.3.4/curry", + "https://Kitchen-2.Example.com./curry", + } { + if got := dinners.SafeSourceURL(link); got == nil || *got != link { + t.Errorf("SafeSourceURL(%q) = %v, want the link", link, deref(got)) + } + } + for _, link := range []string{ + "https://example.com/curry?access_token=query-token", + "https://example.com/curry?", + "https://user:password@example.com/curry", + "https://token@example.com/curry", + "https://@example.com/curry", + "http://example.com/curry", + "https://example.com/curry#token=fragment-token", + "https://example.com/curry#", + "ftp://example.com/curry", + "https:example.com/curry", + "https:///curry", + "https://example.com:99999/curry", + "https://example.com:0/curry", + "//example.com/curry", + "https://secret-token\x7f.example/%zz", + "", + // Hosts url.Parse accepts but the browser's URL parser refuses + // (or reads as another host), which would fail the whole recipe + // screen. + "https://[bad]/curry", + "https://[fe80::1%25en0]/curry", + "https://[1.2.3.4]/curry", + "https://1.2.3.999/curry", + "https://1.2.3.4.5/curry", + "https://999/curry", + "https://example.0x/curry", + "https://xn--a/curry", + "https://ex%61mple.com/curry", + "https://ex%2fample.com/curry", + "https://a..b/curry", + "https://café.example/curry", + } { + if got := dinners.SafeSourceURL(link); got != nil { + t.Errorf("SafeSourceURL(%q) = %q, want nil", link, *got) + } + } +} + +func deref(s *string) any { + if s == nil { + return nil + } + return *s +} diff --git a/internal/dinners/export_test.go b/internal/dinners/export_test.go index b143035..1d62211 100644 --- a/internal/dinners/export_test.go +++ b/internal/dinners/export_test.go @@ -71,3 +71,9 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error) r, err := s.runGroundJob(ctx, p, nil) return r.GroundingStatus, err } + +// PageSiteName is pageSiteName. +var PageSiteName = pageSiteName + +// SafeSourceURL is safeSourceURL. +var SafeSourceURL = safeSourceURL diff --git a/internal/recipes/extract.go b/internal/recipes/extract.go index 2ce6069..9de3383 100644 --- a/internal/recipes/extract.go +++ b/internal/recipes/extract.go @@ -29,7 +29,7 @@ type Recipe struct { // Title is the recipe's name. Title string // SiteName is the publisher's name, else the page's og:site_name, else - // its host without a leading "www.". + // HostSiteName of the page's URL. SiteName string // ImageURL is the largest image, absolute; empty when the recipe has // none. @@ -232,7 +232,13 @@ func siteName(publisher any, g graph, ogSiteName string, base *url.URL) (string, if ogSiteName != "" { return ogSiteName, nil } - return strings.TrimPrefix(base.Hostname(), "www."), nil + return HostSiteName(base), nil +} + +// HostSiteName is the site name of a page that names none: its host +// without a leading "www.". +func HostSiteName(u *url.URL) string { + return strings.TrimPrefix(u.Hostname(), "www.") } // imageURL picks the largest of the recipe's images by width × height; diff --git a/internal/server/dinnerdetail_test.go b/internal/server/dinnerdetail_test.go new file mode 100644 index 0000000..1789e1c --- /dev/null +++ b/internal/server/dinnerdetail_test.go @@ -0,0 +1,469 @@ +package server + +import ( + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// getDetail answers GET /dinners/{id} for id, failing unless it is 200, and +// returns the body both raw (to tell null from absent) and decoded. +func getDetail(t *testing.T, h http.Handler, id string) (map[string]any, dinners.Detail) { + t.Helper() + rec, raw := callDinners(t, h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET dinner = %d: %s", rec.Code, rec.Body) + } + var d dinners.Detail + dec := json.NewDecoder(rec.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&d); err != nil { + t.Fatalf("decode detail: %v", err) + } + return raw, d +} + +// wantNull fails unless each key is present in raw and null. +func wantNull(t *testing.T, raw map[string]any, keys ...string) { + t.Helper() + for _, k := range keys { + if v, ok := raw[k]; !ok || v != nil { + t.Errorf("%s = %v (present %v), want null", k, v, ok) + } + } +} + +// TestGetDinnerGroundedSideQuest: a Side quest grounded in a fetched page +// shows its photo at both stored sizes, its recipe's ingredients and steps +// in recipe order (not row order), its source site and the reason it was +// suggested. +func TestGetDinnerGroundedSideQuest(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40)) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "succeeded" { + t.Fatalf("grounding job = %v", job) + } + id := created["id"].(string) + + // Make it an accepted Side quest with the suggestion that created it, + // and reverse its recipe's positions so row order disagrees with + // recipe order. + var recipeID, photoID string + if err := e.st.Reader.QueryRow(`SELECT recipe_id, photo_id FROM dinners WHERE id = ?`, id).Scan(&recipeID, &photoID); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `UPDATE dinners SET status = 'quest_log', origin = 'side_quest', is_favorite = 1 WHERE id = ?`, + `UPDATE recipe_ingredients SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + `UPDATE recipe_steps SET position = 1 - position WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, + } { + if _, err := e.st.Writer.Exec(stmt, id); err != nil { + t.Fatal(err) + } + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: store.NewID(), DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + + _, got := getDetail(t, e.h, id) + str := func(s string) *string { return &s } + minutes := int64(40) + want := dinners.Detail{ + ID: id, + Name: "Chicken curry", + Status: "quest_log", + Origin: "side_quest", + Method: str("stovetop"), + MinutesTotal: &minutes, + Difficulty: str("easy"), + IsFavorite: true, + PhotoURLs: map[string]string{ + "400": "/media/photos/" + photoID + "-400.jpg", + "1200": "/media/photos/" + photoID + "-1200.jpg", + }, + Ingredients: []dinners.Ingredient{ + {Name: str("garlic"), Qty: str("2"), Unit: str("cloves"), Note: str("minced"), RawText: "2 cloves garlic, minced"}, + {Name: str("onion"), Qty: str("1"), RawText: "1 onion"}, + }, + Steps: []string{"Fry with the garlic.", "Chop the onion."}, + Source: &dinners.Source{SiteName: str("Curry House"), URL: str(curryPage)}, + GroundingStatus: "grounded", + WhyPicked: str(reason), + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } + // Each photo URL is served. + for _, u := range got.PhotoURLs { + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, signedIn(http.MethodGet, u, nil)) + if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/jpeg" { + t.Errorf("GET %s = %d %s", u, rec.Code, rec.Header().Get("Content-Type")) + } + } +} + +// TestGetDinnerManualRegular: a Regular added by hand and given a recipe +// typed in has no photo, no source and no reason, and its ingredients are +// the typed lines with nothing normalized. +func TestGetDinnerManualRegular(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Family chili"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + + raw, got := getDetail(t, e.h, id) + wantNull(t, raw, "photo_urls", "source", "why_picked", "difficulty", "grounding_error") + str := func(s string) *string { return &s } + minutes := int64(240) + want := dinners.Detail{ + ID: id, + Name: "Family chili", + Status: "regular", + Origin: "regular", + Method: str("crockpot"), + MinutesTotal: &minutes, + Ingredients: []dinners.Ingredient{{RawText: "1 lb beef"}, {RawText: "1 can beans"}}, + Steps: []string{"Brown.", "Simmer."}, + GroundingStatus: "manual", + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.Marshal(got) + wj, _ := json.Marshal(want) + t.Errorf("detail =\n %s\nwant\n %s", gj, wj) + } +} + +// TestGetDinnerFailedGrounding: a dinner whose grounding failed shows its +// real state — failed with its reason, no photo, no source and empty +// ingredients and steps — never a pretend recipe. +func TestGetDinnerFailedGrounding(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t).withRecipes(t) + e.start(t) + rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Nothing matches this"}`) + if rec.Code != http.StatusCreated { + t.Fatalf("POST = %d: %s", rec.Code, rec.Body) + } + if job := waitJob(t, e.h, created["grounding_job_id"].(string)); job["status"] != "failed" { + t.Fatalf("grounding job = %v, want failed", job) + } + + raw, got := getDetail(t, e.h, created["id"].(string)) + wantNull(t, raw, "photo_urls", "source", "why_picked", "method", "minutes_total", "difficulty") + if ings, ok := raw["ingredients"].([]any); !ok || len(ings) != 0 { + t.Errorf("ingredients = %v, want []", raw["ingredients"]) + } + if steps, ok := raw["steps"].([]any); !ok || len(steps) != 0 { + t.Errorf("steps = %v, want []", raw["steps"]) + } + if got.GroundingStatus != "failed" || got.GroundingError == nil || *got.GroundingError != dinners.ReasonNoRecipe || + got.Name != "Nothing matches this" || got.Status != "regular" || got.Origin != "regular" { + t.Errorf("detail = %+v, want the failed Regular with no_recipe_found", got) + } +} + +// TestGetDinnerNotFound: an unknown dinner and an archived one are both +// 404, and the detail needs a session like every /api/v1 route. +func TestGetDinnerNotFound(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Old favourite"}`) + if rec, _ := callDinners(t, e.h, http.MethodDelete, dinnersPath+"/"+id, ""); rec.Code != http.StatusOK { + t.Fatalf("DELETE = %d: %s", rec.Code, rec.Body) + } + for _, target := range []string{id, store.NewID()} { + rec, body := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+target, "") + if rec.Code != http.StatusNotFound || errorCode(body) != "not_found" { + t.Errorf("GET %s = %d %v, want 404 not_found", target, rec.Code, body) + } + } + + live := createDinner(t, e.h, `{"name":"Taco night"}`) + req := httptest.NewRequest(http.MethodGet, dinnersPath+"/"+live, nil) + if rec, body := serve(t, e.h, req); rec.Code != http.StatusUnauthorized || errorCode(body) != "unauthenticated" { + t.Errorf("GET without a session = %d %v, want 401", rec.Code, body) + } +} + +// TestGetDinnerSourceURLOnlyWhenSafe: a recipe grounded in a clean https +// link shows it as source.url; one whose stored link carries a token in its +// query or userinfo, is http, or has a fragment shows its site name but +// source.url null and nothing of the link. +func TestGetDinnerSourceURLOnlyWhenSafe(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + clean = "https://kitchen.example/recipes/chicken-curry" + queryToken = "query-token-def456" + queryPage = "https://secret-query.example/curry?access_token=" + queryToken + ) + e.pages.Bodies[clean] = recipestest.RecipePage("Chicken Curry", "Curry House", "https://a.example/curry.jpg", 40) + e.pages.Bodies[queryPage] = recipestest.RecipePage("Weeknight Curry", "B Kitchen", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + var stored string + if err := e.st.Reader.QueryRow(`SELECT source_url FROM recipes WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != page { + t.Fatalf("stored source_url = %q, want %q", stored, page) + } + return id + } + source := func(what, id string) map[string]any { + t.Helper() + raw, _ := getDetail(t, e.h, id) + src, ok := raw["source"].(map[string]any) + if !ok { + t.Fatalf("source of %s = %v, want an object", what, raw["source"]) + } + return src + } + + cleanID := ground("Chicken curry", clean) + if src := source("a clean link", cleanID); !reflect.DeepEqual(src, map[string]any{"site_name": "Curry House", "url": clean}) { + t.Errorf("source of a clean link = %v, want site_name Curry House and url %s", src, clean) + } + + queryID := ground("Weeknight curry", queryPage) + withheld := func(what, id, site string, secrets ...string) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, secret := range secrets { + if strings.Contains(rec.Body.String(), secret) { + t.Errorf("detail of %s contains %q: %s", what, secret, rec.Body) + } + } + if src := source(what, id); !reflect.DeepEqual(src, map[string]any{"site_name": site, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %q and url null", what, src, site) + } + } + withheld("a link with a query token", queryID, "B Kitchen", queryPage, "secret-query.example", queryToken, "access_token") + + // The other unsafe shapes, stored as a link grounded elsewhere would + // leave them. + for _, tc := range []struct{ what, link string }{ + {"a link with a userinfo token", "https://user-token-ghi789@kitchen.example/recipes/chicken-curry"}, + {"a link with a userinfo password", "https://cook:password-jkl012@kitchen.example/recipes/chicken-curry"}, + {"an http link", "http://kitchen.example/recipes/chicken-curry"}, + {"a link with a fragment", "https://kitchen.example/recipes/chicken-curry#token=fragment-mno345"}, + } { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, tc.link, cleanID); err != nil { + t.Fatal(err) + } + withheld(tc.what, cleanID, "Curry House", tc.link, "kitchen.example", "-token-", "password", "fragment") + } + + // web/src/dinners/testdata/source_url_cases.json pins source as this + // handler sends it for each stored link; the recipe screen's tests + // render each one, so a link the server sends and the client refuses + // fails one side or the other. + fixture, err := os.ReadFile("../../web/src/dinners/testdata/source_url_cases.json") + if err != nil { + t.Fatal(err) + } + var shared struct { + Cases []struct { + Stored string + Source map[string]any + } + } + if err := json.Unmarshal(fixture, &shared); err != nil { + t.Fatal(err) + } + if len(shared.Cases) == 0 { + t.Fatal("source_url_cases.json has no cases") + } + for _, c := range shared.Cases { + if _, err := e.st.Writer.Exec(`UPDATE recipes SET source_url = ? WHERE id = (SELECT recipe_id FROM dinners WHERE id = ?)`, c.Stored, cleanID); err != nil { + t.Fatal(err) + } + if src := source(c.Stored, cleanID); !reflect.DeepEqual(src, c.Source) { + got, _ := json.Marshal(src) + want, _ := json.Marshal(c.Source) + t.Errorf("source of %s = %s, want %s", c.Stored, got, want) + } + } +} + +// TestGetDinnerSiteNameNeverFromLink: a page that names no site is stored +// under its host, so a recipe grounded in a pasted link whose host carries a +// token has site_name null and nothing from the link in its detail; a page +// that names its site keeps that name, even on a token-bearing host; and a +// stored name shaped like another host, as a redirect leaves, is withheld. +// The links carry a query, so source.url is withheld too and nothing of the +// link may appear. +func TestGetDinnerSiteNameNeverFromLink(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + const ( + bareToken = "private-token-abc123" + namedToken = "named-token-def456" + bare = "https://" + bareToken + ".example.com/recipe?print=1" + named = "https://www." + namedToken + ".example.com/recipe?print=1" + ) + e.pages.Bodies[bare] = recipestest.RecipePage("Chicken Curry", "", "https://a.example/curry.jpg", 40) + e.pages.Bodies[named] = recipestest.RecipePage("Weeknight Curry", "Curry House", "https://b.example/curry.jpg", 25) + e.images.JPEGs["https://a.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 1) + e.images.JPEGs["https://b.example/curry.jpg"] = recipestest.JPEG(t, 400, 320, 2) + ground := func(dish, page string) (id, recipeID string) { + t.Helper() + id = createDinner(t, e.h, `{"name":"`+dish+`"}`) // runner not started: stays pending + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25)) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", `{"recipe_url":"`+page+`"}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground %s = %d: %s", page, rec.Code, rec.Body) + } + if err := e.st.Reader.QueryRow(`SELECT recipe_id FROM dinners WHERE id = ?`, id).Scan(&recipeID); err != nil { + t.Fatal(err) + } + return id, recipeID + } + storedSite := func(recipeID string) string { + t.Helper() + var site string + if err := e.st.Reader.QueryRow(`SELECT site_name FROM recipes WHERE id = ?`, recipeID).Scan(&site); err != nil { + t.Fatal(err) + } + return site + } + check := func(what, id string, leaks []string, want any) { + t.Helper() + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusOK { + t.Fatalf("GET %s = %d: %s", what, rec.Code, rec.Body) + } + for _, leak := range leaks { + if strings.Contains(strings.ToLower(rec.Body.String()), leak) { + t.Errorf("detail of %s contains %q: %s", what, leak, rec.Body) + } + } + raw, _ := getDetail(t, e.h, id) + if src, _ := raw["source"].(map[string]any); !reflect.DeepEqual(src, map[string]any{"site_name": want, "url": nil}) { + t.Errorf("source of %s = %v, want site_name %v and url null", what, raw["source"], want) + } + } + + bareID, bareRecipe := ground("Chicken curry", bare) + if got := storedSite(bareRecipe); got != bareToken+".example.com" { + t.Fatalf("stored site_name = %q, want the link's host", got) + } + check("a page naming no site", bareID, []string{bare, bareToken, "example.com"}, nil) + + namedID, namedRecipe := ground("Weeknight curry", named) + if got := storedSite(namedRecipe); got != "Curry House" { + t.Fatalf("stored site_name = %q, want the page's", got) + } + check("a page naming its site", namedID, []string{named, namedToken, "example.com"}, "Curry House") + + const redirectHost = "redirect-token-ghi789.example.net" + if _, err := e.st.Writer.Exec(`UPDATE recipes SET site_name = ? WHERE id = ?`, redirectHost, namedRecipe); err != nil { + t.Fatal(err) + } + check("a page named by a redirect's host", namedID, []string{redirectHost, "redirect-token", namedToken}, nil) +} + +// TestGetDinnerWhyPickedIsCreatingSuggestion: why_picked is the reason of +// the suggestion that created the dinner, never that of an earlier +// suggestion row for it that was declined. +func TestGetDinnerWhyPickedIsCreatingSuggestion(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + id := createDinner(t, e.h, `{"name":"Chicken curry"}`) + if _, err := e.st.Writer.Exec(`UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + q := db.New(e.st.Writer) + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + const reason = "Like your Tikka masala, but quicker." + // The declined row is created first, so it is the earliest by both + // created_at and id. + for _, s := range []struct{ reason, status string }{ + {"Passed on last week.", "declined"}, + {reason, "accepted"}, + } { + sid := store.NewID() + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: sid, DinnerID: id, BatchID: batch.ID, Name: "Chicken curry", + Reason: s.reason, CitedRegularIds: "[]", CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + if n, err := q.DecideSuggestion(t.Context(), db.DecideSuggestionParams{ + Status: s.status, Now: sql.NullString{String: now, Valid: true}, ID: sid, + }); err != nil || n != 1 { + t.Fatalf("decide %s = %d, %v", s.status, n, err) + } + } + + if _, got := getDetail(t, e.h, id); got.WhyPicked == nil || *got.WhyPicked != reason { + t.Errorf("why_picked = %v, want %q", got.WhyPicked, reason) + } +} + +// TestGetDinnerDuplicatePositionFails: a recipe with two ingredients or two +// steps at one position has no order anyone can trust, so its detail is a +// 500 rather than the recipe in some order. +func TestGetDinnerDuplicatePositionFails(t *testing.T) { + t.Parallel() + e := newDinnersEnv(t) + for _, table := range []string{"recipe_ingredients", "recipe_steps"} { + id := createDinner(t, e.h, `{"name":"Family chili `+table+`"}`) + if rec, _ := callDinners(t, e.h, http.MethodPost, dinnersPath+"/"+id+"/ground", + `{"manual":{"ingredients":["1 lb beef","1 can beans"],"steps":["Brown.","Simmer."],"minutes":240,"method":"crockpot"}}`); rec.Code != http.StatusOK { + t.Fatalf("POST ground = %d: %s", rec.Code, rec.Body) + } + if _, err := e.st.Writer.Exec(`UPDATE `+table+` SET position = 0 WHERE recipe_id = (SELECT recipe_id FROM dinners WHERE id = ?)`, id); err != nil { + t.Fatal(err) + } + rec, _ := callDinners(t, e.h, http.MethodGet, dinnersPath+"/"+id, "") + if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "Simmer.") || strings.Contains(rec.Body.String(), "1 lb beef") { + t.Errorf("GET with duplicate %s positions = %d: %s, want 500 and no recipe", table, rec.Code, rec.Body) + } + } +} diff --git a/internal/server/dinners.go b/internal/server/dinners.go index fab52ac..5f0d54e 100644 --- a/internal/server/dinners.go +++ b/internal/server/dinners.go @@ -70,6 +70,24 @@ func getDinners(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { } } +// getDinner answers GET /dinners/{id} with the dinner, its recipe and the +// reason it was suggested. An archived dinner is 404, like an unknown one, +// not a 200 with its status: it has no detail to show. source never has the +// recipe page's link, and source.site_name is null unless the page itself +// named its site — a name that could be a host, the link's or a redirect's, +// is withheld (dinners.Source) — so the detail screen shows "Recipe from +// {site}" only when site_name is not null. +func getDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + d, err := svc.Get(r.Context(), chi.URLParam(r, "id")) + if err != nil { + writeDinnerError(w, r, logger, "get dinner", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + // postDinner adds a Regular, recording the session's actor as updated_by, // and answers 201 with it and its grounding_job_id without waiting for the // job. When the job cannot be enqueued it still answers 201, with the dinner diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 61faead..472bdb2 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -86,13 +86,19 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.runner = runner - g := recipestest.NewGrounder(t, st, t.TempDir(), e.search, e.pages, e.images, e.model) + // The router serves the photos the grounder caches. + dataDir := t.TempDir() + g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model) + photos, err := recipes.NewPhotoCache(e.images, st, dataDir) + if err != nil { + t.Fatal(err) + } svc, err := dinners.NewService(st, g, runner, logger) if err != nil { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/server.go b/internal/server/server.go index 5ec718c..3ee34f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -40,7 +40,7 @@ type RouteMounter interface { // /media; settings serves /api/v1/settings/household; search serves // /api/v1/settings/recipe-search and its /test; ai serves // /api/v1/settings/ai, its /api-key, /verify, /models and /reset-prompt; -// dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE +// dinnerSvc serves GET and POST /api/v1/dinners, GET, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; // suggestSvc serves POST /api/v1/suggestions/batches, GET @@ -137,6 +137,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/settings/ai/reset-prompt", postAIResetPrompt(logger, ai)) apiV1.Get("/dinners", getDinners(logger, dinnerSvc)) apiV1.Post("/dinners", postDinner(logger, dinnerSvc)) + apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc)) apiV1.Patch("/dinners/{id}", patchDinner(logger, dinnerSvc)) apiV1.Delete("/dinners/{id}", deleteDinner(logger, dinnerSvc)) apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql index 1068622..61aef9b 100644 --- a/migrations/queries/suggestions.sql +++ b/migrations/queries/suggestions.sql @@ -116,3 +116,14 @@ WHERE id = sqlc.arg(id) AND status = 'pending'; UPDATE dinners SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) WHERE id = sqlc.arg(id) AND status = 'suggested'; + +-- name: GetDinnerSuggestionReasons :many +-- The reason of each suggestion of dinner_id that can have created it, +-- none for a dinner no suggestion created. A suggestion is only ever +-- created with a new dinner, and declining one archives its dinner, so a +-- live dinner's creating suggestion is pending (the dinner still +-- suggested) or accepted; a declined one did not create it. More than one +-- row means nothing records which did. +SELECT reason FROM suggestions +WHERE dinner_id = ? AND status IN ('pending', 'accepted') +ORDER BY id; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index e9be839..4c29ca0 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -37,6 +37,7 @@ const SCREENS = { '/discover': true, '/quick': true, '/cook-from-pantry': true, + '/dinner/:id': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -300,8 +301,8 @@ describe('App offline bootstrap', () => { }) it.each([ + ['/', 'Home'], ['/settings/notifications', 'Notifications'], - ['/dinner/01J9ZK', 'Dinner'], ])('an offline reload of %s after an online visit opens the saved page', async (path, title) => { await visitOnline() goOffline() diff --git a/web/src/App.tsx b/web/src/App.tsx index 8fb3ea3..6f7c439 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' +import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx' import { DiscoverPage } from './discover/DiscoverPage.tsx' import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx' import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx' @@ -49,6 +50,7 @@ const screens: Partial> = { '/discover': , '/quick': , '/cook-from-pantry': , + '/dinner/:id': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/dinners/AddToWeekSheet.module.css b/web/src/dinners/AddToWeekSheet.module.css new file mode 100644 index 0000000..80ffbfc --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.module.css @@ -0,0 +1,7 @@ +/* A week with no plan: why there's no day, over the link to draft it. */ +.noPlan { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 14px; +} diff --git a/web/src/dinners/AddToWeekSheet.tsx b/web/src/dinners/AddToWeekSheet.tsx new file mode 100644 index 0000000..0998ae2 --- /dev/null +++ b/web/src/dinners/AddToWeekSheet.tsx @@ -0,0 +1,173 @@ +import { useId, useState } from 'react' +import { Link } from 'react-router' +import { usePlan, useWeekSwap, type PlanDinner, type Swap } from '../plan/api.ts' +import { cooking } from '../plan/PlanPage.tsx' +import swapStyles from '../plan/SwapSheet.module.css' +import { addDays, dayOfMonth, localNow, weekdayName, weekLabel, weekStart as weekOf } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import buttonStyles from '../ui/Button.module.css' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented } from '../ui/Segmented.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import type { DinnerDetail } from './detail.ts' +import styles from './AddToWeekSheet.module.css' + +// PLAN is the Draft board, where a week with no plan is drafted. +export const PLAN = '/plan' + +// Which is the week a dinner is added to: the household's current week or +// the one after. +export type Which = 'this' | 'next' + +export const weekNames: Record = { this: 'this week', next: 'next week' } + +// Added is a swap sent from the sheet: the week it went to and the swap. +export type Added = { week: string; which: Which; swap: Swap } + +// planDinner is the dinner as it is planned on a day. +function planDinner(dinner: DinnerDetail): PlanDinner { + return { id: dinner.id, name: dinner.name, photo_id: dinner.photo_id, minutes: dinner.minutes_total, method: dinner.method } +} + +type WeekDaysProps = { + dinner: DinnerDetail + week: string + which: Which + onAdd: (added: Added) => void +} + +// WeekDays is the seven days of the week starting on week, each with its +// dinner; choosing one swaps the dinner in as the Draft board swaps a day +// (useWeekSwap). A week with no plan offers to draft it on the Draft board: +// nothing here makes a plan up. A dinner already on the week can't be added +// again, nor can one while a swap of the week is still being saved or the +// week is being pushed to Calendar. +function WeekDays({ dinner, week, which, onAdd }: WeekDaysProps) { + const plan = usePlan(week) + const { swap, push, refused, swapIn } = useWeekSwap(week) + + if (plan.isError) { + return void plan.refetch()}>Couldn't load {weekNames[which]}'s plan. {plan.error.message} + } + if (plan.isPending) { + return ( +
    + {Array.from({ length: 7 }, (_, i) => ( + + ))} +
    + ) + } + if (plan.data === null) { + return ( +
    +

    There's no plan for {weekNames[which]} yet, so there's no day to add it to.

    + + Draft {weekNames[which]} first + +
    + ) + } + + const days = plan.data.days + const planned = days.find((day) => day.dinner.id === dinner.id) + const choosable = planned === undefined && !refused + function add(date: string) { + const chosen: Swap = { date, dinner: planDinner(dinner), fromQuestLog: dinner.status === 'quest_log' } + swapIn(chosen) + onAdd({ week, which, swap: chosen }) + } + return ( + <> + {planned !== undefined ? ( +

    + {dinner.name} is already on {weekdayName(planned.date)} {weekNames[which]}. +

    + ) : swap.pending ? ( +

    A change to {weekNames[which]} is still being saved.

    + ) : ( + push.pending &&

    The plan for {weekNames[which]} is being pushed to Calendar.

    + )} +
      + {days.map((day) => ( +
    • +
    • + ))} +
    + + ) +} + +export type AddToWeekSheetProps = { + dinner: DinnerDetail + onAdd: (added: Added) => void + onClose: () => void +} + +// AddToWeekSheet is the sheet Add to this week opens: the seven days of the +// household's current week, Monday to Sunday in its timezone, each with its +// dinner, and a toggle to next week. Choosing a day replaces its dinner. +export function AddToWeekSheet({ dinner, onAdd, onClose }: AddToWeekSheetProps) { + const titleId = useId() + const settings = useHouseholdSettings() + const [which, setWhich] = useState('this') + + let body + if (settings.isError) { + body = ( + void settings.refetch()}> + Couldn't load the household settings. {settings.error.message} + + ) + } else if (settings.isPending) { + body = + } else { + const thisWeek = weekOf(localNow(settings.data.timezone, new Date()).date) + const week = which === 'this' ? thisWeek : addDays(thisWeek, 7) + body = ( + <> +

    + {weekLabel(week).dates} · replaces the day's dinner +

    + + + + ) + } + + return ( + +
    +

    + Add {dinner.name} +

    + {body} +
    +
    + ) +} diff --git a/web/src/dinners/DinnerDetailActions.test.tsx b/web/src/dinners/DinnerDetailActions.test.tsx new file mode 100644 index 0000000..dc967a4 --- /dev/null +++ b/web/src/dinners/DinnerDetailActions.test.tsx @@ -0,0 +1,406 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { PlanPage } from '../plan/PlanPage.tsx' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +const DINNER = '/api/v1/dinners/d1' +const FAVORITE = `${DINNER}/favorite` +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' +const THURSDAY = `${THIS_WEEK}/days/2026-10-08` + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }], + steps: ['Brown the beef with the sauce.'], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + ...overrides, + } +} + +// listed is the bowls as the dinners list, and so the favorite toggle, +// answers: no recipe. +function listed(is_favorite: boolean) { + return { + id: 'd1', + name: 'Korean beef bowls', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + is_favorite, + photo_url: null, + grounding_status: 'grounded', + } +} + +const THIS_NAMES = ['Beef tacos', 'Spaghetti bolognese', 'Lemon orzo', 'Chicken stir-fry', 'Pizza night', 'Fish tacos', 'Sheet-pan salmon'] +const NEXT_NAMES = ['Chili', 'Pad thai', 'Burgers', 'Ramen', 'Pizza night', 'Pot roast', 'Fried rice'] + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it, with +// dinner ids p0 to p6 unless ids says otherwise. +function planBody(weekStart: string, names: string[], ids = names.map((_, i) => `p${i}`)) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null as string | null, + days: names.map((name, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: { id: ids[i], name, photo: null, minutes: 30, method: 'stovetop' }, + is_new: false, + start_by: null, + })), + } +} + +// THURSDAY_SWAPPED is this week's plan as the swap of Thursday answers it. +function thursdaySwapped() { + const ids = THIS_NAMES.map((_, i) => (i === 3 ? 'd1' : `p${i}`)) + const names = THIS_NAMES.map((name, i) => (i === 3 ? 'Korean beef bowls' : name)) + const plan = planBody('2026-10-05', names, ids) + plan.days[3]!.is_new = true + return plan +} + +type Responder = (init: RequestInit | undefined) => Response | Promise + +// stubServer answers "METHOD path" requests from responders, with /me, the +// household settings and the dinner; any other request fails the test. +function stubServer(responders: Record = {}) { + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + 'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [`GET ${DINNER}`]: () => jsonResponse(200, detail()), + ...responders, + } + const fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond(init)) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +// sent lists the requests sent with method, as "path body". +function sent(fetchMock: Mock, method: string) { + return fetchMock.mock.calls + .filter(([, init]) => init?.method === method) + .map(([input, init]) => `${String(input)} ${String(init?.body)}`) +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === path).length +} + +// deferred is a response the test sends when it chooses. +function deferred() { + let send!: (response: Response) => void + const response = new Promise((resolve) => { + send = resolve + }) + return { response, send } +} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the bowls' detail screen, with plan for the Plan: a probe unless told +// otherwise. It answers with the router. +async function renderPage(plan =

    Plan probe

    ) { + const api = createApiClient(fakeLocation('/dinner/d1'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/dinner/:id', element: }, + { path: '/plan', element: plan }, + ], + { initialEntries: ['/dinner/d1'] }, + ) + render( + + + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return router +} + +// openSheet opens Add to this week and waits for the week's days. +async function openSheet(listName = 'Days of this week') { + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const days = await within(sheet).findByRole('list', { name: listName }) + return { sheet, days } +} + +// dayRows is each day's row text, as "Monday 5 · Beef tacos · Stovetop · 30 min", +// with plain spaces. +function dayRows(days: HTMLElement) { + return within(days) + .getAllByRole('listitem') + .map((row) => [...row.querySelectorAll('span')].filter((span) => span.children.length === 0 && !span.closest('[aria-hidden="true"]')).map((span) => span.textContent).join(' · ').replace(/\s/g, ' ')) +} + +beforeEach(() => { + mockViewport(390) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('Add to this week', () => { + it('lists the seven days of this week with their dinners, and next week by the toggle', async () => { + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`GET ${NEXT_WEEK}`]: () => jsonResponse(200, planBody('2026-10-12', NEXT_NAMES)), + }) + await renderPage() + // Nothing about the week is read until the sheet opens. + expect(reads(fetchMock, THIS_WEEK)).toBe(0) + + const { sheet, days } = await openSheet() + expect(within(sheet).getByText("Oct 5 – 11 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(days)).toEqual([ + 'Monday 5 · Beef tacos · Stovetop · 30 min', + 'Tuesday 6 · Spaghetti bolognese · Stovetop · 30 min', + 'Wednesday 7 · Lemon orzo · Stovetop · 30 min', + 'Thursday 8 · Chicken stir-fry · Stovetop · 30 min', + 'Friday 9 · Pizza night · Stovetop · 30 min', + 'Saturday 10 · Fish tacos · Stovetop · 30 min', + 'Sunday 11 · Sheet-pan salmon · Stovetop · 30 min', + ]) + expect(within(days).getAllByRole('button')).toHaveLength(7) + + fireEvent.click(within(sheet).getByRole('radio', { name: 'Next week' })) + const next = await within(sheet).findByRole('list', { name: 'Days of next week' }) + expect(within(sheet).getByText("Oct 12 – 18 · replaces the day's dinner")).toBeInTheDocument() + expect(dayRows(next)[0]).toBe('Monday 12 · Chili · Stovetop · 30 min') + expect(dayRows(next)[6]).toBe('Sunday 18 · Fried rice · Stovetop · 30 min') + + fireEvent.click(within(sheet).getByRole('radio', { name: 'This week' })) + expect(await within(sheet).findByRole('list', { name: 'Days of this week' })).toBeInTheDocument() + }) + + it("takes this week in the household's timezone, not the device's UTC date", async () => { + // Sunday Oct 11, 10 pm in New York, already Monday Oct 12 in UTC. + vi.setSystemTime(new Date('2026-10-12T02:00:00Z')) + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)) }) + await renderPage() + + const { days } = await openSheet() + expect(dayRows(days)[6]).toBe('Sunday 11 · Sheet-pan salmon · Stovetop · 30 min') + expect(reads(fetchMock, THIS_WEEK)).toBe(1) + expect(reads(fetchMock, NEXT_WEEK)).toBe(0) + }) + + it('swaps the day chosen through the plan API, confirms it and reads the plan again', async () => { + const swap = deferred() + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`PUT ${THURSDAY}`]: () => swap.response, + }) + await renderPage() + const { days } = await openSheet() + + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + await waitFor(() => expect(sent(fetchMock, 'PUT')).toHaveLength(1)) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + expect(screen.getByRole('status')).toHaveTextContent('Adding to Thursday…') + + plan = thursdaySwapped() + await act(async () => swap.send(jsonResponse(200, plan))) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + // The plan query is invalidated: the week is read again from the server. + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(2)) + + // The sheet opened again shows the plan as swapped. + const again = await openSheet() + expect(dayRows(again.days)[3]).toBe('Thursday 8 · Korean beef bowls · Stovetop · 30 min · This dinner') + expect(within(again.sheet).getByText('Korean beef bowls is already on Thursday this week.')).toBeInTheDocument() + expect(within(again.days).queryAllByRole('button')).toHaveLength(0) + }) + + it('offers to draft a week with no plan on the Plan, and swaps nothing', async () => { + const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found') }) + await renderPage() + + fireEvent.click(screen.getByRole('button', { name: 'Add to this week' })) + const sheet = screen.getByRole('dialog', { name: 'Add Korean beef bowls' }) + const draft = await within(sheet).findByRole('link', { name: 'Draft this week first' }) + expect(draft).toHaveAttribute('href', '/plan') + expect(within(sheet).getByText("There's no plan for this week yet, so there's no day to add it to.")).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(draft) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) + + it('shows a refused swap with the error, puts the day back and retries it', async () => { + let refuse = true + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`PUT ${THURSDAY}`]: () => (refuse ? errorResponse(500, 'internal') : jsonResponse(200, thursdaySwapped())), + }) + await renderPage() + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't add it to Thursday this week. internal message") + const reopened = await openSheet() + expect(dayRows(reopened.days)[3]).toBe('Thursday 8 · Chicken stir-fry · Stovetop · 30 min') + fireEvent.keyDown(reopened.sheet, { key: 'Escape' }) + + refuse = false + fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`, `${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it("leaves a push's Supply run outcome behind on the Plan, as a swap on the Draft board does", async () => { + let plan = planBody('2026-10-05', THIS_NAMES) + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan), + [`POST ${THIS_WEEK}/push-calendar`]: () => { + plan = { ...plan, status: 'synced', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, { ...plan, supply_run: { status: 'generated', error: null } }) + }, + [`PUT ${THURSDAY}`]: () => { + plan = { ...thursdaySwapped(), status: 'changed', calendar_synced_at: '2026-10-07T16:00:00Z' } + return jsonResponse(200, plan) + }, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + + // The week is pushed on the Plan, which says its Supply run was made. + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + // Coming back from the dinner with nothing swapped, it still says so. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + await act(() => router.navigate('/plan')) + expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.") + + // Thursday is swapped from the dinner's Add to this week. + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { days } = await openSheet() + fireEvent.click(within(days).getByRole('button', { name: /Thursday 8/ })) + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('Added to Thursday this week.')) + + // The Plan shows the swapped plan, not changed since its push, and no + // longer says the earlier plan's Supply run was made. + await act(() => router.navigate('/plan')) + expect(await screen.findByText('Changes not pushed to Google Calendar')).toBeInTheDocument() + expect(screen.getByText('Korean beef bowls')).toBeInTheDocument() + expect(screen.queryByRole('status')).not.toBeInTheDocument() + expect(screen.queryByText("Made this week's Supply run from the plan.")).not.toBeInTheDocument() + expect(sent(fetchMock, 'POST')).toEqual([`${THIS_WEEK}/push-calendar undefined`]) + expect(sent(fetchMock, 'PUT')).toEqual([`${THURSDAY} {"dinner_id":"d1"}`]) + }) + + it('refuses a swap while the week is being pushed to Calendar', async () => { + const push = deferred() + const fetchMock = stubServer({ + [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('2026-10-05', THIS_NAMES)), + [`POST ${THIS_WEEK}/push-calendar`]: () => push.response, + 'GET /api/v1/shopping/2026-10-05': () => errorResponse(404, 'not_found'), + }) + const router = await renderPage() + await act(() => router.navigate('/plan')) + fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' })) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + + await act(() => router.navigate('/dinner/d1')) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + const { sheet, days } = await openSheet() + expect(within(sheet).getByText('The plan for this week is being pushed to Calendar.')).toBeInTheDocument() + expect(within(days).queryAllByRole('button')).toHaveLength(0) + expect(sent(fetchMock, 'PUT')).toEqual([]) + }) +}) + +describe('the favorite heart', () => { + it('fills at once, keeps the recipe and takes the server answer', async () => { + const favorite = deferred() + const fetchMock = stubServer({ [`POST ${FAVORITE}`]: () => favorite.response }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'false') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'true')) + expect(sent(fetchMock, 'POST')).toEqual([`${FAVORITE} undefined`]) + + await act(async () => favorite.send(jsonResponse(200, listed(true)))) + await waitFor(() => expect(sent(fetchMock, 'POST')).toHaveLength(1)) + expect(heart).toHaveAttribute('aria-pressed', 'true') + // The list-shaped answer did not replace the detail: the recipe stays. + expect(screen.getByText('Ground beef')).toBeInTheDocument() + expect(screen.getByText('Brown the beef with the sauce.')).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('empties again with the error when the server refuses, and retries', async () => { + const refused = deferred() + let refuse = true + const fetchMock = stubServer({ + [`GET ${DINNER}`]: () => jsonResponse(200, detail({ is_favorite: true })), + [`POST ${FAVORITE}`]: () => (refuse ? refused.response : jsonResponse(200, listed(false))), + }) + await renderPage() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toHaveAttribute('aria-pressed', 'true') + + fireEvent.click(heart) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await act(async () => refused.send(errorResponse(500, 'internal'))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't unfavorite Korean beef bowls. internal message") + expect(heart).toHaveAttribute('aria-pressed', 'true') + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(heart).toHaveAttribute('aria-pressed', 'false')) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(fetchMock, 'POST')).toHaveLength(2) + }) +}) diff --git a/web/src/dinners/DinnerDetailOffline.test.tsx b/web/src/dinners/DinnerDetailOffline.test.tsx new file mode 100644 index 0000000..386a1ec --- /dev/null +++ b/web/src/dinners/DinnerDetailOffline.test.tsx @@ -0,0 +1,184 @@ +// A dinner's detail offline once visited: the page reads through the service +// worker's real API strategy (src/test/serviceWorker.ts), so what it shows +// offline is what the worker saved while it was online. jsdom has no service +// worker, so the strategy runs directly against an in-memory cache. +import { render, screen, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { OfflinePill } from '../pwa/OfflinePill.tsx' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheNames, routeFor } from '../sw/routes.ts' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' + +// grounded is a dinner as GET /api/v1/dinners/{id} sends it, its recipe found. +function grounded(id: string, name: string) { + return { + id, + name, + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': `/media/photos/${id}-400.jpg`, '1200': `/media/photos/${id}-1200.jpg` }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: null, + } +} + +// server is what the server sends while it can be reached. Kitchen reading +// mode also reads the household settings, which the worker never saves, and +// the week's plans to find the dinner's day, of which there are none here. +const server: Record = { + '/api/v1/settings/household': HOUSEHOLD_SETTINGS, + '/api/v1/dinners/d1': grounded('d1', 'Korean beef bowls'), + '/api/v1/dinners/d2': grounded('d2', 'Chicken tikka masala'), + '/api/v1/dinners/d3': { + ...grounded('d3', 'Pork carnitas'), + photo_urls: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'pending', + }, +} + +let cache: FakeCache +let reachable: boolean +// networkReads is every path the network was asked for, reached or not. +let networkReads: string[] + +beforeEach(() => { + cache = stubWorkerCaches() + reachable = true + networkReads = [] + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) + stubFetchThroughWorker(async (request) => { + const path = new URL(request.url).pathname + networkReads.push(path) + if (!reachable) throw new TypeError('Failed to fetch') + if (path.startsWith('/api/v1/plans/')) return errorResponse(404, 'not_found') + if (!Object.hasOwn(server, path)) throw new Error(`unexpected network fetch: ${request.url}`) + return jsonResponse(200, server[path]) + }) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +// open loads /dinner/{id} as a fresh page load would: a new API client, +// network status and query cache, with the offline indicator App shows. +function open(id: string) { + const router = createMemoryRouter( + [{ element: , children: [{ path: '/dinner/:id', element: }] }], + { initialEntries: [`/dinner/${id}`] }, + ) + return render( + + + + , + ) +} + +// visit opens a dinner while online and leaves once its title shows. +async function visit(id: string, name: string) { + const page = open(id) + expect(await screen.findByRole('heading', { level: 1, name })).toBeInTheDocument() + expect(screen.queryByText('Offline — showing saved data')).not.toBeInTheDocument() + page.unmount() +} + +describe('dinner detail offline', () => { + it('saves a visited dinner in the API cache the worker reads offline', async () => { + mockViewport(390) + expect(routeFor({ method: 'GET', url: new URL('/api/v1/dinners/d1', origin), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + await visit('d1', 'Korean beef bowls') + expect([...cache.entries.keys()]).toEqual([`${origin}/api/v1/dinners/d1`]) + }) + + it('shows a visited dinner offline from its saved copy: photo, ingredients and steps, with the offline indicator', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByRole('status')).toHaveTextContent('Offline — showing saved data') + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + 'Ground beef1 lb', + 'Jasmine rice2 cups', + ]) + expect(within(steps!).getAllByRole('listitem').map((row) => row.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + ]) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + }) + + it('shows a visited dinner offline in Kitchen reading mode on a tablet', async () => { + mockViewport(1180) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d1') + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveAttribute('src', '/media/photos/d1-400.jpg') + expect(within(screen.getByRole('region', { name: 'Ingredients' })).getAllByRole('checkbox')).toHaveLength(2) + expect(screen.getByRole('listitem', { current: 'step' })).toHaveTextContent('Cook the rice.') + }) + + it('shows an error, not a blank page or placeholder, for a dinner never visited', async () => { + mockViewport(390) + await visit('d1', 'Korean beef bowls') + + reachable = false + open('d2') + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument() + expect(screen.queryByText(/Korean beef bowls|Chicken tikka masala/)).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByLabelText('Dinner loading')).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + }) + + it('keeps a saved copy still finding its recipe as that, not a finished recipe', async () => { + mockViewport(390) + await visit('d3', 'Pork carnitas') + + reachable = false + open('d3') + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.getByText('Offline — showing saved data')).toBeInTheDocument() + // Read once online and tried once offline before the saved copy answered. + expect(networkReads.filter((path) => path.startsWith('/api/v1/dinners/'))).toEqual([ + '/api/v1/dinners/d3', + '/api/v1/dinners/d3', + ]) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.module.css b/web/src/dinners/DinnerDetailPage.module.css new file mode 100644 index 0000000..425564e --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.module.css @@ -0,0 +1,547 @@ +/* Mock 1k on mobile: the photo and the sheet run full-bleed past the + shell's 20 px side padding. From tablet up the page is Kitchen reading + mode (mock 2a, below). The page is at least as tall as the shell's scroll + area (a stretched flex item, so its height resolves the percentage) and + the sheet, or the reading view, grows into any room left, so the action + bar rests at the bottom of a short recipe. */ +.page { + display: flex; + flex-direction: column; + min-height: 100%; + font-family: var(--font-body); + color: var(--color-text); +} + +/* Kitchen reading mode is no wider than the handoff's tablet frame. */ +.tablet, +.desktop { + width: 100%; + max-width: 1180px; + margin: 0 auto; +} + +.hero { + position: relative; + height: 250px; +} + +.mobile .hero, +.mobile .skeletonHero { + margin: 0 -20px; +} + +/* Without a dinner to show, the back button sits alone above the message. */ +.backRow { + padding-top: calc(16px + env(safe-area-inset-top)); +} + +.backRow .back { + position: static; +} + +.photo { + width: 100%; + height: 100%; +} + +.back { + position: absolute; + top: calc(16px + env(safe-area-inset-top)); + left: 20px; +} + +/* The sheet overlaps the photo by 28 px. */ +.sheet { + position: relative; + flex-grow: 1; + margin-top: -28px; + display: flex; + flex-direction: column; + gap: 10px; + padding: 22px 20px 0; + border-radius: 32px 32px 0 0; + background: var(--color-bg); +} + +.mobile .sheet { + margin-left: -20px; + margin-right: -20px; +} + +.tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 28px; + font-weight: 400; + line-height: 1.05; +} + +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-size: 13px; + line-height: 1.45; +} + +.section { + display: flex; + flex-direction: column; + margin-top: 6px; +} + +.sectionTitle { + margin: 0 0 4px; + font-family: var(--font-heading); + font-size: 18px; + font-weight: 400; + line-height: 1.15; +} + +.ingredients, +.steps { + margin: 0; + padding: 0; + list-style: none; + font-size: 14px; + line-height: 1.45; +} + +.ingredient { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--color-divider); +} + +.ingredient:last-child { + border-bottom: 0; +} + +.note, +.qty { + color: var(--color-neutral-700); +} + +.qty { + flex: none; + text-align: right; +} + +.step { + display: flex; + gap: 12px; + padding: 8px 0; +} + +.number { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-pill); + background: var(--color-accent-100); + color: var(--color-accent-800); + font-size: 12px; + font-weight: 700; +} + +.source { + margin: 4px 0 0; + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.source a { + color: inherit; +} + +.missing { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + margin: 6px 0 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-neutral-100); + color: var(--color-neutral-800); + font-size: 13px; + line-height: 1.45; +} + +.missing p { + margin: 0; +} + +/* A 44 px tall tap target for the link. */ +.fix { + display: inline-flex; + align-items: center; + min-height: 44px; + color: var(--color-accent-700); + font-weight: 700; +} + +/* At the bottom of the page, which fills the scroll area, and held at the + bottom of the screen over a long recipe. Sticky offsets count from inside + the scroll area's padding, which on mobile already keeps clear of the nav + bar. */ +.bar { + position: sticky; + bottom: 0; + z-index: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 24px; + padding: 10px 0; + background: var(--color-bg); +} + +.add { + flex: 1; + min-height: 52px; + font-size: 16px; +} + +/* What became of an add or a favorite: a full row above the buttons. */ +.status { + order: -1; + flex-basis: 100%; +} + +.added { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + font-weight: 600; + color: var(--color-accent-2-800); +} + +.filled { + color: var(--color-accent); + fill: currentColor; +} + +.skeletonHero { + height: 250px; + border-radius: 0; +} + +.skeletonTitle { + height: 32px; + width: 70%; +} + +/* Mock 2a, Kitchen reading mode: the header over two columns, the + ingredients card (400 px) and the steps. Ingredient and step text scale + with the text size chosen: the handoff's sizes are medium. */ +.reading { + flex-grow: 1; + display: flex; + flex-direction: column; + gap: 22px; +} + +.small { + --reading-scale: 0.85; +} + +.medium { + --reading-scale: 1; +} + +.large { + --reading-scale: 1.25; +} + +.readingHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 18px; +} + +.readingHeader .back { + position: static; + flex: none; +} + +.heading { + flex: 1 1 240px; + min-width: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.kicker { + margin: 0; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.readingTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 36px; + font-weight: 400; + line-height: 1.05; +} + +.keepOn { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + font-weight: 700; +} + +.keepOnHint { + margin: 0; + font-size: 14px; + line-height: 1.4; + color: var(--color-neutral-700); +} + +/* The A / A / A control: an outlined pill, the size chosen in grape, each + A drawn at the size it stands for. */ +.readingHeader .textSize { + gap: 0; + padding: 0; + border: 1px solid var(--color-divider); + background: transparent; + overflow: hidden; +} + +.readingHeader .textSize > * { + padding: 0 14px; + border-radius: 0; + color: var(--color-text); +} + +.readingHeader .textSize > [aria-checked="true"] { + background: var(--color-accent); + color: var(--on-accent); + box-shadow: none; +} + +.readingHeader .textSize > :nth-child(1) { + font-size: 13px; +} + +.readingHeader .textSize > :nth-child(2) { + font-size: 17px; +} + +.readingHeader .textSize > :nth-child(3) { + font-size: 21px; +} + +.columns { + flex-grow: 1; + display: grid; + grid-template-columns: 400px minmax(0, 1fr); + gap: 28px; +} + +/* Below a 1024 px viewport the shell leaves too little beside a 400 px + card for the steps, so the columns share the width instead. */ +@media (max-width: 1023px) { + .columns { + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + } +} + +.oneColumn { + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.columns .ingredientsCard { + display: flex; + flex-direction: column; + gap: 6px; + padding: 22px; + border-radius: 30px; +} + +.cardHead { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 6px; +} + +.readingSectionTitle { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; + line-height: 1.15; +} + +.setOutCount { + flex: none; + white-space: nowrap; + font-size: 13px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.checklist { + margin: 0; + padding: 0; + list-style: none; +} + +.checkRow { + display: flex; + align-items: center; + gap: 14px; + padding: 4px 0; + border-bottom: 1px solid var(--color-divider); +} + +.checkRow .check { + flex: 1; + min-width: 0; + gap: 14px; +} + +.checkName { + font-size: calc(18px * var(--reading-scale)); + font-weight: 400; + line-height: 1.3; +} + +.setOut { + color: var(--color-neutral-600); + text-decoration: line-through; +} + +.checkQty { + flex: none; + text-align: right; + font-size: calc(15px * var(--reading-scale)); + color: var(--color-neutral-700); +} + +.method { + flex-grow: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 14px; +} + +.intro { + display: flex; + align-items: center; + gap: 16px; +} + +.introPhoto { + flex: 1; + min-width: 0; + height: 96px; +} + +.introWhy { + flex: 1.6; + padding: 14px 18px; + border-radius: 22px; + font-size: 14px; +} + +.readingSteps { + display: flex; + flex-direction: column; + gap: 14px; + margin-top: 4px; +} + +.stepList { + display: flex; + flex-direction: column; + gap: 14px; + margin: 0; + padding: 0; + list-style: none; +} + +.readingStep { + display: flex; + align-items: flex-start; + gap: 16px; + padding: 6px 18px; + border-radius: 26px; + font-size: calc(18px * var(--reading-scale)); + line-height: 1.45; +} + +.stepCircle { + flex: none; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-pill); + background: var(--color-surface); + font-family: var(--font-heading); + font-size: 18px; +} + +.stepText { + padding-top: 6px; +} + +.stepDone { + color: var(--color-neutral-600); +} + +.stepCurrent { + padding: 18px; + background: var(--color-accent-100); + box-shadow: inset 0 0 0 2px var(--color-accent-300); + font-size: calc(22px * var(--reading-scale)); +} + +.stepCurrent .stepCircle { + background: var(--color-accent); + color: var(--on-accent); +} + +/* The action bar rests at the bottom of the steps column, with Previous + and Next step at its right. */ +.reading .bar { + margin-top: auto; +} + +.reading .add { + flex: none; +} + +.stepButtons { + display: flex; + gap: 10px; + margin-left: auto; +} diff --git a/web/src/dinners/DinnerDetailPage.test.tsx b/web/src/dinners/DinnerDetailPage.test.tsx new file mode 100644 index 0000000..021f39f --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.test.tsx @@ -0,0 +1,473 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { AppShell } from '../shell/AppShell.tsx' +import { cacheFallbackHeader } from '../sw/routes.ts' +import { ApiProviders } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse, stubFetchByPath } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { GROUNDING_POLL_MS } from './api.ts' +import { DinnerDetailPage, REGULAR_DINNERS } from './DinnerDetailPage.tsx' +import sourceCases from './testdata/source_url_cases.json' + +const DINNER = '/api/v1/dinners/d1' + +// detail is a dinner as GET /api/v1/dinners/{id} sends it: mock 1k's +// Korean beef bowls, a Side quest grounded in a page that named its site. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: { '400': '/media/photos/p1-400.jpg', '1200': '/media/photos/p1-1200.jpg' }, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Soy sauce', qty: '¼', unit: 'cup', note: null, raw_text: '¼ cup soy sauce' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: ['Cook the rice.', 'Brown the beef with the sauce.', 'Serve over rice with the onions.'], + source: { site_name: 'Serious Eats' }, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos and Chicken stir-fry.', + ...overrides, + } +} + +// A recipe the household typed in: only raw_text, no source. +const MANUAL = { + status: 'regular', + origin: 'regular', + photo_urls: null, + ingredients: [ + { name: null, qty: null, unit: null, note: null, raw_text: '2 lb chuck roast' }, + { name: null, qty: null, unit: null, note: null, raw_text: 'A splash of red wine' }, + ], + steps: ['Sear the roast.', 'Slow cook 8 hours.'], + source: null, + grounding_status: 'manual', + why_picked: null, +} + +// A Regular whose recipe could not be found: no recipe at all. +const FAILED = { + status: 'regular', + origin: 'regular', + photo_urls: null, + minutes_total: null, + difficulty: null, + ingredients: [], + steps: [], + source: null, + grounding_status: 'failed', + grounding_error: 'no_recipe_found', + why_picked: null, +} + +// renderAt renders the detail screen at the last of entries, inside the +// app shell's scroll area as App routes it, with probes for where its links +// and back button go. +function renderAt(entries: string[]) { + const router = createMemoryRouter( + [ + { element: , children: [{ path: '/dinner/:id', element: }] }, + { path: '/', element:

    Home probe

    }, + { path: '/plan', element:

    Plan probe

    }, + { path: REGULAR_DINNERS, element:

    Regular dinners probe

    }, + ], + { initialEntries: entries, initialIndex: entries.length - 1 }, + ) + render( + + + , + ) + return router +} + +function serve(body: unknown) { + return stubFetchByPath({ [DINNER]: () => jsonResponse(200, body) }) +} + +beforeEach(() => { + mockViewport(390) +}) + +afterEach(() => { + Reflect.deleteProperty(window, 'matchMedia') + vi.unstubAllGlobals() +}) + +describe('DinnerDetailPage', () => { + describe('a grounded Side quest', () => { + it('shows its photo washed and full-bleed, tags, title and why it was picked', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })).toBeInTheDocument() + const photo = screen.getByRole('img', { name: 'Korean beef bowls' }) + expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(photo).toHaveAttribute('srcset', '/media/photos/p1-400.jpg 400w, /media/photos/p1-1200.jpg 1200w') + expect(declaredStyle(photo.parentElement!).height).toBe('250px') + expect(declaredStyle(photo.parentElement!).margin).toBe('0px -20px') + + expect(screen.getByText('Side quest')).toBeInTheDocument() + expect(screen.getByText('Easy')).toBeInTheDocument() + expect(screen.getByText('30 min')).toBeInTheDocument() + expect(screen.queryByText('Regular')).not.toBeInTheDocument() + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent( + 'Why it was picked: close cousin of your Beef tacos and Chicken stir-fry.', + ) + }) + + it('lists ingredients in recipe order with quantities on the right, then numbered steps', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + const rows = within(screen.getAllByRole('list')[0]!).getAllByRole('listitem') + expect(rows.map((row) => Array.from(row.children, (cell) => cell.textContent))).toEqual([ + ['Ground beef', '1 lb'], + ['Jasmine rice', '2 cups'], + ['Soy sauce', '¼ cup'], + ['Green onions, sliced', '1 bunch'], + ]) + const qty = rows[0]!.lastElementChild! + expect(declaredStyle(qty).textAlign).toBe('right') + expect(declaredStyle(rows[0]!).justifyContent).toBe('space-between') + expect(declaredStyle(rows[0]!).borderBottom).toBe('1px solid var(--color-divider)') + + expect(screen.getByRole('heading', { level: 2, name: 'Steps' })).toBeInTheDocument() + const steps = screen.getAllByRole('list')[1]! + expect(steps.tagName).toBe('OL') + expect(within(steps).getAllByRole('listitem').map((step) => step.textContent)).toEqual([ + '1Cook the rice.', + '2Brown the beef with the sauce.', + '3Serve over rice with the onions.', + ]) + }) + + it('credits the site the recipe came from, with no link to it', async () => { + serve(detail()) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Serious Eats') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('links the credit to the recipe in a new tab once the server sends a safe url', async () => { + serve(detail({ source: { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from Serious Eats' }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['http', 'http://www.seriouseats.com/korean-beef-bowls'], + ['a password', 'https://user:secret@www.seriouseats.com/korean-beef-bowls'], + ['a query', 'https://www.seriouseats.com/korean-beef-bowls?token=abc'], + ['a fragment', 'https://www.seriouseats.com/korean-beef-bowls#token=abc'], + ])('refuses a source url with %s', async (_, url) => { + serve(detail({ source: { site_name: 'Serious Eats', url } })) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent('source url is not an https link') + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + // Each source exactly as the Go handler sends it for a stored link + // (internal/server's TestGetDinnerSourceURLOnlyWhenSafe checks the same + // file), so every url the server sends must parse here. + it.each(sourceCases.cases)('renders the source the server sends for $stored', async ({ source }) => { + serve(detail({ source })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from Curry House') + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + if (source.url === null) { + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + } else { + expect(screen.getByRole('link', { name: 'Recipe from Curry House' })).toHaveAttribute('href', new URL(source.url).href) + } + }) + + it("links the credit by the url's host when the server names no site", async () => { + serve(detail({ source: { site_name: null, url: 'https://Kitchen.Example:8443/korean-beef-bowls' } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByRole('link', { name: 'Recipe from kitchen.example:8443' }) + expect(credit).toHaveAttribute('href', 'https://kitchen.example:8443/korean-beef-bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + // The server withholds both when the stored name could be the link's + // host and the link could carry a token: the credit names neither. + it('credits the web in plain text when the server sends neither site nor url', async () => { + serve(detail({ source: { site_name: null, url: null } })) + renderAt(['/dinner/d1']) + + const credit = await screen.findByText('Recipe from the web') + expect(credit.closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('holds Add to this week and the heart at the bottom', async () => { + serve(detail({ is_favorite: true })) + renderAt(['/dinner/d1']) + + const add = await screen.findByRole('button', { name: 'Add to this week' }) + expect(add).toBeEnabled() + const heart = screen.getByRole('button', { name: 'Favorite' }) + expect(heart).toBeEnabled() + expect(heart).toHaveAttribute('aria-pressed', 'true') + const bar = heart.parentElement! + expect(bar).toContainElement(add) + expect(declaredStyle(bar).position).toBe('sticky') + expect(declaredStyle(bar).bottom).toBe('0px') + }) + }) + + // jsdom does no layout, so no test here can measure where the bar lands. + // This pins the CSS chain that puts it at the bottom of the screen on a + // short recipe: the shell's scroll area is a full-height flex item, the + // page fills it as a column, the sheet takes the room left and the bar is + // the page's last child. Drop any link and the bar sits right after the + // content again, and this fails. + it('fills the scroll area so the bar rests at the bottom of a short recipe', async () => { + serve(detail(FAILED)) + renderAt(['/dinner/d1']) + + const bar = (await screen.findByRole('button', { name: 'Favorite' })).parentElement! + const page = bar.parentElement! + const main = page.parentElement! + const shell = main.parentElement! + expect(main.tagName).toBe('MAIN') + expect(declaredStyle(shell).height).toBe('100dvh') + expect(declaredStyle(shell).display).toBe('flex') + expect(declaredStyle(shell).alignItems).toBe('') + expect(declaredStyle(main).overflowY).toBe('auto') + + expect(declaredStyle(page).minHeight).toBe('100%') + expect(declaredStyle(page).display).toBe('flex') + expect(declaredStyle(page).flexDirection).toBe('column') + expect(page.lastElementChild).toBe(bar) + const sheet = screen.getByRole('article') + expect(sheet.parentElement).toBe(page) + expect(sheet.nextElementSibling).toBe(bar) + expect(declaredStyle(sheet).flexGrow).toBe('1') + expect(declaredStyle(bar).position).toBe('sticky') + }) + + it('shows the striped placeholder when the dinner has no photo', async () => { + serve(detail({ photo_urls: null })) + renderAt(['/dinner/d1']) + + const placeholder = await screen.findByRole('img', { name: 'Korean beef bowls' }) + expect(placeholder.tagName).toBe('DIV') + expect(placeholder).toHaveTextContent('dish photo') + }) + + it('shows a manual Regular line by line, as typed, with no credit', async () => { + serve(detail(MANUAL)) + renderAt(['/dinner/d1']) + + expect(await screen.findByText('Regular')).toBeInTheDocument() + expect(screen.queryByText('Side quest')).not.toBeInTheDocument() + const [ingredients, steps] = screen.getAllByRole('list') + expect(within(ingredients!).getAllByRole('listitem').map((row) => row.firstElementChild!.textContent)).toEqual([ + '2 lb chuck roast', + 'A splash of red wine', + ]) + expect(within(steps!).getAllByRole('listitem')).toHaveLength(2) + expect(screen.queryByText(/Why it was picked/)).not.toBeInTheDocument() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + expect(screen.getByRole('img', { name: 'Korean beef bowls' })).toHaveTextContent('dish photo') + }) + + it('links a dinner whose recipe was not found to Settings → Regular dinners, with no recipe', async () => { + serve(detail(FAILED)) + const router = renderAt(['/dinner/d1']) + + const fix = await screen.findByRole('link', { name: 'Fix recipe' }) + expect(screen.getByText(/couldn't find a recipe/)).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'Steps' })).not.toBeInTheDocument() + expect(screen.queryByRole('list')).not.toBeInTheDocument() + + fireEvent.click(fix) + expect(await screen.findByRole('heading', { name: 'Regular dinners probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe(REGULAR_DINNERS) + }) + + it('says it is still finding a recipe while grounding is pending', async () => { + serve(detail({ ...FAILED, grounding_status: 'pending', grounding_error: null })) + renderAt(['/dinner/d1']) + + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'Fix recipe' })).not.toBeInTheDocument() + }) + + describe('while the recipe is being found', () => { + const PENDING = detail({ ...FAILED, grounding_status: 'pending', grounding_error: null }) + const GROUNDED = detail({ grounding_status: 'grounded' }) + let onLine: boolean + let body: unknown + let fromCache: boolean + let fetchMock: Mock + + const reads = () => fetchMock.mock.calls.length + const tick = (intervals = 1) => act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * intervals)) + + function setOnline(value: boolean) { + onLine = value + act(() => { + window.dispatchEvent(new Event(value ? 'online' : 'offline')) + }) + } + + beforeEach(() => { + // Only intervals are faked: TanStack Query polls with setInterval, and + // Testing Library's waits keep their real timers. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + onLine = true + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine) + body = PENDING + fromCache = false + fetchMock = stubFetchByPath({ + [DINNER]: () => + new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json', ...(fromCache ? { [cacheFallbackHeader]: '1' } : {}) }, + }), + }) + }) + + // An offline event leaves TanStack Query's online manager, shared by + // every test, offline until an online event. + afterEach(() => { + window.dispatchEvent(new Event('online')) + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('reads the dinner again until the recipe lands, then stops', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick() + await waitFor(() => expect(reads()).toBe(2)) + + body = GROUNDED + await tick() + expect(await screen.findByRole('heading', { level: 2, name: 'Ingredients' })).toBeInTheDocument() + expect(reads()).toBe(3) + + await tick(3) + expect(reads()).toBe(3) + }) + + it('pauses while the browser is offline and resumes when it is back', async () => { + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + setOnline(false) + await tick(3) + expect(reads()).toBe(1) + + setOnline(true) + await tick() + await waitFor(() => expect(reads()).toBe(2)) + }) + + it('reads a saved pending copy offline once, then waits for the connection', async () => { + onLine = false + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await tick(3) + expect(reads()).toBe(1) + + // The connection comes back: the saved copy is read again at once, + // and polling goes on once the server answers. + fromCache = false + setOnline(true) + await waitFor(() => expect(reads()).toBe(2)) + await tick() + await waitFor(() => expect(reads()).toBe(3)) + }) + + it('stops polling on a saved copy even while the browser says it is online', async () => { + fromCache = true + renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + + await tick(3) + expect(reads()).toBe(1) + }) + + it('stops polling when the page is left', async () => { + const router = renderAt(['/dinner/d1']) + expect(await screen.findByText(/Still finding the recipe/)).toBeInTheDocument() + expect(reads()).toBe(1) + + await act(() => router.navigate('/')) + expect(screen.getByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + await tick(3) + expect(reads()).toBe(1) + }) + }) + + it('says an unknown or removed dinner is not found', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(404, 'not_found') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument() + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: 'Go Home' })).toHaveAttribute('href', '/') + }) + + it('reports any other failure with a retry', async () => { + stubFetchByPath({ [DINNER]: () => errorResponse(500, 'internal') }) + renderAt(['/dinner/d1']) + + expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load this dinner.") + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() + }) + + describe('the back button', () => { + it('goes back in the app', async () => { + serve(detail()) + const router = renderAt(['/plan', '/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Plan probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/plan') + }) + + it('goes Home when the dinner was opened from a link', async () => { + serve(detail()) + const router = renderAt(['/dinner/d1']) + + fireEvent.click(await screen.findByRole('button', { name: 'Back' })) + expect(await screen.findByRole('heading', { name: 'Home probe' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/') + }) + }) +}) diff --git a/web/src/dinners/DinnerDetailPage.tsx b/web/src/dinners/DinnerDetailPage.tsx new file mode 100644 index 0000000..9874787 --- /dev/null +++ b/web/src/dinners/DinnerDetailPage.tsx @@ -0,0 +1,582 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Check, ChevronLeft, ChevronRight, Heart } from 'lucide-react' +import { useCallback, useEffect, useId, useState, type ReactNode } from 'react' +import { Link, useLocation, useNavigate, useParams } from 'react-router' +import { ApiError } from '../api/errors.ts' +import { planKey, usePlan, useWeekSwap } from '../plan/api.ts' +import { duration } from '../plan/PlanPage.tsx' +import { addDays, dayAndDate, localNow, weekdayName, weekStart } from '../plan/week.ts' +import { useHouseholdSettings } from '../settings/api.ts' +import { useBreakpoint } from '../shell/useBreakpoint.ts' +import { Button } from '../ui/Button.tsx' +import buttonStyles from '../ui/Button.module.css' +import { Card } from '../ui/Card.tsx' +import { Checkbox } from '../ui/Checkbox.tsx' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Segmented, type SegmentedOption } from '../ui/Segmented.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { Tag } from '../ui/Tag.tsx' +import { Toggle } from '../ui/Toggle.tsx' +import { AddToWeekSheet, weekNames, type Added } from './AddToWeekSheet.tsx' +import { useToggleFavorite, type Difficulty } from './api.ts' +import { useDinnerDetail, type DinnerDetail, type Ingredient, type RecipeSource } from './detail.ts' +import styles from './DinnerDetailPage.module.css' +import { useKeepScreenOn } from './keepScreenOn.ts' +import { textSizeFailureText, useTextSize, type TextSize } from './textSize.ts' + +// REGULAR_DINNERS is Settings → Regular dinners, where a recipe is fixed. +export const REGULAR_DINNERS = '/settings/regulars' + +const difficultyLabels: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// BackButton goes back in the app's history, or, opened from a link with +// no history in the app, to Home. +function BackButton() { + const navigate = useNavigate() + const location = useLocation() + // The first entry of a session has the key "default". + const back = () => (location.key === 'default' ? void navigate('/', { replace: true }) : void navigate(-1)) + return ( + + + + ) +} + +// quantity is an ingredient's amount as the right-hand column shows it, +// "1 lb", or empty when the recipe gives none. +function quantity(ingredient: Ingredient): string { + return [ingredient.qty, ingredient.unit].filter(Boolean).join(' ') +} + +// IngredientRow is one ingredient: its name with any note, or the line as +// the recipe gives it when there is no name, and its amount on the right. +function IngredientRow({ ingredient }: { ingredient: Ingredient }) { + return ( +
  • + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && , {ingredient.note}} + + {quantity(ingredient)} +
  • + ) +} + +// SourceCredit is "Recipe from {site}" for a recipe fetched from the web: a +// link opening the recipe's page in a new tab when the server sends a url +// safe to open, else plain text. The site is the name the page gave, else +// the url's host — already in the link, so naming it shows nothing more — +// else, with neither to show, just "the web": a fetched recipe always +// credits where it came from, without a name the server withheld. +function SourceCredit({ siteName, url }: RecipeSource) { + const credit = `Recipe from ${siteName ?? (url === null ? 'the web' : new URL(url).host)}` + return ( +

    + {url === null ? ( + credit + ) : ( + + {credit} + + )} +

    + ) +} + +// hasRecipe says whether the dinner's recipe was found or typed in, rather +// than still being searched for or not found. +function hasRecipe(dinner: DinnerDetail): boolean { + return dinner.grounding_status !== 'pending' && dinner.grounding_status !== 'failed' +} + +// RecipeMissing is what stands in for a recipe still being searched for, or +// the link to fix one that could not be found. +function RecipeMissing({ dinner }: { dinner: DinnerDetail }) { + if (dinner.grounding_status === 'failed') { + return ( +
    +

    We couldn't find a recipe for this dinner, so there are no ingredients or steps yet.

    + + Fix recipe + +
    + ) + } + return

    Still finding the recipe — its ingredients and steps will show here.

    +} + +// Recipe is the dinner's ingredients and steps, or what stands in for them. +function Recipe({ dinner }: { dinner: DinnerDetail }) { + if (!hasRecipe(dinner)) return + return ( + <> + {dinner.ingredients.length > 0 && ( +
    +

    Ingredients

    +
      + {dinner.ingredients.map((ingredient, i) => ( + + ))} +
    +
    + )} + {dinner.steps.length > 0 && ( +
    +

    Steps

    +
      + {dinner.steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + )} + {dinner.source !== null && } + + ) +} + +// AddedStatus says what became of a dinner added to a day: being saved, +// added, or why it couldn't be, with Retry, which sends the same swap again +// as the Draft board would (useWeekSwap), and is not offered while that is +// refused. The week's plan stays read while it shows, so a retry finds the +// day to swap; once added, the plan is read again from the server. +function AddedStatus({ added }: { added: Added }) { + const queryClient = useQueryClient() + usePlan(added.week) + const { swap, refused, swapIn } = useWeekSwap(added.week) + const { date } = added.swap + const done = swap.succeeded === added.swap + const failed = swap.failed?.swap === added.swap ? swap.failed.error : null + useEffect(() => { + if (done) void queryClient.invalidateQueries({ queryKey: planKey(added.week) }) + }, [done, queryClient, added.week]) + + if (failed !== null) { + return ( +
    + swapIn(added.swap)}> + Couldn't add it to {weekdayName(date)} {weekNames[added.which]}. {failed.message} + +
    + ) + } + return ( +

    + {done ? `Added to ${weekdayName(date)} ${weekNames[added.which]}.` : `Adding to ${weekdayName(date)}…`} +

    + ) +} + +// AddToWeek is Add to this week: it opens the sheet of the week's days, and +// says what became of the dinner added. +function AddToWeek({ dinner }: { dinner: DinnerDetail }) { + const [open, setOpen] = useState(false) + const [added, setAdded] = useState(null) + function add(chosen: Added) { + setOpen(false) + setAdded(chosen) + } + return ( + <> + {added !== null && } + + {open && setOpen(false)} />} + + ) +} + +// FavoriteHeart toggles whether the dinner is a favorite, at once, changing +// back with the error and Retry if the server refuses. One toggle is sent at +// a time. +function FavoriteHeart({ dinner }: { dinner: DinnerDetail }) { + const favorite = useToggleFavorite() + function toggle() { + if (!favorite.isPending) favorite.mutate({ id: dinner.id, is_favorite: dinner.is_favorite }) + } + return ( + <> + {favorite.isError && ( +
    + + Couldn't {dinner.is_favorite ? 'unfavorite' : 'favorite'} {dinner.name}. {favorite.error.message} + +
    + )} + + + + + ) +} + +// ActionBar is the bar at the bottom of the screen, the page's last child: +// the page fills the scroll area so a short recipe still leaves it at the +// bottom, and it sticks there over a long one. It holds Add to this week +// and the favorite heart, with what became of either above them, and in +// Kitchen reading mode the step buttons after them. +function ActionBar({ dinner, children }: { dinner: DinnerDetail; children?: ReactNode }) { + return ( +
    + + + {children} +
    + ) +} + +// Detail is a dinner as mock 1k shows it: the photo full-bleed under a sheet +// with its tags, title, why it was picked and its recipe, over the action bar. +function Detail({ dinner }: { dinner: DinnerDetail }) { + const tags = [ + dinner.difficulty !== null && difficultyLabels[dinner.difficulty], + dinner.minutes_total !== null && duration(dinner.minutes_total), + ].filter((tag) => tag !== false) + return ( + <> +
    + + +
    +
    +
    + {dinner.status === 'regular' ? 'Regular' : 'Side quest'} + {tags.map((tag) => ( + + {tag} + + ))} +
    +

    {dinner.name}

    + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} + +
    + + + ) +} + +// statusName is the dinner as the household calls it: a Regular, or a Side +// quest for any dinner not yet one. +function statusName(dinner: DinnerDetail): string { + return dinner.status === 'regular' ? 'Regular' : 'Side quest' +} + +// PlannedDay is the kicker of a dinner on a plan, "Saturday, Oct 10 · Side +// quest", whenever this week's or next week's plan has it. Of the days that +// do, it is the first from today on in the household's timezone; when every +// one is already past, it is the latest of them. There is none while the +// plans are read, nor when neither plan has the dinner. +function PlannedDay({ dinner, timezone }: { dinner: DinnerDetail; timezone: string }) { + const today = localNow(timezone, new Date()).date + const thisWeek = weekStart(today) + const plans = [usePlan(thisWeek), usePlan(addDays(thisWeek, 7))] + const failed = plans.find((plan) => plan.isError) + if (failed !== undefined) { + return ( + plans.forEach((plan) => plan.isError && void plan.refetch())}> + Couldn't check the plan for this dinner's day. {failed.error.message} + + ) + } + const planned = plans.flatMap((plan) => plan.data?.days ?? []).filter((day) => day.dinner.id === dinner.id) + const day = planned.find((day) => day.date >= today) ?? planned.at(-1) + if (day === undefined) return null + return ( +

    + {dayAndDate(day.date)} · {statusName(dinner)} +

    + ) +} + +// Kicker is PlannedDay, once the household's timezone is read. +function Kicker({ dinner }: { dinner: DinnerDetail }) { + const settings = useHouseholdSettings() + if (settings.isError) { + return ( + void settings.refetch()}> + Couldn't load the household settings to find this dinner's day. {settings.error.message} + + ) + } + if (settings.isPending) return null + return +} + +const textSizes: SegmentedOption[] = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'medium', label: 'A', ariaLabel: 'Medium text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, +] + +// ReadingHeader is Kitchen reading mode's header: back, the day the dinner +// is planned for, its title, Keep screen on and the text size. +function ReadingHeader({ dinner, textSize }: { dinner: DinnerDetail; textSize: ReturnType }) { + const keepOnId = useId() + const keepOnHintId = useId() + const keepScreenOn = useKeepScreenOn() + const { failure } = textSize + return ( + <> +
    + +
    + +

    {dinner.name}

    +
    +
    + Keep screen on + +
    + +
    + {!keepScreenOn.supported && ( +

    + This browser can't keep the screen on, so it may dim while you cook. +

    + )} + {keepScreenOn.failure !== null && ( + keepScreenOn.setOn(true)}> + Couldn't keep the screen on: {keepScreenOn.failure} + + )} + {failure !== null && ( + + {textSizeFailureText[failure.action]}: {failure.message} + + )} + + ) +} + +// IngredientsCard is the ingredients with a checkbox each, struck through +// once set out, and how many are. Which are set out is this visit's alone: +// it is never sent to the server and is not the Supply run's "have it". +function IngredientsCard({ ingredients }: { ingredients: Ingredient[] }) { + const titleId = useId() + const [setOut, setSetOut] = useState>(() => new Set()) + const count = ingredients.filter((_, i) => setOut.has(i)).length + function toggle(i: number) { + setSetOut((before) => { + const after = new Set(before) + if (!after.delete(i)) after.add(i) + return after + }) + } + return ( + +
    +

    + Ingredients +

    + + {count} of {ingredients.length} set out + +
    +
      + {ingredients.map((ingredient, i) => ( +
    • + toggle(i)} + label={ + + {ingredient.name ?? ingredient.raw_text} + {ingredient.name !== null && ingredient.note !== null && `, ${ingredient.note}`} + + } + /> + {quantity(ingredient)} +
    • + ))} +
    +
    + ) +} + +// Steps is the recipe's steps: those before current done, muted with a +// check; current highlighted in larger text; the rest still to come. +function Steps({ steps, current }: { steps: string[]; current: number }) { + const titleId = useId() + return ( +
    +

    + Steps +

    +
      + {steps.map((step, i) => ( +
    1. + + {step} +
    2. + ))} +
    +
    + ) +} + +// typing says whether target is a field taking text, where the arrow keys +// move the caret and are not the screen's to take. +function typing(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + return target.isContentEditable || target.matches('textarea, select, input:not([type="checkbox"], [type="radio"])') +} + +// useCurrentStep is the step being cooked, of count, from the first, with +// move, which goes back or on by a step and stops at either end. The ← and → +// keys move it too, unless a text field has focus, a sheet is open over the +// screen or the key was already taken (a segmented control's arrows). +function useCurrentStep(count: number): [number, (by: number) => void] { + const [step, setStep] = useState(0) + const last = count - 1 + const move = useCallback( + (by: number) => setStep((before) => Math.max(0, Math.min(Math.min(before, last) + by, last))), + [last], + ) + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + const by = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0 + if (by === 0 || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (typing(event.target) || (event.target instanceof Element && event.target.closest('[role="dialog"]') !== null)) return + event.preventDefault() + move(by) + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [move]) + return [Math.max(0, Math.min(step, last)), move] +} + +// KitchenReading is a dinner as mock 2a shows it on a tablet: the header, +// then the ingredients to check off beside the photo, why it was picked and +// the steps, one at a time, over the action bar with Previous and Next step. +// What is checked and the step reached last only as long as this visit. +function KitchenReading({ dinner }: { dinner: DinnerDetail }) { + const textSize = useTextSize() + const [current, move] = useCurrentStep(dinner.steps.length) + const recipe = hasRecipe(dinner) + const last = dinner.steps.length - 1 + return ( +
    + +
    0 ? styles.columns : styles.oneColumn}> + {recipe && dinner.ingredients.length > 0 && } +
    +
    + + {dinner.why_picked !== null && ( +

    + Why it was picked: {dinner.why_picked} +

    + )} +
    + {!recipe && } + {recipe && dinner.steps.length > 0 && } + {recipe && dinner.source !== null && ( + + )} + + {recipe && dinner.steps.length > 0 && ( +
    + + +
    + )} +
    +
    +
    +
    + ) +} + +// DinnerDetailPage is a dinner with its recipe: on a phone (mock 1k) its +// photo, or the striped placeholder, under a sheet with Regular or Side +// quest, difficulty and time, why it was picked, the ingredients and the +// steps; from tablet up, Kitchen reading mode (mock 2a). A dinner whose +// recipe could not be found links to Settings → Regular dinners to fix it; +// an unknown or removed dinner says it is not found. +export function DinnerDetailPage() { + const { id = '' } = useParams() + const layout = useBreakpoint() + const dinner = useDinnerDetail(id) + + let body + if (dinner.isError) { + body = ( + <> +
    + +
    + {dinner.error instanceof ApiError && dinner.error.status === 404 ? ( + <> + This dinner isn't in our kitchen — it may have been removed. + + Go Home + + + ) : ( + void dinner.refetch()}>Couldn't load this dinner. {dinner.error.message} + )} + + ) + } else if (dinner.isPending) { + body = ( +
    + +
    + + + +
    +
    + ) + } else { + // Keyed by the dinner, so moving to another dinner starts its visit + // afresh: nothing checked, on the first step. + body = + layout === 'mobile' ? : + } + return
    {body}
    +} diff --git a/web/src/dinners/KitchenReading.test.tsx b/web/src/dinners/KitchenReading.test.tsx new file mode 100644 index 0000000..e393ae6 --- /dev/null +++ b/web/src/dinners/KitchenReading.test.tsx @@ -0,0 +1,604 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx' +import { declaredStyle } from '../test/cssRules.ts' +import { errorResponse, jsonResponse } from '../test/fetchStub.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { DinnerDetailPage } from './DinnerDetailPage.tsx' +import { TEXT_SIZE_KEY } from './textSize.ts' + +const DINNER = '/api/v1/dinners/d1' +const SETTINGS = '/api/v1/settings/household' +const THIS_WEEK = '/api/v1/plans/2026-10-05' +const NEXT_WEEK = '/api/v1/plans/2026-10-12' + +// detail is the Korean beef bowls, a Quest-log Side quest, as GET +// /api/v1/dinners/{id} sends it. +function detail(overrides: Record = {}) { + return { + id: 'd1', + name: 'Korean beef bowls', + status: 'quest_log', + origin: 'suggested', + method: 'stovetop', + minutes_total: 30, + difficulty: 'easy', + cuisine: 'korean', + is_favorite: false, + photo_urls: null, + ingredients: [ + { name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }, + { name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' }, + { name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' }, + ], + steps: [ + 'Rinse the rice and start it cooking.', + 'Brown the beef with the garlic and ginger.', + 'Stir in the sauce and simmer until glossy.', + ], + source: null, + grounding_status: 'grounded', + grounding_error: null, + why_picked: 'close cousin of your Beef tacos.', + ...overrides, + } +} + +// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to +// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0. +function planBody(weekStart: string, ...bowlsOn: number[]) { + const start = new Date(`${weekStart}T00:00:00Z`).getTime() + return { + id: `plan-${weekStart}`, + week_start: weekStart, + status: 'draft', + ai_note: null, + calendar_synced_at: null, + days: Array.from({ length: 7 }, (_, i) => ({ + date: new Date(start + i * 86_400_000).toISOString().slice(0, 10), + dinner: + bowlsOn.includes(i) + ? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' } + : { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' }, + is_new: bowlsOn.includes(i), + start_by: null, + })), + } +} + +// serve answers the screen's reads: the dinner, the household settings and +// both weeks' plans, unless responses says otherwise. +function serve(responses: Record Response> = {}): Mock { + const all: Record Response> = { + [DINNER]: () => jsonResponse(200, detail()), + [SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)), + [NEXT_WEEK]: () => errorResponse(404, 'not_found'), + ...responses, + } + const fetchMock = vi.fn((input, init) => { + const path = String(input) + const respond = Object.hasOwn(all, path) ? all[path] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', fetchMock) + return fetchMock +} + +function reads(fetchMock: Mock, path: string) { + return fetchMock.mock.calls.filter(([input]) => String(input) === path).length +} + +// renderPage renders the bowls' detail screen and waits for its title. +async function renderPage() { + const router = createMemoryRouter([{ path: '/dinner/:id', element: }], { + initialEntries: ['/dinner/d1'], + }) + const view = render( + + + , + ) + await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' }) + return view +} + +function steps() { + return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem') +} + +// currentStep is the text of the step marked current. +function currentStep() { + const current = steps().filter((step) => step.getAttribute('aria-current') === 'step') + expect(current).toHaveLength(1) + return current[0]!.textContent +} + +// FakeSentinel is a WakeLockSentinel as far as the screen uses one. +type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise> } + +// letGo releases a lock as the browser does, telling its listeners so. +function letGo(sentinel: FakeSentinel) { + if (sentinel.released) return + sentinel.released = true + sentinel.dispatchEvent(new Event('release')) +} + +// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request +// is granted at once, or, given hold, only when the test calls grant. +function stubWakeLock({ hold = false } = {}) { + const sentinels: FakeSentinel[] = [] + const pending: (() => void)[] = [] + const request = vi.fn((type: string) => { + expect(type).toBe('screen') + const sentinel: FakeSentinel = Object.assign(new EventTarget(), { + released: false, + release: vi.fn(() => { + letGo(sentinel) + return Promise.resolve() + }), + }) + sentinels.push(sentinel) + if (!hold) return Promise.resolve(sentinel) + return new Promise((resolve) => pending.push(() => resolve(sentinel))) + }) + Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } }) + return { request, sentinels, grant: () => pending.shift()!() } +} + +// setVisibility shows or hides the page as the browser would, telling the +// page so. +function setVisibility(state: DocumentVisibilityState) { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state }) + document.dispatchEvent(new Event('visibilitychange')) +} + +beforeEach(() => { + mockViewport(1180) + // Wednesday Oct 7, noon in New York. + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date('2026-10-07T16:00:00Z')) +}) + +afterEach(() => { + vi.useRealTimers() + Reflect.deleteProperty(window, 'matchMedia') + Reflect.deleteProperty(navigator, 'wakeLock') + Reflect.deleteProperty(document, 'visibilityState') + vi.unstubAllGlobals() +}) + +describe('Kitchen reading mode', () => { + it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => { + stubWakeLock() + serve() + await renderPage() + + const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' }) + expect(declaredStyle(title).fontSize).toBe('36px') + expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument() + // Not the phone's sheet: no photo hero tags. + expect(screen.queryByText('Easy')).not.toBeInTheDocument() + + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + const sizes = screen.getByRole('radiogroup', { name: 'Text size' }) + expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([ + 'Small text', + 'Medium text', + 'Large text', + ]) + expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe( + '400px minmax(0, 1fr)', + ) + expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin') + }) + + describe('kicker', () => { + it('names the day the plan has the dinner on, and Side quest', async () => { + serve() + await renderPage() + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + + it("names a day of next week's plan, and Regular, for a Regular", async () => { + serve({ + [DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })), + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument() + }) + + it("names a day of this week's plan already past when that is the only day", async () => { + // Today is Wednesday; the bowls were on Monday, and are on no day of next week. + serve({ + [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)), + [NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')), + }) + await renderPage() + expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument() + }) + + it('names the first day from today on over a day already past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) }) + await renderPage() + expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument() + }) + + it('names the latest day already past when every day is past', async () => { + serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) }) + await renderPage() + expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument() + expect(screen.queryByText(/Monday/)).not.toBeInTheDocument() + }) + + it('is absent when neither week has a plan', async () => { + const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') }) + await renderPage() + await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1)) + await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1)) + await act(async () => {}) + expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument() + expect(screen.queryByText(/Oct/)).not.toBeInTheDocument() + }) + + it('says so when the plan cannot be read, with Retry', async () => { + let failing = true + serve({ + [THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))), + }) + await renderPage() + expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument() + + failing = false + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument() + }) + }) + + describe('ingredients', () => { + it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => { + const fetchMock = serve() + await renderPage() + const card = screen.getByRole('region', { name: 'Ingredients' }) + expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument() + + const beef = within(card).getByRole('checkbox', { name: 'Ground beef' }) + expect(declaredStyle(beef).width).toBe('22px') + expect(declaredStyle(beef).height).toBe('22px') + expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked() + + fireEvent.click(beef) + expect(beef).toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through') + fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' })) + expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument() + + fireEvent.click(beef) + expect(beef).not.toBeChecked() + expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('') + expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument() + + // Local to the screen: nothing is sent. + expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([]) + }) + + it('starts afresh on the next visit', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' })) + fireEvent.click(screen.getByRole('button', { name: 'Next step' })) + view.unmount() + + await renderPage() + expect(screen.getByText('0 of 3 set out')).toBeInTheDocument() + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('steps', () => { + it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => { + serve() + await renderPage() + const previous = screen.getByRole('button', { name: 'Previous' }) + const next = screen.getByRole('button', { name: 'Next step' }) + + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + expect(previous).toBeDisabled() + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))') + expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)') + expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)') + expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + // The step done is muted, its number a check. + expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.') + expect(steps()[0]!.querySelector('svg')).not.toBeNull() + expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)') + expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))') + + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + expect(next).toBeDisabled() + fireEvent.click(next) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + + fireEvent.click(previous) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('moves by the ← and → keys, stopping at either end', async () => { + serve() + await renderPage() + + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + fireEvent.keyDown(document.body, { key: 'ArrowRight' }) + expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + fireEvent.keyDown(document.body, { key: 'ArrowLeft' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + // From a checked ingredient too. + const beef = screen.getByRole('checkbox', { name: 'Ground beef' }) + beef.focus() + fireEvent.keyDown(beef, { key: 'ArrowRight' }) + expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.') + }) + + it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => { + serve() + await renderPage() + const field = document.createElement('input') + document.body.append(field) + field.focus() + fireEvent.keyDown(field, { key: 'ArrowRight' }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + field.remove() + + fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true }) + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + + const medium = screen.getByRole('radio', { name: 'Medium text' }) + fireEvent.keyDown(medium, { key: 'ArrowRight' }) + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(currentStep()).toBe('1Rinse the rice and start it cooking.') + }) + }) + + describe('text size', () => { + it('is remembered on this device', async () => { + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('radio', { name: 'Large text' })) + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large') + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + view.unmount() + + await renderPage() + expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false') + expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe( + '1.25', + ) + }) + + it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => { + localStorage.setItem(TEXT_SIZE_KEY, 'huge') + serve() + await renderPage() + expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"') + expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true') + + fireEvent.click(screen.getByRole('radio', { name: 'Small text' })) + expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument() + expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small') + }) + }) + + describe('source credit', () => { + it.each([ + ['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'], + ['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'], + ])('links the recipe page in a new tab for %s', async (_, source, name) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + const credit = screen.getByRole('link', { name }) + expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls') + expect(credit).toHaveAttribute('target', '_blank') + expect(credit).toHaveAttribute('rel', 'noopener noreferrer') + }) + + it.each([ + ['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'], + ['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'], + ])('is plain text for %s', async (_, source, text) => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source })) }) + await renderPage() + expect(screen.getByText(text).closest('a')).toBeNull() + expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument() + }) + + it('is absent for a recipe typed in', async () => { + serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) }) + await renderPage() + expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument() + }) + }) + + it('shows what stands in for a recipe not found, with no step buttons', async () => { + serve({ + [DINNER]: () => + jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })), + }) + await renderPage() + expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument() + expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument() + }) + + describe('Keep screen on', () => { + it('holds a screen wake lock while on and releases it when turned off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeEnabled() + expect(wakeLock.request).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled() + + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + + // On again: a fresh lock. + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(wakeLock.sentinels[1]!.released).toBe(false) + }) + + it('releases the lock when the screen is left', async () => { + const wakeLock = stubWakeLock() + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + view.unmount() + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('releases a lock granted only after the screen was left', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + const view = await renderPage() + fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' })) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + view.unmount() + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1) + }) + + it('asks for the lock again when the page is visible again while on, and not while off', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + // Hidden, the browser lets the lock go; nothing is asked while hidden. + act(() => setVisibility('hidden')) + act(() => letGo(wakeLock.sentinels[0]!)) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + act(() => setVisibility('visible')) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // Visible again with the lock still held: no second lock. + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + fireEvent.click(keepOn) + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + }) + + it('asks for the lock again when the browser lets it go while the page is visible', async () => { + const wakeLock = stubWakeLock() + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1)) + + act(() => letGo(wakeLock.sentinels[0]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + await act(async () => {}) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + + // The fresh lock is watched too. + act(() => letGo(wakeLock.sentinels[1]!)) + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3)) + expect(wakeLock.sentinels[2]!.released).toBe(false) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('asks for the lock again when the page was hidden and shown while a request was pending', async () => { + const wakeLock = stubWakeLock({ hold: true }) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + + // Hidden and shown again before the first lock is granted, which the + // browser has let go by the time it is. + act(() => setVisibility('hidden')) + act(() => setVisibility('visible')) + expect(wakeLock.request).toHaveBeenCalledTimes(1) + letGo(wakeLock.sentinels[0]!) + await act(async () => wakeLock.grant()) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + + await act(async () => wakeLock.grant()) + expect(wakeLock.sentinels[1]!.released).toBe(false) + expect(wakeLock.request).toHaveBeenCalledTimes(2) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + }) + + it('reports a lock the browser refuses and turns the switch back off', async () => { + const wakeLock = stubWakeLock() + wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError')) + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + fireEvent.click(keepOn) + + expect(await screen.findByRole('alert')).toHaveTextContent( + "Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy", + ) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(keepOn).toHaveAttribute('aria-checked', 'true') + await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('is disabled, and says why, in a browser that cannot keep the screen on', async () => { + serve() + await renderPage() + const keepOn = screen.getByRole('switch', { name: 'Keep screen on' }) + expect(keepOn).toBeDisabled() + expect(keepOn).toHaveAttribute('aria-checked', 'false') + expect(keepOn).toHaveAccessibleDescription( + "This browser can't keep the screen on, so it may dim while you cook.", + ) + fireEvent.click(keepOn) + expect(keepOn).toHaveAttribute('aria-checked', 'false') + }) + }) +}) diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 06e040c..2c0c081 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -1,6 +1,7 @@ import { useIsMutating, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' +import type { DinnerDetail } from './detail.ts' export const DINNERS_PATH = '/api/v1/dinners' export const REGULARS_PATH = `${DINNERS_PATH}?status=regular` @@ -12,7 +13,7 @@ export const GROUNDING_POLL_MS = 3000 // Methods and Difficulties are the values the dinners API allows. export const METHODS = ['stovetop', 'oven', 'crockpot', 'grill', 'other'] as const export const DIFFICULTIES = ['easy', 'medium', 'hard'] as const -const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const +export const GROUNDING_STATUSES = ['pending', 'grounded', 'failed', 'manual'] as const export type Method = (typeof METHODS)[number] export type Difficulty = (typeof DIFFICULTIES)[number] @@ -146,6 +147,11 @@ export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } +// dinnerDetailKey is the query of dinner id with its recipe (useDinnerDetail). +export function dinnerDetailKey(id: string) { + return ['dinners', 'detail', id] as const +} + // useRegulars is the household's Regulars, in the server's order. While any // of them is still finding its recipe it is read again every // GROUNDING_POLL_MS, except while any change is in flight (whose settling @@ -275,28 +281,43 @@ export function useEditRegular() { }) } -// useToggleFavorite toggles a Regular's favorite heart (POST -// /api/v1/dinners/{id}/favorite). The heart changes at once and changes -// back if the server refuses. +// Favorite is the dinner whose heart is toggled, as it was before the toggle. +export type Favorite = { id: string; is_favorite: boolean } + +// setFavorite shows dinner id's heart as is_favorite wherever it is cached: +// in the Regulars and in its detail. A cache not yet read is left unread. +function setFavorite(queryClient: QueryClient, id: string, is_favorite: boolean) { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => + list?.map((r) => (r.id === id ? { ...r, is_favorite } : r)), + ) + queryClient.setQueryData(dinnerDetailKey(id), (dinner) => dinner && { ...dinner, is_favorite }) +} + +// useToggleFavorite toggles a dinner's favorite heart (POST +// /api/v1/dinners/{id}/favorite), a Regular's or any dinner's on its detail +// screen. The heart changes at once and changes back if the server refuses. +// The server answers with the dinner as the dinners list sends it, which +// replaces the Regular; the detail keeps its recipe and takes only the heart. export function useToggleFavorite() { const { apiFetch } = useApi() const queryClient = useQueryClient() - const set = (id: string, is_favorite: boolean) => - setRegulars(queryClient, (list) => list.map((r) => (r.id === id ? { ...r, is_favorite } : r))) return useMutation({ mutationKey: REGULARS_MUTATION_KEY, - mutationFn: async (regular: Regular) => + mutationFn: async (dinner: Favorite) => parseDinner( `POST ${DINNERS_PATH}/{id}/favorite`, - await apiFetch(`${dinnerPath(regular.id)}/favorite`, { method: 'POST' }), + await apiFetch(`${dinnerPath(dinner.id)}/favorite`, { method: 'POST' }), ), - onMutate: async (regular) => { - await changeOptimistically(queryClient, (list) => - list.map((r) => (r.id === regular.id ? { ...r, is_favorite: !regular.is_favorite } : r)), - ) + onMutate: async (dinner) => { + await queryClient.cancelQueries({ queryKey: dinnerDetailKey(dinner.id) }) + await queryClient.cancelQueries({ queryKey: REGULARS_LIST_KEY }) + setFavorite(queryClient, dinner.id, !dinner.is_favorite) }, - onSuccess: (dinner) => replace(queryClient, dinner), - onError: (_error, regular) => set(regular.id, regular.is_favorite), + onSuccess: (dinner) => { + queryClient.setQueryData(REGULARS_LIST_KEY, (list) => list?.map((r) => (r.id === dinner.id ? dinner : r))) + queryClient.setQueryData(dinnerDetailKey(dinner.id), (detail) => detail && { ...detail, is_favorite: dinner.is_favorite }) + }, + onError: (_error, dinner) => setFavorite(queryClient, dinner.id, dinner.is_favorite), onSettled: () => settle(queryClient), }) } diff --git a/web/src/dinners/detail.ts b/web/src/dinners/detail.ts new file mode 100644 index 0000000..e5386b2 --- /dev/null +++ b/web/src/dinners/detail.ts @@ -0,0 +1,178 @@ +import { useQuery } from '@tanstack/react-query' +import { useEffect, useSyncExternalStore } from 'react' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { + DIFFICULTIES, + dinnerDetailKey, + dinnerPath, + DINNERS_PATH, + GROUNDING_POLL_MS, + GROUNDING_STATUSES, + METHODS, + oneOf, + photoId, + type Difficulty, + type GroundingStatus, + type Method, +} from './api.ts' + +// The statuses a dinner with detail can have; an archived dinner is 404. +const STATUSES = ['suggested', 'quest_log', 'tried', 'regular'] as const + +export type DinnerStatus = (typeof STATUSES)[number] + +// Ingredient is one recipe line. raw_text is the line as the recipe gives +// it; name, qty, unit and note are the server's reading of it, each null +// when it has none — always name, qty and unit for a recipe typed in. +export type Ingredient = { + name: string | null + qty: string | null + unit: string | null + note: string | null + raw_text: string +} + +// RecipeSource is where a fetched recipe came from. siteName is the site, +// or null when there is none to name: a page that named no site, or a name +// the server withheld because it could have come from the link. url is the +// recipe's page, or null when the server withheld the stored link because +// it could carry a token; the credit then never names its host. +export type RecipeSource = { siteName: string | null; url: string | null } + +// DinnerDetail is a dinner with its recipe (GET /api/v1/dinners/{id}). +// source is null for a recipe typed in. cached says the service worker +// answered from its saved copy because the server could not be reached. +export type DinnerDetail = { + id: string + name: string + status: DinnerStatus + method: Method | null + minutes_total: number | null + difficulty: Difficulty | null + is_favorite: boolean + photo_id: string | undefined + ingredients: Ingredient[] + steps: string[] + source: RecipeSource | null + grounding_status: GroundingStatus + why_picked: string | null + cached: boolean +} + +function text(where: string, field: string, value: unknown): string { + if (typeof value !== 'string') throw new Error(`${where}: ${field} is not a string`) + return value +} + +function nullableText(where: string, field: string, value: unknown): string | null { + return value === null ? null : text(where, field, value) +} + +function parseIngredient(where: string, body: unknown): Ingredient { + if (!isRecord(body)) throw new Error(`${where}: ingredient is not an object`) + return { + name: nullableText(where, 'ingredient name', body.name), + qty: nullableText(where, 'ingredient qty', body.qty), + unit: nullableText(where, 'ingredient unit', body.unit), + note: nullableText(where, 'ingredient note', body.note), + raw_text: text(where, 'ingredient raw_text', body.raw_text), + } +} + +// detailPhotoId is the photo id of photo_urls, which the server sends as +// both cached sizes of one photo, or null. +function detailPhotoId(where: string, value: unknown): string | undefined { + if (value === null) return undefined + if (!isRecord(value)) throw new Error(`${where}: photo_urls is not an object`) + const id = photoId(where, value['400']) + if (id === undefined || value['1200'] !== `/media/photos/${encodeURIComponent(id)}-1200.jpg`) { + throw new Error(`${where}: photo_urls is not both sizes of one stored photo`) + } + return id +} + +// sourceUrl is source.url, null when the server withholds it, or a link safe +// to open: https, with no user, password, query or fragment. +function sourceUrl(where: string, value: unknown): string | null { + if (value === undefined || value === null) return null + const href = text(where, 'source url', value) + let link: URL + try { + link = new URL(href) + } catch (error) { + throw new Error(`${where}: source url is not a URL`, { cause: error }) + } + if (link.protocol !== 'https:' || link.username !== '' || link.password !== '' || link.search !== '' || link.hash !== '') { + throw new Error(`${where}: source url is not an https link without credentials, query or fragment`) + } + return link.href +} + +function parseSource(where: string, value: unknown): RecipeSource | null { + if (value === null) return null + if (!isRecord(value)) throw new Error(`${where}: source is not an object`) + return { siteName: nullableText(where, 'source site_name', value.site_name), url: sourceUrl(where, value.url) } +} + +// parseDinnerDetail checks a dinner with its recipe as the API sends it; +// cached says whether the service worker answered from its saved copy. +export function parseDinnerDetail(where: string, body: unknown, cached: boolean): DinnerDetail { + if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { + throw new Error(`${where}: response is not a dinner`) + } + if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`) + if (!Array.isArray(body.ingredients) || !Array.isArray(body.steps)) throw new Error(`${where}: response has no recipe lists`) + const minutes = body.minutes_total + if (minutes !== null && (typeof minutes !== 'number' || !Number.isInteger(minutes))) { + throw new Error(`${where}: minutes_total is not a whole number`) + } + return { + id: body.id, + name: body.name, + status: oneOf(where, 'status', STATUSES, body.status), + method: body.method === null ? null : oneOf(where, 'method', METHODS, body.method), + minutes_total: minutes, + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + is_favorite: body.is_favorite, + photo_id: detailPhotoId(where, body.photo_urls), + ingredients: body.ingredients.map((ingredient) => parseIngredient(where, ingredient)), + steps: body.steps.map((step) => text(where, 'step', step)), + source: parseSource(where, body.source), + grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status), + why_picked: nullableText(where, 'why_picked', body.why_picked), + cached, + } +} + +// useDinnerDetail is dinner id with its recipe (GET /api/v1/dinners/{id}). +// While the dinner is still finding its recipe it is read again every +// GROUNDING_POLL_MS, but only while the server can be reached: polling pauses +// while the browser is offline or the last read was the service worker's +// saved copy, which would only answer the same again, and a saved pending +// copy is read once more when the connection comes back. Polling stops on +// unmount. An unknown or archived dinner fails with the API's 404. It runs +// even while the browser reports offline, so the copy the service worker +// saved is shown, or the read fails, instead of waiting for the network. +export function useDinnerDetail(id: string) { + const { apiRead, network } = useApi() + const offline = useSyncExternalStore(network.subscribe, network.offline) + const query = useQuery({ + queryKey: dinnerDetailKey(id), + queryFn: async () => { + const read = await apiRead(dinnerPath(id)) + return parseDinnerDetail(`GET ${DINNERS_PATH}/{id}`, read.body, read.cached) + }, + networkMode: 'always', + refetchInterval: (query) => { + const dinner = query.state.data + return dinner?.grounding_status === 'pending' && !dinner.cached && !offline ? GROUNDING_POLL_MS : false + }, + }) + const { data, refetch } = query + const stalled = data?.grounding_status === 'pending' && data.cached + useEffect(() => { + if (stalled && !offline) void refetch() + }, [stalled, offline, refetch]) + return query +} diff --git a/web/src/dinners/keepScreenOn.ts b/web/src/dinners/keepScreenOn.ts new file mode 100644 index 0000000..63ed9a3 --- /dev/null +++ b/web/src/dinners/keepScreenOn.ts @@ -0,0 +1,86 @@ +import { useEffect, useState } from 'react' + +// KeepScreenOn is Kitchen reading mode's Keep screen on switch: whether it is +// on, setOn, whether this browser can keep the screen on at all, and why the +// screen could not be kept on, if it could not. +export type KeepScreenOn = { + on: boolean + setOn: (on: boolean) => void + supported: boolean + failure: string | null +} + +// useKeepScreenOn holds a Screen Wake Lock while the switch is on, and only +// then. It starts off on each visit. The browser lets the lock go whenever +// the page is hidden, so it is asked for again each time the page is visible +// while the switch is on. Turning the switch off or leaving the screen +// releases it, including a lock still being granted as the screen is left. +// +// A browser without the Screen Wake Lock API (or a page not served over +// HTTPS) cannot keep the screen on: supported is false and the switch must +// not be offered as working. A request the browser refuses turns the switch +// back off and is reported as failure; turning it on again asks again. +export function useKeepScreenOn(): KeepScreenOn { + const [on, setOnState] = useState(false) + const [failure, setFailure] = useState(null) + const supported = 'wakeLock' in navigator + + useEffect(() => { + if (!on) return + const wakeLock = navigator.wakeLock + let sentinel: WakeLockSentinel | null = null + let requesting = false + let left = false + + // The browser may let a lock go while the switch is on: always when the + // page is hidden, and, the specification allows, while it is still + // visible. Either way the lock is asked for again at once if the page is + // visible, or when it is visible again if not, so the switch never claims + // a lock that is not held. + function onRelease() { + if (!left) acquire() + } + + function acquire() { + if (document.visibilityState !== 'visible' || requesting) return + if (sentinel !== null && !sentinel.released) return + requesting = true + wakeLock.request('screen').then( + (granted) => { + requesting = false + if (left) { + void granted.release() + return + } + sentinel = granted + granted.addEventListener('release', onRelease) + // While the request was pending the page may have been hidden and + // shown again (that visibilitychange was ignored) and the lock let + // go before it could be listened for, so check again now. + acquire() + }, + (err: unknown) => { + requesting = false + if (left) return + setFailure(String(err)) + setOnState(false) + }, + ) + } + + acquire() + document.addEventListener('visibilitychange', acquire) + return () => { + left = true + document.removeEventListener('visibilitychange', acquire) + if (sentinel !== null) void sentinel.release() + } + }, [on]) + + function setOn(next: boolean) { + if (next) setFailure(null) + setOnState(next) + } + + return { on, setOn, supported, failure } +} diff --git a/web/src/dinners/testdata/source_url_cases.json b/web/src/dinners/testdata/source_url_cases.json new file mode 100644 index 0000000..4d64542 --- /dev/null +++ b/web/src/dinners/testdata/source_url_cases.json @@ -0,0 +1,52 @@ +{ + "cases": [ + { + "stored": "https://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://kitchen.example/recipes/chicken-curry" } + }, + { + "stored": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://Kitchen-2.Example.com.:8443/recipes/chicken-curry" } + }, + { + "stored": "https://[2001:db8::1]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://[2001:db8::1]/recipes/chicken-curry" } + }, + { + "stored": "https://192.0.2.7/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": "https://192.0.2.7/recipes/chicken-curry" } + }, + { + "stored": "https://[bad]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://[fe80::1%25en0]/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://192.0.2.999/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://xn--a.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry?access_token=query-token", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://user-token@kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "http://kitchen.example/recipes/chicken-curry", + "source": { "site_name": "Curry House", "url": null } + }, + { + "stored": "https://kitchen.example/recipes/chicken-curry#token=fragment-token", + "source": { "site_name": "Curry House", "url": null } + } + ] +} diff --git a/web/src/dinners/textSize.ts b/web/src/dinners/textSize.ts new file mode 100644 index 0000000..0875785 --- /dev/null +++ b/web/src/dinners/textSize.ts @@ -0,0 +1,63 @@ +import { useState } from 'react' + +// TEXT_SIZE_KEY is the localStorage key remembering, on this device, the text +// size chosen in Kitchen reading mode. +export const TEXT_SIZE_KEY = 'mealquest.readingTextSize' + +export const TEXT_SIZES = ['small', 'medium', 'large'] as const + +export type TextSize = (typeof TEXT_SIZES)[number] + +// DEFAULT_TEXT_SIZE is the size of a device that has never chosen one: the +// handoff's 18 px ingredients and steps. +export const DEFAULT_TEXT_SIZE: TextSize = 'medium' + +// TextSizeState is the text size, and the storage failure, if any, that +// stopped us reading or saving it. +type TextSizeState = { + size: TextSize + failure: { action: 'read' | 'write'; message: string } | null +} + +function isTextSize(value: string): value is TextSize { + return (TEXT_SIZES as readonly string[]).includes(value) +} + +// Storage can be unavailable (blocked by the browser or privacy settings), in +// which case using it throws, and can hold a value MealQuest did not save. +// Either is reported, never treated as a remembered size. +function readTextSize(): TextSizeState { + try { + const saved = window.localStorage.getItem(TEXT_SIZE_KEY) + if (saved === null) return { size: DEFAULT_TEXT_SIZE, failure: null } + if (!isTextSize(saved)) throw new Error(`"${saved}" is not a text size MealQuest saved`) + return { size: saved, failure: null } + } catch (err) { + return { size: DEFAULT_TEXT_SIZE, failure: { action: 'read', message: String(err) } } + } +} + +function writeTextSize(size: TextSize): TextSizeState { + try { + window.localStorage.setItem(TEXT_SIZE_KEY, size) + return { size, failure: null } + } catch (err) { + return { size, failure: { action: 'write', message: String(err) } } + } +} + +export const textSizeFailureText = { + read: 'Could not read the text size chosen on this device', + write: 'Could not remember the text size on this device', +} + +// useTextSize is Kitchen reading mode's text size on this device: the size, +// the storage failure if any, choose, which saves a size, and retry, which +// reads or saves again whatever failed. A size that could not be saved is +// still used until the screen is left. +export function useTextSize() { + const [state, setState] = useState(readTextSize) + const choose = (size: TextSize) => setState(writeTextSize(size)) + const retry = () => setState(state.failure?.action === 'write' ? writeTextSize(state.size) : readTextSize()) + return { ...state, choose, retry } +} diff --git a/web/src/plan/PlanPage.tsx b/web/src/plan/PlanPage.tsx index 87124ba..f6828b8 100644 --- a/web/src/plan/PlanPage.tsx +++ b/web/src/plan/PlanPage.tsx @@ -23,9 +23,8 @@ import { parseDraftResult, planKey, usePlan, - usePushToCalendar, useStartDraft, - useSwapDay, + useWeekSwap, type DraftResult, type Plan, type PlanDay, @@ -266,8 +265,7 @@ function WeekBoard({ const [jobId, setJobId] = useState(null) const job = useJob(DRAFT_JOB_KIND, jobId) const [confirming, setConfirming] = useState(false) - const swap = useSwapDay(weekStart) - const push = usePushToCalendar(weekStart) + const { swap, push, refused, swapIn: sendSwap } = useWeekSwap(weekStart) const run = useSupplyRun(weekStart) // Makes the week's Supply run again after a push could not make it. const generate = useGenerateSupplyRun(weekStart) @@ -431,25 +429,18 @@ function WeekBoard({ void run.refetch()}>Couldn't load this week's shopping list. {run.error.message} ) - // swapIn sends the swap chosen in the sheet and closes it. + // swapIn sends the swap chosen in the sheet, dropped on a day or retried + // (useWeekSwap) and closes the sheet. The Supply run made again on the + // Plan after a push is left behind with the push. function swapIn(chosen: Swap) { setSwappingDate(null) - push.reset() generate.reset() - swap.mutate(chosen) + sendSwap(chosen) } - // dropIn swaps in a dinner dragged onto a day. A drag is not picked up, - // nor dropped, while a swap of the week is pending. - function dropIn(chosen: Swap) { - swap.dismiss() - swapIn(chosen) - } - // openSwap opens the swap sheet on a day. One swap of the week is sent at - // a time, even across leaving the week and coming back: the plan the - // server answers it with must not overwrite another still pending. Nor is - // one sent while the week is being pushed to Calendar. + // openSwap opens the swap sheet on a day, unless a swap is refused now + // (useWeekSwap). A drag is not picked up, nor dropped, then either. function openSwap(date: string) { - if (swap.pending || pushing) return + if (refused) return swap.dismiss() setSwappingDate(date) } @@ -488,7 +479,7 @@ function WeekBoard({ body = layout !== 'mobile' ? ( <> - +
      {days.map((day) => ( void dismiss: () => void @@ -420,6 +422,7 @@ export function useSwapDay(weekStart: string): SwapDay { return { pending: swaps.some((swap) => swap.status === 'pending'), + succeeded: latest?.status === 'success' ? latest.swap : null, failed: latest?.status === 'error' ? { swap: latest.swap, error: latest.error! } : null, mutate, dismiss, @@ -531,6 +534,37 @@ export function usePushToCalendar(weekStart: string): PushToCalendar { } } +// WeekSwap is the one way a day of a week is swapped, wherever the swap is +// chosen: the week's swaps and pushes, whether a swap is refused now, and +// swapIn to send one. +export type WeekSwap = { + swap: SwapDay + push: PushToCalendar + refused: boolean + swapIn: (chosen: Swap) => void +} + +// useWeekSwap swaps a day of the week starting on weekStart, from the Draft +// board or a dinner's Add to this week. One swap of the week is sent at a +// time, even across leaving the week and coming back: the plan the server +// answers it with must not overwrite another still pending. Nor is one sent +// while the week is being pushed to Calendar: refused says so, and swapIn +// then throws. A swap leaves the week's settled swaps behind, and its +// pushes: what a push said of Calendar and the Supply run was said of the +// plan before the swap, not the one swapped. +export function useWeekSwap(weekStart: string): WeekSwap { + const swap = useSwapDay(weekStart) + const push = usePushToCalendar(weekStart) + const refused = swap.pending || push.pending + function swapIn(chosen: Swap) { + if (refused) throw new Error(`The week of ${weekStart} has a swap or push pending, so ${chosen.date} cannot be swapped.`) + swap.dismiss() + push.reset() + swap.mutate(chosen) + } + return { swap, push, refused, swapIn } +} + // settleUnmadePushes is called as the Supply run of the week starting on // weekStart starts being made, from the Plan or the Supply run screen // (useGenerateSupplyRun). The function it answers with is called once the diff --git a/web/src/plan/week.test.ts b/web/src/plan/week.test.ts index f56af4e..d3ad06d 100644 --- a/web/src/plan/week.test.ts +++ b/web/src/plan/week.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { addDays, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' +import { addDays, dayAndDate, dayOfMonth, defaultWeek, localNow, weekdayKicker, weekdayName, weekLabel, weekStart } from './week.ts' // SCHEDULE drafts the plan on Fridays (Sunday = 0) at 09:00 in New York. const SCHEDULE = { timezone: 'America/New_York', plan_draft_dow: 5, plan_draft_time: '09:00' } @@ -36,6 +36,11 @@ describe('week helpers', () => { expect(weekdayName('2026-10-11')).toBe('Sunday') }) + it('names the day of a date in full with its month and day', () => { + expect(dayAndDate('2026-10-10')).toBe('Saturday, Oct 10') + expect(dayAndDate('2026-11-01')).toBe('Sunday, Nov 1') + }) + it('reads the day of the month of a date', () => { expect(dayOfMonth('2026-10-05')).toBe(5) expect(dayOfMonth('2026-10-31')).toBe(31) diff --git a/web/src/plan/week.ts b/web/src/plan/week.ts index 41c2552..b2c82d0 100644 --- a/web/src/plan/week.ts +++ b/web/src/plan/week.ts @@ -52,6 +52,13 @@ export function weekdayName(date: string): string { return weekday.format(midnightUTC(date)) } +const longDay = new Intl.DateTimeFormat('en-US', { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' }) + +// dayAndDate is date's day in full with its month and day: "Saturday, Oct 10". +export function dayAndDate(date: string): string { + return longDay.format(midnightUTC(date)) +} + // monday reads start as midnight UTC, or throws when it is not a Monday. function monday(start: string): Date { const t = midnightUTC(start) diff --git a/web/src/sw/apiStrategy.test.ts b/web/src/sw/apiStrategy.test.ts index 46a7f5e..0aaab31 100644 --- a/web/src/sw/apiStrategy.test.ts +++ b/web/src/sw/apiStrategy.test.ts @@ -1,51 +1,16 @@ // Runs the real Workbox network-first strategy against an in-memory cache // storage: jsdom has none, and the worker itself needs service worker globals. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { RouteHandlerCallbackOptions } from 'workbox-core/types' import { createApiClient, createVerifiedSession } from '../api/index.ts' import { createNetworkStatus } from '../pwa/networkStatus.ts' import { fakeLocation, jsonResponse } from '../test/fetchStub.ts' -import { createApiStrategy } from './apiStrategy' -import { cacheFallbackHeader, cacheNames, routeFor } from './routes' - -const origin = 'https://mealquest.test' - -// FakeCache keys entries by URL, which is all the API cache needs. -class FakeCache { - readonly entries = new Map() - async match(request: RequestInfo | URL): Promise { - return this.entries.get(new Request(request).url)?.clone() - } - async put(request: RequestInfo | URL, response: Response): Promise { - this.entries.set(new Request(request).url, response) - } -} - -// FakeExtendableEvent stands in for the worker-only ExtendableEvent, -// collecting the promises the strategy waits on. -class FakeExtendableEvent extends Event { - readonly pending: Promise[] = [] - waitUntil(promise: Promise) { - this.pending.push(promise) - } -} +import { answer, origin, stubFetchThroughWorker, stubWorkerCaches, type FakeCache } from '../test/serviceWorker.ts' +import { cacheFallbackHeader } from './routes' let cache: FakeCache beforeEach(() => { - cache = new FakeCache() - // Workbox checks its arguments against these worker-only globals. - vi.stubGlobal('FetchEvent', class FetchEvent {}) - vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) - const only = (name: string | undefined) => { - if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) - return cache - } - vi.stubGlobal('caches', { - open: async (name: string) => only(name), - match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => - only(options?.cacheName).match(request), - }) + cache = stubWorkerCaches() }) afterEach(() => { @@ -58,17 +23,6 @@ async function handle(path: string): Promise { return answer(new Request(new URL(path, origin))) } -async function answer(request: Request): Promise { - expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ - strategy: 'network-first', - cacheName: cacheNames.api, - }) - const event = new FakeExtendableEvent('fetch') - const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) - await Promise.all(event.pending) - return response -} - function recipe(): Response { return new Response(JSON.stringify({ id: '01J9ZR', name: 'Chicken tikka masala' }), { status: 200, @@ -107,22 +61,13 @@ describe('previously viewed plan and recipe reads', () => { const server: Record = { '/api/v1/plans/2026-W40': plan, '/api/v1/recipes/01J9ZR': recipe } let reachable: boolean - // The page's fetch goes through the worker's API strategy; the strategy's - // own fetch (always a Request) is the network. The API client fetches a - // path string, so the two are told apart by the argument's type. As in a - // browser, a worker that cannot answer is a network error to the page. beforeEach(() => { reachable = true vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => reachable) - vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { - if (!(input instanceof Request)) { - return answer(new Request(new URL(String(input), origin), init)).catch((error: unknown) => { - throw new TypeError('Failed to fetch', { cause: error }) - }) - } + stubFetchThroughWorker(async (request) => { if (!reachable) throw new TypeError('Failed to fetch') - const body = server[new URL(input.url).pathname] - if (body === undefined) throw new Error(`unexpected network fetch: ${input.url}`) + const body = server[new URL(request.url).pathname] + if (body === undefined) throw new Error(`unexpected network fetch: ${request.url}`) return jsonResponse(200, body) }) }) diff --git a/web/src/test/serviceWorker.ts b/web/src/test/serviceWorker.ts new file mode 100644 index 0000000..606a12a --- /dev/null +++ b/web/src/test/serviceWorker.ts @@ -0,0 +1,79 @@ +// Runs requests through the service worker's real API strategy against an +// in-memory cache storage: jsdom has neither a service worker nor caches. +import { expect, vi } from 'vitest' +import type { RouteHandlerCallbackOptions } from 'workbox-core/types' +import { createApiStrategy } from '../sw/apiStrategy.ts' +import { cacheNames, routeFor } from '../sw/routes.ts' + +export const origin = 'https://mealquest.test' + +// FakeCache keys entries by URL, which is all the API cache needs. +export class FakeCache { + readonly entries = new Map() + async match(request: RequestInfo | URL): Promise { + return this.entries.get(new Request(request).url)?.clone() + } + async put(request: RequestInfo | URL, response: Response): Promise { + this.entries.set(new Request(request).url, response) + } +} + +// FakeExtendableEvent stands in for the worker-only ExtendableEvent, +// collecting the promises the strategy waits on. +class FakeExtendableEvent extends Event { + readonly pending: Promise[] = [] + waitUntil(promise: Promise) { + this.pending.push(promise) + } +} + +// stubWorkerCaches stubs the worker globals Workbox checks and a cache +// storage holding only the API cache, which it returns. Undo with +// vi.unstubAllGlobals. +export function stubWorkerCaches(): FakeCache { + const cache = new FakeCache() + vi.stubGlobal('FetchEvent', class FetchEvent {}) + vi.stubGlobal('ExtendableEvent', FakeExtendableEvent) + const only = (name: string | undefined) => { + if (name !== cacheNames.api) throw new Error(`unexpected cache ${name}`) + return cache + } + vi.stubGlobal('caches', { + open: async (name: string) => only(name), + match: async (request: RequestInfo | URL, options?: MultiCacheQueryOptions) => + only(options?.cacheName).match(request), + }) + return cache +} + +// answer sends a GET through the API strategy, as src/sw.ts does for a +// network-first route; the strategy's own fetch is the network. +export async function answer(request: Request): Promise { + expect(routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin)).toEqual({ + strategy: 'network-first', + cacheName: cacheNames.api, + }) + const event = new FakeExtendableEvent('fetch') + const response = await createApiStrategy().handle({ request, event: event as unknown as RouteHandlerCallbackOptions['event'] }) + await Promise.all(event.pending) + return response +} + +// stubFetchThroughWorker replaces the global fetch with the page's view of +// it behind the worker, routed as src/sw.ts routes it: a network-first path +// from the API client goes through the API strategy, a network-only one +// straight to network; the strategy's own fetch, a Request, is network too. +// As in a browser, a worker that cannot answer is a network error to the +// page. Undo with vi.unstubAllGlobals. +export function stubFetchThroughWorker(network: (request: Request) => Promise) { + vi.stubGlobal('fetch', async (input: RequestInfo | URL, init?: RequestInit) => { + if (input instanceof Request) return network(input) + const request = new Request(new URL(String(input), origin), init) + const route = routeFor({ method: request.method, url: new URL(request.url), mode: 'cors' }, origin) + if (route.strategy === 'network-only') return network(request) + if (route.strategy !== 'network-first') throw new Error(`unexpected ${route.strategy} fetch: ${request.url}`) + return answer(request).catch((error: unknown) => { + throw new TypeError('Failed to fetch', { cause: error }) + }) + }) +} diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 2995110..910f3ab 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -24,6 +24,11 @@ margin: -2px; } +.size52 { + width: 52px; + height: 52px; +} + .size62 { width: 62px; height: 62px; @@ -56,6 +61,11 @@ height: 40px; } +.size52 .face { + width: 52px; + height: 52px; +} + .size62 .face { width: 62px; height: 62px; diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index e909cdd..a17f343 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -26,6 +26,13 @@ describe('IconButton', () => { expect(hitArea(face)).toEqual({ width: 40, height: 40 }) }) + it('is 52 px at the medium size', () => { + render() + const button = screen.getByRole('button', { name: 'Favorite' }) + expectMinHitArea(button) + expect(hitArea(button)).toEqual({ width: 52, height: 52 }) + }) + it('is 62 px at the large size', () => { render() const button = screen.getByRole('button', { name: 'Try it' }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 6da1b22..a4132bf 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -1,7 +1,7 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' -export type IconButtonSize = 40 | 62 +export type IconButtonSize = 40 | 52 | 62 export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { @@ -15,7 +15,8 @@ export type IconButtonProps = Omit, 'aria-label' } // Round icon button, named by its label and showing it only with showLabel. -// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// The 40 px size keeps a 44 px hit box (see the CSS); the 52 px size sits +// beside a 52 px pill button, as the recipe's heart; the 62 px size is the // round action button with --shadow-md. export function IconButton({ label, diff --git a/web/src/ui/Segmented.test.tsx b/web/src/ui/Segmented.test.tsx index c107a1e..99c04c8 100644 --- a/web/src/ui/Segmented.test.tsx +++ b/web/src/ui/Segmented.test.tsx @@ -83,6 +83,16 @@ describe('Segmented', () => { for (const option of screen.getAllByRole('radio')) expectMinHitArea(option) }) + it('names an option by its ariaLabel when it has one', () => { + const sizes = [ + { value: 'small', label: 'A', ariaLabel: 'Small text' }, + { value: 'large', label: 'A', ariaLabel: 'Large text' }, + ] as const + render( {}} />) + expect(radio('Small text')).toHaveTextContent('A') + expect(radio('Large text')).toHaveAttribute('aria-checked', 'true') + }) + it('fails fast when the value is not one of the options', () => { expect(() => render( {}} />), diff --git a/web/src/ui/Segmented.tsx b/web/src/ui/Segmented.tsx index bd53b27..6e943b2 100644 --- a/web/src/ui/Segmented.tsx +++ b/web/src/ui/Segmented.tsx @@ -4,6 +4,9 @@ import styles from './Segmented.module.css' export type SegmentedOption = { value: T label: string + // Accessible name, when the visible label alone does not say what the + // option is (the A / A / A of a text size). + ariaLabel?: string } export type SegmentedProps = { @@ -63,6 +66,7 @@ export function Segmented({ label, options, value, onValueChan type="button" role="radio" aria-checked={checked} + aria-label={option.ariaLabel} tabIndex={checked ? 0 : -1} className={checked ? `${styles.option} ${styles.selected}` : styles.option} onClick={() => select(index)}