Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b96dea5d8 | ||
|
|
47a5b5cb89 | ||
|
|
839eb61f38 |
+14
-2
@@ -139,7 +139,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ideasSvc, err := ideas.NewService(grounder, ideaFinder(ai, guard), runner, logger)
|
||||
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
|
||||
// ideas.NewService ideas.KindFromIngredients).
|
||||
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -356,6 +356,18 @@ func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -338,6 +338,59 @@ automatic** (D-021).
|
||||
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}],
|
||||
@@ -516,6 +569,16 @@ automatic** (D-021).
|
||||
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
|
||||
|
||||
@@ -7,3 +7,4 @@ suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
|
||||
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
+100
-3
@@ -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.
|
||||
|
||||
+403
-93
@@ -1,10 +1,12 @@
|
||||
// Package ideas finds dinner ideas for Cook from what I have: from a list
|
||||
// of ingredients on hand, ideas_from_ingredients proposes dinners, each is
|
||||
// grounded with the recipe grounding pipeline (dropping those no recipe
|
||||
// grounds), 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 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 (
|
||||
@@ -14,15 +16,27 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
const KindFromIngredients = "ideas_from_ingredients"
|
||||
// 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.
|
||||
@@ -38,25 +52,26 @@ const (
|
||||
// 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 normalize_recipe while
|
||||
// grounding, failed; no idea is made up instead (D-017).
|
||||
// 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
|
||||
// ideas_from_ingredients' rules, because no recipe grounds it, or, for
|
||||
// a Quick only request, because its grounded recipe is not a Quick
|
||||
// dinner.
|
||||
// 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 the job.
|
||||
// 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.
|
||||
// ingredient list or an unknown kind of Quick dinner.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
@@ -68,42 +83,69 @@ func (e *InvalidError) Error() string { return e.Message }
|
||||
// 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 finds ideas with generate, grounds
|
||||
// them with grounder and registers the KindFromIngredients job on runner,
|
||||
// which must not have started yet. The job is started only by
|
||||
// StartFromIngredients, which checks the ingredient list first.
|
||||
func NewService(grounder *recipes.Grounder, generate Generator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
// 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{grounder: grounder, generate: generate, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindFromIngredients, s.run, Reasons...)
|
||||
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(withoutIdeas); err != nil {
|
||||
if err := kind.FailedResult(failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.job = kind
|
||||
return s, nil
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
@@ -195,31 +237,54 @@ func IsQuick(method string, minutes *int) bool {
|
||||
// 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, 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.
|
||||
// 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)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return Result{}, 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.
|
||||
s.logger.InfoContext(ctx, "ideas from ingredients failed", "reason", failure.Code)
|
||||
}
|
||||
if len(res.Ideas) > 0 {
|
||||
res.Ideas = []Idea{}
|
||||
if perr := publish(res); perr != nil {
|
||||
return Result{}, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
|
||||
}
|
||||
}
|
||||
return Result{}, err
|
||||
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
|
||||
}
|
||||
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
|
||||
@@ -243,34 +308,16 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
// order[k] is the index in out.Ideas of res.Ideas[k], so each idea is
|
||||
// put in its place in the model's order as it grounds.
|
||||
var order []int
|
||||
for next := 0; len(res.Ideas) < skills.IdeasKept && next < len(out.Ideas); {
|
||||
first := next
|
||||
round := out.Ideas[first:min(first+skills.IdeasKept-len(res.Ideas), len(out.Ideas))]
|
||||
next += len(round)
|
||||
err := s.ground(ctx, round, func(i int, gr *recipes.Grounded) error {
|
||||
if gr == nil {
|
||||
res.Dropped++
|
||||
return nil
|
||||
}
|
||||
idea := ideaOf(round[i], *gr)
|
||||
if p.QuickOnly && !idea.Quick {
|
||||
res.Dropped++
|
||||
return nil
|
||||
}
|
||||
at := len(order)
|
||||
for at > 0 && order[at-1] > first+i {
|
||||
at--
|
||||
}
|
||||
order = slices.Insert(order, at, first+i)
|
||||
res.Ideas = slices.Insert(res.Ideas, at, idea)
|
||||
return publish(res)
|
||||
})
|
||||
if 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}
|
||||
@@ -279,20 +326,58 @@ func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(R
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ground grounds ideas by their search queries and calls kept, one call at
|
||||
// a time, as each idea grounds, with its index in ideas 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 (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i int, gr *recipes.Grounded) error) error {
|
||||
// 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()
|
||||
reqs := make([]recipes.GroundRequest, len(ideas))
|
||||
for i, idea := range ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
var failed error
|
||||
s.grounder.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
if failed != nil {
|
||||
return
|
||||
}
|
||||
@@ -309,7 +394,7 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
failed = &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", ideas[i].Name, r.Err)
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
|
||||
}
|
||||
if failed != nil {
|
||||
cancel()
|
||||
@@ -324,13 +409,12 @@ func (s *Service) ground(ctx context.Context, ideas []skills.Idea, kept func(i i
|
||||
// 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(stored json.RawMessage) (json.RawMessage, error) {
|
||||
var res Result
|
||||
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)
|
||||
}
|
||||
res.Ideas = []Idea{}
|
||||
return res.PublicJSON()
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
@@ -350,3 +434,229 @@ func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"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"
|
||||
@@ -32,17 +33,21 @@ type env struct {
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
// model answers ideas_from_ingredients; normalize answers
|
||||
// normalize_recipe while grounding.
|
||||
// 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 without a model call.
|
||||
// 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 and an unstarted
|
||||
// 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 with the scripted e.model, and grounds with fake
|
||||
// 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()
|
||||
@@ -60,12 +65,16 @@ func newEnv(t *testing.T) *env {
|
||||
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)
|
||||
@@ -84,12 +93,18 @@ func newEnv(t *testing.T) *env {
|
||||
}
|
||||
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(g, generate, e.runner, logger)
|
||||
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+172
-1
@@ -4,11 +4,16 @@ 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 body.
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
|
||||
const maxIdeasBody = 16 << 10
|
||||
|
||||
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
||||
@@ -51,3 +56,169 @@ func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.Hand
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"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"
|
||||
@@ -28,13 +29,16 @@ import (
|
||||
const ideasPath = "/api/v1/ideas/from-ingredients"
|
||||
|
||||
// newIdeasRouter returns a router whose ideas service runs
|
||||
// ideas_from_ingredients 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. The runner is started when
|
||||
// start is set.
|
||||
// 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 {
|
||||
@@ -54,7 +58,10 @@ func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestes
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(g, generate, runner, logger)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,9 @@ type RouteMounter interface {
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients; runner serves
|
||||
// /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.
|
||||
@@ -164,6 +166,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
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))
|
||||
|
||||
@@ -192,7 +192,7 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
}
|
||||
|
||||
// newTestIdeas returns an ideas service with its own unstarted job runner
|
||||
// and a generator that always fails.
|
||||
// and generators that always fail.
|
||||
func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
@@ -205,7 +205,10 @@ func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.Ideas{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(g, fail, runner, logger)
|
||||
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)
|
||||
}
|
||||
|
||||
+20
-18
@@ -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<typeof fetch>().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(
|
||||
<InDataRouter path="/quick">
|
||||
<InDataRouter path="/settings/notifications">
|
||||
<App api={api} serviceWorker={undefined} />
|
||||
</InDataRouter>,
|
||||
)
|
||||
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 () => {
|
||||
|
||||
@@ -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<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/quick': <QuickDinnersPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
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<Response>
|
||||
|
||||
// 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<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((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<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
function bodyOf(call: Parameters<typeof fetch>) {
|
||||
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(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/cook-from-pantry']}>
|
||||
<Routes>
|
||||
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
|
||||
<Route path="/plan" element={<p>Plan screen</p>} />
|
||||
<Route path="/" element={<p>Home screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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<typeof stubServer>['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('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()
|
||||
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<Response>()
|
||||
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('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({
|
||||
[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<Response>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,321 @@
|
||||
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 {
|
||||
ADDED_LABEL,
|
||||
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<Exclude<IdeaStage, 'done'>, 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; 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()
|
||||
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
|
||||
return (
|
||||
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
|
||||
</div>
|
||||
<p className={styles.summary}>{idea.summary}</p>
|
||||
<div className={styles.actions}>
|
||||
{tonight.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> On tonight's plan
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || tonight.isPending}
|
||||
onClick={() => tonight.mutate(ideaRef!)}
|
||||
>
|
||||
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
|
||||
</Button>
|
||||
)}
|
||||
{questLog.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> {ADDED_LABEL[questLog.data.status]}
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{noPlan ? (
|
||||
<div className={styles.noPlan} role="status">
|
||||
<span>This week has no plan yet, so there's no tonight to put it on.</span>
|
||||
<Link to="/plan" className={styles.planLink}>
|
||||
Plan this week
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
tonight.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't put it on tonight's plan. {tonight.error.message}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
|
||||
}
|
||||
|
||||
// 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 lists what is still typed, so it is enabled by a chip
|
||||
// or by text typed and not yet added, and 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<HTMLInputElement>(null)
|
||||
const [ingredients, setIngredients] = useState<string[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [when, setWhen] = useState<When>('any')
|
||||
// The search last asked for, which Retry asks again, and its job.
|
||||
const [asked, setAsked] = useState<Search | null>(null)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const findIdeas = useFindIdeas()
|
||||
const job = useJob<unknown>(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<HTMLInputElement>) {
|
||||
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 = (
|
||||
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
|
||||
<p className={styles.count}>{findingLines.suggesting}</p>
|
||||
<Skeletons count={IDEAS_KEPT} />
|
||||
</section>
|
||||
)
|
||||
} else if (findIdeas.isError) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null && job.status === 'failed') {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
|
||||
} else if (found.error !== null) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
|
||||
} 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 && (
|
||||
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
|
||||
)}
|
||||
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
|
||||
<p className={styles.count}>
|
||||
{done
|
||||
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
|
||||
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
|
||||
</p>
|
||||
{ideas.map((idea, index) => (
|
||||
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
|
||||
))}
|
||||
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<h1 className={styles.title}>Cook from what I have</h1>
|
||||
</header>
|
||||
{/* A tap anywhere on the field types in it. */}
|
||||
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
|
||||
<ul className={styles.chips} aria-label="Ingredients on hand">
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={ingredient}>
|
||||
<Chip
|
||||
label={ingredient}
|
||||
className={styles.chip}
|
||||
onRemove={() => {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.filter((_, j) => j !== i))
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.entry}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label="Add ingredient"
|
||||
labelHidden
|
||||
placeholder="Add ingredient…"
|
||||
enterKeyHint="enter"
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={onDraftKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{notice !== null && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.controls}>
|
||||
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
|
||||
<Button
|
||||
variant="dark"
|
||||
className={styles.find}
|
||||
disabled={(ingredients.length < 1 && draft.trim() === '') || findIdeas.isPending}
|
||||
onClick={find}
|
||||
>
|
||||
Find ideas
|
||||
</Button>
|
||||
</div>
|
||||
{results}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
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<Response>
|
||||
|
||||
// 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<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((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<string, Responder> = {}) {
|
||||
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<typeof stubServer>['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<typeof stubServer>['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(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/quick']}>
|
||||
<Routes>
|
||||
<Route path="/quick" element={<QuickDinnersPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
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<Response>()
|
||||
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('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<Response>()
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
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 {
|
||||
ADDED_LABEL,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
MAX_EXCLUDE,
|
||||
parseQuickResult,
|
||||
QUICK_IDEAS_KEPT,
|
||||
QUICK_JOB_KIND,
|
||||
quickTabKey,
|
||||
shownNames,
|
||||
useAddIdeaToQuestLog,
|
||||
useFindQuickIdeas,
|
||||
useQuickTab,
|
||||
type AddedDinner,
|
||||
type AddedStatus,
|
||||
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<QuickKind, string> = {
|
||||
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, 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.
|
||||
function QuickRow({
|
||||
idea,
|
||||
ideaRef,
|
||||
added,
|
||||
onAdded,
|
||||
}: {
|
||||
idea: QuickIdea
|
||||
ideaRef: IdeaRef | null
|
||||
added: AddedStatus | undefined
|
||||
onAdded: (idea: IdeaRef, dinner: AddedDinner) => void
|
||||
}) {
|
||||
const questLog = useAddIdeaToQuestLog(onAdded)
|
||||
return (
|
||||
<li className={styles.row} aria-label={idea.name}>
|
||||
<Photo id={idea.photo_id} name={idea.name} radius="50%" sizes="64px" className={styles.photo} />
|
||||
<div className={styles.text}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
<span className={styles.meta}>{meta(idea)}</span>
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{added !== undefined ? (
|
||||
<span className={styles.added} role="status">
|
||||
<Icon icon={Check} size="nav" label={ADDED_LABEL[added]} />
|
||||
</span>
|
||||
) : (
|
||||
<IconButton
|
||||
label={`Add ${idea.name} to the Quest log`}
|
||||
tone="teal"
|
||||
className={styles.add}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
<Icon icon={Plus} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => (
|
||||
<li key={i} aria-hidden="true">
|
||||
<Skeleton data-testid="skeleton-row" className={styles.skeletonRow} />
|
||||
</li>
|
||||
))
|
||||
}
|
||||
|
||||
// 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<unknown>(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, { 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
|
||||
// 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 = <Skeletons count={QUICK_IDEAS_KEPT} />
|
||||
} else if (tab.startError !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't start looking for Quick dinners. {tab.startError.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (jobId !== null && failure !== null && !exhausted) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't find Quick dinners. {ideasJobFailure(failure)}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} else if (found.error !== null) {
|
||||
next = (
|
||||
<li>
|
||||
<ErrorBanner onRetry={ask}>Couldn't read the Quick dinners. {found.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)
|
||||
} 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 && <Skeletons count={Math.max(QUICK_IDEAS_KEPT - shown.length, 0)} />}
|
||||
{job.error !== null && (
|
||||
<li>
|
||||
<ErrorBanner onRetry={lost ? ask : job.poll}>Couldn't check on the Quick dinners. {job.error.message}</ErrorBanner>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<section aria-label={TABS.find((t) => t.value === kind)!.label} className={styles.panel}>
|
||||
<p className={styles.helper}>{helpers[kind]}</p>
|
||||
<ul aria-busy={busy} aria-label="Quick dinners" className={styles.rows}>
|
||||
{rows.map(({ idea, ref, key }) => (
|
||||
<QuickRow key={key} idea={idea} ideaRef={ref} added={tab.added[key]} onAdded={markAdded} />
|
||||
))}
|
||||
{next}
|
||||
</ul>
|
||||
{between &&
|
||||
(allShown ? (
|
||||
<p className={styles.end} role="status">
|
||||
That's every Quick dinner for now.
|
||||
</p>
|
||||
) : (
|
||||
<button type="button" className={styles.more} onClick={ask}>
|
||||
<Icon icon={RefreshCw} /> Show me more
|
||||
</button>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<QuickKind>('under30')
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<span className={styles.zap}>
|
||||
<Icon icon={Zap} className={styles.zapIcon} />
|
||||
</span>
|
||||
<h1 className={styles.title}>Quick dinners</h1>
|
||||
</header>
|
||||
<Segmented label="Kind of Quick dinner" options={TABS} value={kind} onValueChange={setKind} className={styles.tabs} />
|
||||
<QuickTabPanel key={kind} kind={kind} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
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, type Regular } 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<string, string> = {
|
||||
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<string> => {
|
||||
const body = await apiFetch<unknown>(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 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<string, AddedStatus>
|
||||
}
|
||||
|
||||
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<QuickTab>(quickTabKey(kind)) ?? NEW_TAB,
|
||||
initialData: NEW_TAB,
|
||||
staleTime: Infinity,
|
||||
gcTime: Infinity,
|
||||
})
|
||||
const update = useCallback(
|
||||
(change: (tab: QuickTab) => QuickTab) => queryClient.setQueryData<QuickTab>(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<string> => {
|
||||
queryClient.setQueryData<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), startError: null }))
|
||||
const body = await apiFetch<unknown>(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<QuickTab>(quickTabKey(kind), (tab) => ({ ...(tab ?? NEW_TAB), jobId }))
|
||||
return jobId
|
||||
},
|
||||
onError: (startError: Error) => queryClient.setQueryData<QuickTab>(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}`
|
||||
}
|
||||
|
||||
// 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<AddedStatus, string> = {
|
||||
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
|
||||
// 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<AddedDinner> => {
|
||||
const path = ideaPath(idea, 'quest-log')
|
||||
return parseAddedDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (dinner, idea) => {
|
||||
onAdded?.(idea, dinner)
|
||||
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<string> => {
|
||||
const path = ideaPath(idea, 'cook-tonight')
|
||||
const where = `POST ${path}`
|
||||
const body = await apiFetch<unknown>(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 }),
|
||||
})
|
||||
}
|
||||
+3
-1
@@ -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' ||
|
||||
|
||||
@@ -201,10 +201,15 @@ async function openPage() {
|
||||
}
|
||||
|
||||
// reload ends the page open and its timers, as a reload does: a toggle it
|
||||
// was waiting to try again is never tried, by it, again.
|
||||
// was waiting to try again is never tried, by it, again. The clock reads on
|
||||
// as it did: clearAllTimers also puts it back to the real time the fake
|
||||
// timers were installed at, which would open the new page on whatever week
|
||||
// the real date falls in.
|
||||
function reload(page: { unmount: () => void }) {
|
||||
page.unmount()
|
||||
const now = Date.now()
|
||||
vi.clearAllTimers()
|
||||
vi.setSystemTime(now)
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
|
||||
Reference in New Issue
Block a user