diff --git a/cmd/server/main.go b/cmd/server/main.go index 3935c0a..ca0834e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/server" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -106,6 +107,10 @@ func run(cfg config.Config, logger *slog.Logger) error { if err != nil { return err } + suggestSvc, err := suggest.NewService(db, grounder, sideQuests(ai, guard), runner, logger) + if err != nil { + return err + } sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax) if err != nil { @@ -123,7 +128,7 @@ func run(cfg config.Config, logger *slog.Logger) error { return err } - router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, runner, photos, frontend.Assets()) + router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, runner, photos, frontend.Assets()) if err != nil { return err } @@ -247,7 +252,8 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds // registerJobKinds registers the framework's job kinds on runner. A feature // service registers its own kinds when it is built, before the runner -// starts (dinners.NewService registers dinners.KindGroundDinner). +// starts (dinners.NewService registers dinners.KindGroundDinner and +// suggest.NewService suggest.KindSuggestionBatch). func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) { builtins, err := jobs.RegisterBuiltins(runner) if err != nil { @@ -268,6 +274,18 @@ func normalizer(ai *aisettings.Service, guard *harness.Guard) recipes.Normalizer } } +// sideQuests runs suggest_side_quests with the AI settings current at each +// run, sharing guard. +func sideQuests(ai *aisettings.Service, guard *harness.Guard) suggest.Suggester { + return func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) { + cfg, err := ai.HarnessConfig(ctx, guard) + if err != nil { + return skills.SideQuests{}, err + } + return skills.SuggestSideQuests(ctx, cfg, scope, in) + } +} + // logListening logs the startup line. Only the public URL's origin is logged // so no credential-bearing URL component can reach the log. func logListening(logger *slog.Logger, cfg config.Config) { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 41af307..b566ef0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,7 +98,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`). | `photos` | source_url, local_path, width, height, sha256, fetched_at. | | `dinners` | The meal record and its lifecycle: name, normalized_name, status (`suggested` / `quest_log` / `tried` / `regular` / `archived`), origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, recipe_url (a pasted recipe link awaiting grounding), photo_id, grounding_status (`pending` / `grounded` / `failed` / `manual`), grounding_error (why grounding failed), grounding_revision (raised whenever a dinner is renamed, so only the latest `ground_dinner` job may write it and no candidate offered before the rename grounds it), updated_by. | | `suggestion_batches` | trigger, status, ai_run_id. | -| `suggestions` | dinner_id, batch_id, reason, cited_regular_ids (JSON), status, decided_at. | +| `suggestions` | dinner_id, batch_id, name (the name the dish was suggested under, kept whatever its dinner is later renamed, so a Pass stays an immutable record every later batch dedupes against), reason, cited_regular_ids (JSON), status, decided_at. | | `week_plans` | week_start (unique), status (`draft` / `synced` / `changed`), ai_note, ai_run_id, calendar_synced_at. | | `plan_days` | plan_id, date (unique per plan), dinner_id, is_new, calendar_event_id. | | `rotation_reviews` | dinner_id + plan_date (unique), status, decision (`keep` / `drop` / `not_made`), decided_at, updated_by. | @@ -127,7 +127,7 @@ suggested (deck) ──Try it──▶ quest_log ──planned date passes── │ └──────── explicit decision ─────┤ │ ├──▶ regular │ └──▶ archived - └──Pass──▶ archived (name kept for dedupe) + └──Pass──▶ archived (suggested name kept for dedupe) ``` From `tried`, an explicit decision moves the dinner to `regular`, to @@ -194,7 +194,9 @@ automatic** (D-021). `GET /api/v1/dinners?status=regular|quest_log` returns `{dinners: [...]}` ordered by normalized name, each with `photo_url` (`/media/photos/{photo_id}-400.jpg`, or null) and `grounding_status`; - any other status is 400. `PATCH /api/v1/dinners/{id}` edits `name` + any other status is 400. The Quest log (`quest_log`) is the dinners + accepted and not yet scheduled: one on the plan for the household's + current week (by its timezone) or a later week is left out. `PATCH /api/v1/dinners/{id}` edits `name` (deduplicated against the other unarchived dinners), `method` (`stovetop` / `oven` / `crockpot` / `grill` / `other`), `minutes_total` (1–1440) and `difficulty` (`easy` / `medium` / `hard`); invalid values @@ -271,6 +273,67 @@ automatic** (D-021). dinner, is 500, logged with a fixed reason and the dinner id only — never the link, which can carry a personal token, or dependency error text. +- Side quest suggestions (`suggest.Service`). `POST + /api/v1/suggestions/batches` creates a `manual` batch and enqueues its + `suggestion_batch` job, answering 202 `{batch_id, job_id, trigger, + status}` without waiting; while a batch is `pending` or `running` it + answers 202 with that batch instead, so at most one runs at a time (the + check, the batch and its job share one write transaction). The job reads the + household context, the deck's names and every declined name, runs + `suggest_side_quests` for 5 Side quests (scope: the batch id), then + grounds the survivors in the model's order with `Grounder.GroundMany`, + as many as are still wanted at a time, until 5 are kept or none are + left. A Side quest no recipe grounds is dropped; each round's grounded + ones are stored before the next — a dinner with status `suggested`, + origin `side_quest`, grounding_status `grounded` with the recipe's + recipe_id, photo_id, method, minutes_total and difficulty and the + model's cuisine, and a `pending` suggestion with the name it is + suggested under, the reason and `cited_regular_ids` — unless, since the + job read its input, a dinner it repeats was added, a suggestion it + repeats passed, or a Regular it cites renamed or archived (checked in + the storing transaction). The job publishes + `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`, + `grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the + skill or `normalize_recipe` failed; no Side quest is made up instead, + D-017), `search_not_configured` or `search_failed`, and the batch is set + `failed`; Side quests stored before the failure stay in the deck. If + recording the batch's end fails, its job fails; the job runner retries + a job's final status write with backoff until it lands, each attempt + bounded at 5 seconds; at shutdown an attempt that fails or times out is + not retried, and the next start fails the job as interrupted. Recording + the batch's end is bounded the same way (one attempt, 5 seconds, made + even during shutdown), so a busy writer cannot hold a stopping worker; + a batch whose end timed out is left `running` with its job. Each time a + batch is started, before the active batch is selected, every batch + still `pending` or `running` with no job queued or running (the process + stopped, or recording its end failed) is set `failed`, so it does not + block the next. + `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}], + active_batch, failed_batch}`: the `pending` suggestions whose dinner is + still `suggested`, oldest first; the batch `pending` or `running` + (`{batch_id, job_id, trigger, status}`, or null); and the newest batch + when it `failed` (same shape, or null; a later batch, done or under + way, supersedes the failure), read in one snapshot, so a client that + did not start the batch can follow its job and learns of a failure it + was not following. At most one of the two is set. `POST /api/v1/suggestions/{id}/accept` (Try it) sets the + suggestion `accepted` and moves its dinner to `quest_log`; `POST + /api/v1/suggestions/{id}/decline` (Pass) sets it `declined` and archives + the dinner; every later batch, and the household context's passed list, + reads the suggestion's own name as declined, so renaming the archived + dinner changes nothing. Both record + the session's subject as the dinner's `updated_by` and answer 200 + `{suggestion, replenish_job_id}`. Repeating a decision changes nothing + and answers 200; the opposite decision is 409 `suggestion_decided`, a + suggestion whose dinner left the deck some other way 409 + `dinner_not_suggested`, and an unknown id 404. A decision that leaves + fewer than 5 in the deck starts a `replenish` batch, or joins the batch + already pending or running, and returns that batch's job id either way + (`replenish_job_id` is null when the deck keeps 5 or more). The batch and its job are written in + the decision's own transaction, so a decision is never recorded without + the replenish it calls for; when they cannot be written, neither is the + decision, and a retry decides again. - `/health` — liveness. - Everything else serves the embedded PWA with SPA fallback. @@ -333,6 +396,18 @@ automatic** (D-021). dinner when the token-set (Jaccard) similarity of their words is at least 0.85. A `Deduper` over the Regulars, Quest log, pending suggestions and passed names rejects a suggestion that repeats any of them. +- **Side quest suggestions** (`skills.SuggestSideQuests`, skill + `suggest_side_quests` v1): asks for twice the Side quests needed, at the + household temperature, with the household context plus the pending Side + quests. Malformed replies (bad JSON, a missing field, a bad enum, an + empty name) get the one repair turn. Well-formed Side quests that break a + rule are dropped, not repaired, and listed in `Dropped` with the rule: + one that repeats a Regular, Quest log dinner, pending Side quest, any + passed name (the whole history, not only the 60 in the context) or an + earlier Side quest of the batch; one citing no Regular or a name that is + not a Regular; and one whose reason is over 180 characters or names none + of its cited Regulars. Cited names are rewritten as the household writes + them. Grounding each survivor in a recipe is the caller's next step. ## Recipe grounding diff --git a/internal/ai/harness/context_test.go b/internal/ai/harness/context_test.go index b121eb7..ef369b6 100644 --- a/internal/ai/harness/context_test.go +++ b/internal/ai/harness/context_test.go @@ -82,9 +82,9 @@ func (s *seeder) pass(name, decidedAt string) { batch := s.id("batch") s.exec(`INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at) VALUES (?, 'manual', 'done', ?, ?)`, batch, decidedAt, decidedAt) - s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, - decided_at, created_at, updated_at) VALUES (?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`, - s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, decidedAt, decidedAt, decidedAt) + s.exec(`INSERT INTO suggestions (id, dinner_id, batch_id, name, reason, cited_regular_ids, status, + decided_at, created_at, updated_at) VALUES (?, ?, ?, ?, 'why', '[]', 'declined', ?, ?, ?)`, + s.id("suggestion"), s.dinner(name, "archived", "", "", 0, false), batch, name, decidedAt, decidedAt, decidedAt) } // plan inserts the week plan starting weekStart with one dinner per date; diff --git a/internal/ai/skills/prompts/checksums.txt b/internal/ai/skills/prompts/checksums.txt index c7c6a99..baf7690 100644 --- a/internal/ai/skills/prompts/checksums.txt +++ b/internal/ai/skills/prompts/checksums.txt @@ -3,3 +3,4 @@ # version, rename its template to match and add a line for the new version. echo v1 ab5d7884f5c7a2261c06b0c9353bb9597070c001420bd4cbb6ec6272c093fdce normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752d7ee +suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a diff --git a/internal/ai/skills/prompts/suggest_side_quests.v1.tmpl b/internal/ai/skills/prompts/suggest_side_quests.v1.tmpl new file mode 100644 index 0000000..db2b54d --- /dev/null +++ b/internal/ai/skills/prompts/suggest_side_quests.v1.tmpl @@ -0,0 +1,22 @@ +{{/* suggest_side_quests drafts Side quests: new dinners for the household to try, each based on its Regulars. */}} +{{define "instructions" -}} +You suggest Side quests: new dinners for the household to try, based on the dinners it already makes. + +Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch. + +Give for each Side quest: +- name: the dinner's usual name, as a recipe site would title it. +- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex". +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- difficulty: "easy", "medium" or "hard" for a home cook. +- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars. +- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list. +- search_query: a web search query that finds a recipe for this dinner. + +One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household): +{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"} +{{- end}} +{{define "user" -}} +Suggest {{.Count}} Side quests. +{{- end}} diff --git a/internal/ai/skills/suggest_side_quests.go b/internal/ai/skills/suggest_side_quests.go new file mode 100644 index 0000000..ac10af7 --- /dev/null +++ b/internal/ai/skills/suggest_side_quests.go @@ -0,0 +1,420 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" +) + +// ReasonLimit is the most characters a Side quest's reason, its "Why +// this?", may have. +const ReasonLimit = 180 + +// SideQuestsRequest is what suggest_side_quests reads. +type SideQuestsRequest struct { + // Context is the household context (see harness.BuildContext). It must + // hold at least one Regular, since every Side quest cites one. + Context harness.HouseholdContext + // Needed is how many Side quests the caller wants to keep. The skill + // asks the model for twice as many, since validation and grounding + // drop some. It must be positive. + Needed int + // Pending holds the names of the Side quests not yet tried or passed. + Pending []string + // Declined holds the name of every passed Side quest, not only the + // recent ones Context holds. + Declined []string +} + +// SideQuest is one suggested new dinner. Method, EstMinutes and Difficulty +// are the model's estimates until the dinner is grounded in a recipe. +type SideQuest struct { + Name string `json:"name"` + Cuisine string `json:"cuisine"` + // Method is one of stovetop, oven, crockpot, grill and other. + Method string `json:"method"` + EstMinutes int `json:"est_minutes"` + // Difficulty is easy, medium or hard. + Difficulty string `json:"difficulty"` + // Reason is the "Why this?": at most ReasonLimit characters, naming at + // least one of CitedRegulars. + Reason string `json:"reason"` + // CitedRegulars are names of the household's Regulars, as the + // household writes them. + CitedRegulars []string `json:"cited_regulars"` + // SearchQuery is a web search query that finds a recipe for the dinner. + SearchQuery string `json:"search_query"` +} + +// SideQuests is suggest_side_quests' output. +type SideQuests struct { + // Suggestions are the Side quests that passed every rule, in the + // model's order. There may be more or fewer than Needed. + Suggestions []SideQuest `json:"suggestions"` + // Dropped are the Side quests the model gave that broke a rule, in the + // model's order. It is never part of the model's reply. + Dropped []DroppedSideQuest `json:"-"` +} + +// DroppedSideQuest is a Side quest dropped by PostProcess. +type DroppedSideQuest struct { + Name string + // Why says which rule it broke. + Why string +} + +// SuggestSideQuests runs suggest_side_quests v1 on in and returns the Side +// quests that cite only real Regulars, give a short reason naming one of +// them, and repeat no Regular, Quest log dinner, pending or passed Side +// quest, nor each other. The rest are in Dropped. scope names the batch +// (see harness.Run). +func SuggestSideQuests(ctx context.Context, cfg harness.Config, scope string, in SideQuestsRequest) (SideQuests, error) { + return harness.Run(ctx, cfg, suggestSideQuests{in: in}, scope, in) +} + +// suggestSideQuests is the skill. It holds its input because validation +// and post-processing compare the output with it. +type suggestSideQuests struct { + in SideQuestsRequest +} + +var _ harness.Skill[SideQuestsRequest, SideQuests] = suggestSideQuests{} + +func (suggestSideQuests) Name() string { return "suggest_side_quests" } + +func (suggestSideQuests) Version() int { return 1 } + +func (suggestSideQuests) Timeout() time.Duration { return harness.DefaultTimeout } + +// sideQuestsPrompt is the template data of suggest_side_quests.v1.tmpl. +type sideQuestsPrompt struct { + // Count is how many Side quests to ask for. + Count int +} + +// requested is how many Side quests the model is asked for. +func (s suggestSideQuests) requested() int { return 2 * s.in.Needed } + +func (suggestSideQuests) PromptInput(in SideQuestsRequest) (harness.PromptInput, error) { + if in.Needed <= 0 { + return harness.PromptInput{}, fmt.Errorf("%d Side quests needed; the number must be positive", in.Needed) + } + if len(in.Context.Regulars) == 0 { + return harness.PromptInput{}, errors.New("household has no Regulars for Side quests to be based on") + } + sections := append(in.Context.Sections(), harness.ContextSection{Title: "Pending Side quests", Lines: in.Pending}) + return harness.PromptInput{Data: sideQuestsPrompt{Count: suggestSideQuests{in: in}.requested()}, Context: sections}, nil +} + +func (suggestSideQuests) Schema() llm.JSONSchema { + return llm.JSONSchema{Name: "suggest_side_quests", Schema: json.RawMessage(`{ + "type": "object", + "properties": { + "suggestions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "cuisine": {"type": "string"}, + "method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]}, + "est_minutes": {"type": "integer"}, + "difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]}, + "reason": {"type": "string"}, + "cited_regulars": {"type": "array", "items": {"type": "string"}}, + "search_query": {"type": "string"} + }, + "required": ["name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"], + "additionalProperties": false + } + } + }, + "required": ["suggestions"], + "additionalProperties": false + }`)} +} + +// UnmarshalJSON decodes a Side quest, requiring each of its fields as the +// schema does: encoding/json would otherwise read a missing or null one as +// empty. +func (q *SideQuest) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "name", "cuisine", "method", "est_minutes", "difficulty", "reason", "cited_regulars", "search_query"); err != nil { + return fmt.Errorf("suggestion %s", err) + } + type plain SideQuest + return json.Unmarshal(data, (*plain)(q)) +} + +// UnmarshalJSON decodes the output, requiring its one field as the schema +// does. +func (out *SideQuests) UnmarshalJSON(data []byte) error { + if err := requireFields(data, nil, "suggestions"); err != nil { + return fmt.Errorf("output %s", err) + } + type plain SideQuests + return json.Unmarshal(data, (*plain)(out)) +} + +// Validate finds what makes the reply unusable as a whole: a wrong number +// of Side quests or one that is malformed. A well-formed Side quest that +// breaks a rule is dropped by PostProcess instead. +func (s suggestSideQuests) Validate(out SideQuests) []string { + var problems []string + switch n := len(out.Suggestions); { + case n == 0 && len(out.Dropped) == 0: + problems = append(problems, fmt.Sprintf("suggestions is empty; give %d Side quests", s.requested())) + case n > s.requested(): + problems = append(problems, fmt.Sprintf("suggestions has %d entries; give at most %d Side quests", n, s.requested())) + } + for i, q := range out.Suggestions { + for _, f := range []struct{ field, value string }{ + {"name", q.Name}, {"cuisine", q.Cuisine}, {"reason", q.Reason}, {"search_query", q.SearchQuery}, + } { + if strings.TrimSpace(f.value) == "" { + problems = append(problems, fmt.Sprintf("suggestion %d has an empty %s; give one", i+1, f.field)) + } + } + if strings.TrimSpace(q.Name) != "" && harness.Normalize(q.Name) == "" { + problems = append(problems, fmt.Sprintf("suggestion %d name %q has only filler words; name the dish", i+1, q.Name)) + } + if !slices.Contains(methods, q.Method) { + problems = append(problems, fmt.Sprintf("suggestion %d method %q is not one of %s", i+1, q.Method, strings.Join(methods, ", "))) + } + if q.EstMinutes <= 0 { + problems = append(problems, fmt.Sprintf("suggestion %d est_minutes %d is not positive; give the minutes from prep to table", i+1, q.EstMinutes)) + } + if !slices.Contains(difficulties, q.Difficulty) { + problems = append(problems, fmt.Sprintf("suggestion %d difficulty %q is not one of %s", i+1, q.Difficulty, strings.Join(difficulties, ", "))) + } + } + return problems +} + +// PostProcess tidies every Side quest's text, writes its cited Regulars as +// the household does, and moves each one that breaks a rule to Dropped +// (see drop). +func (s suggestSideQuests) PostProcess(out SideQuests) (SideQuests, error) { + regulars := regularNames{exact: map[string][]string{}, normalized: map[string][]string{}} + var names []string + for _, r := range s.in.Context.Regulars { + regulars.add(r.Name) + names = append(names, r.Name) + } + regulars.all = names + known := harness.NewDeduper(harness.DedupeSources{ + Regulars: names, + QuestLog: s.in.Context.QuestLog, + Pending: s.in.Pending, + Declined: s.in.Declined, + }) + result := SideQuests{Suggestions: []SideQuest{}} + for _, q := range out.Suggestions { + q = SideQuest{ + Name: oneLine(q.Name), + Cuisine: oneLine(q.Cuisine), + Method: q.Method, + EstMinutes: q.EstMinutes, + Difficulty: q.Difficulty, + Reason: oneLine(q.Reason), + CitedRegulars: q.CitedRegulars, + SearchQuery: oneLine(q.SearchQuery), + } + cited, why := drop(q, regulars, known, result.Suggestions) + if why != "" { + result.Dropped = append(result.Dropped, DroppedSideQuest{Name: q.Name, Why: why}) + continue + } + q.CitedRegulars = cited + result.Suggestions = append(result.Suggestions, q) + } + return result, nil +} + +// regularNames finds the Regular a cited name means. Names need not be +// unique, even ignoring case, so each key holds every Regular with it. +type regularNames struct { + // exact is keyed by the name in lower case on one line. + exact map[string][]string + // normalized is keyed by harness.Normalize of the name. + normalized map[string][]string + // all holds every Regular's name, in the household's order. + all []string +} + +func exactName(name string) string { return strings.ToLower(oneLine(name)) } + +func (r regularNames) add(name string) { + r.exact[exactName(name)] = append(r.exact[exactName(name)], name) + if n := harness.Normalize(name); n != "" { + r.normalized[n] = append(r.normalized[n], name) + } +} + +// find returns the one Regular cited means, or "" and why there is none. +// A Regular named exactly cited, ignoring case and spacing, comes first; +// failing that, cited may name a Regular by its normalized name. +func (r regularNames) find(cited string) (string, string) { + matches := r.exact[exactName(cited)] + if len(matches) == 0 { + matches = r.normalized[harness.Normalize(cited)] + } + switch len(matches) { + case 0: + return "", fmt.Sprintf("cites %q, which is not a Regular", cited) + case 1: + return matches[0], "" + default: + return "", fmt.Sprintf("cites %q, which could be any of the Regulars %q", cited, matches) + } +} + +// drop returns why q must be dropped, or "" and its cited Regulars as the +// household writes them when it may be kept. q is dropped when it repeats +// a known dinner or a Side quest already kept, cites no Regular or one +// regulars finds no single Regular for, or has a reason over ReasonLimit +// characters or naming none of its cited Regulars. +func drop(q SideQuest, regulars regularNames, known *harness.Deduper, kept []SideQuest) ([]string, string) { + if known.IsDuplicate(q.Name) { + return nil, "repeats a Regular, Quest log dinner, pending or passed Side quest" + } + for _, k := range kept { + if harness.Similar(q.Name, k.Name) { + return nil, fmt.Sprintf("repeats the Side quest %q in the same batch", k.Name) + } + } + if len(q.CitedRegulars) == 0 { + return nil, "cites no Regular" + } + var cited []string + for _, c := range q.CitedRegulars { + name, why := regulars.find(c) + if why != "" { + return nil, why + } + cited = append(cited, name) + } + if n := utf8.RuneCountInString(q.Reason); n > ReasonLimit { + return nil, fmt.Sprintf("reason has %d characters, over %d", n, ReasonLimit) + } + named := regulars.named(q.Reason) + if !slices.ContainsFunc(cited, func(name string) bool { return slices.Contains(named, name) }) { + return nil, "reason names none of its cited Regulars" + } + return cited, "" +} + +// named returns the Regulars text names, by one rule. A mention of a +// Regular is a place where text spells its name, ignoring case and spacing, +// with no letter, mark or digit just outside it: the start or end of text, +// white space, punctuation or a symbol must border it, so "Beef +// Chili-inspired" and "Beef Chili's" mention "Beef Chili" while "Beef +// Chilis" does not, nor does "Cafe" with a combining accent after it +// mention "Cafe". Punctuation at either end of the name, with any spacing +// beside it, is optional, and a mention takes in as much of it as text +// spells, spaced in any way, with the border still holding after it: "Easy +// Beef Chili!" is mentioned by "Easy Beef Chili." too, by "Easy Beef Chili +// !" with its "!", and by "Easy Beef Chili!x" without its "!", and "Beef +// Chili!!" by "Beef Chili!" with one "!". A +// combining mark is part of its letter, never punctuation. A mention names +// its Regular unless a mention of a different Regular covers the same text +// or more, so "Your Easy Beef Chili." names "Easy Beef Chili" and not "Beef +// Chili", and text that two Regulars spell alike names neither there. +func (r regularNames) named(text string) []string { + type mention struct { + name string + start, end int + } + text = exactName(text) + bordered := func(start, end int) bool { + before, _ := utf8.DecodeLastRuneInString(text[:start]) + after, _ := utf8.DecodeRuneInString(text[end:]) + return (start == 0 || !isWord(before)) && (end == len(text) || !isWord(after)) + } + var mentions []mention + for _, name := range r.all { + full := exactName(name) + core := strings.TrimFunc(full, isOuter) + if core == "" { + continue + } + lead := full[:len(full)-len(strings.TrimLeftFunc(full, isOuter))] + trail := full[len(lead)+len(core):] + for at := 0; ; { + i := strings.Index(text[at:], core) + if i < 0 { + break + } + start, end := at+i, at+i+len(core) + at = start + 1 + if !bordered(start, end) { + continue + } + start = spelledBefore(text, start, lead, func(s int) bool { return bordered(s, end) }) + end = spelledAfter(text, end, trail, func(e int) bool { return bordered(start, e) }) + mentions = append(mentions, mention{name, start, end}) + } + } + var names []string + for _, m := range mentions { + covered := slices.ContainsFunc(mentions, func(o mention) bool { + return o.name != m.name && o.start <= m.start && o.end >= m.end + }) + if !covered && !slices.Contains(names, m.name) { + names = append(names, m.name) + } + } + return names +} + +// spelledAfter returns where the longest start of outer that text spells +// from at ends, ignoring spacing, among those after which bordered holds, +// or at when there are none. +func spelledAfter(text string, at int, outer string, bordered func(int) bool) int { + end := at + for _, c := range strings.Join(strings.Fields(outer), "") { + at = len(text) - len(strings.TrimLeftFunc(text[at:], unicode.IsSpace)) + if !strings.HasPrefix(text[at:], string(c)) { + break + } + at += utf8.RuneLen(c) + if bordered(at) { + end = at + } + } + return end +} + +// spelledBefore returns where the longest end of outer that text spells up +// to at starts, ignoring spacing, among those before which bordered holds, +// or at when there are none. +func spelledBefore(text string, at int, outer string, bordered func(int) bool) int { + start := at + punct := []rune(strings.Join(strings.Fields(outer), "")) + for i := len(punct) - 1; i >= 0; i-- { + at = len(strings.TrimRightFunc(text[:at], unicode.IsSpace)) + if !strings.HasSuffix(text[:at], string(punct[i])) { + break + } + at -= utf8.RuneLen(punct[i]) + if bordered(at) { + start = at + } + } + return start +} + +// isWord reports whether c is a letter, a mark on one or a digit. +func isWord(c rune) bool { return unicode.IsLetter(c) || unicode.IsMark(c) || unicode.IsDigit(c) } + +// isOuter reports whether c may be left off the end of a name: punctuation +// or white space. +func isOuter(c rune) bool { return unicode.IsPunct(c) || unicode.IsSpace(c) } diff --git a/internal/ai/skills/suggest_side_quests_test.go b/internal/ai/skills/suggest_side_quests_test.go new file mode 100644 index 0000000..a5c0dc5 --- /dev/null +++ b/internal/ai/skills/suggest_side_quests_test.go @@ -0,0 +1,488 @@ +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strings" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +func date(y int, m time.Month, d int) store.LocalDate { + return store.LocalDateOf(time.Date(y, m, d, 0, 0, 0, 0, time.UTC)) +} + +// household asks for two Side quests, so four are requested. +var household = SideQuestsRequest{ + Context: harness.HouseholdContext{ + Today: date(2026, 10, 6), + Timezone: "America/New_York", + Regulars: []harness.RegularDinner{ + {Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240}, + {Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30, Favorite: true}, + {Name: "Spaghetti Bolognese", Cuisine: "Italian", Method: "stovetop", Minutes: 45}, + }, + QuestLog: []string{"Pad Thai"}, + Passed: []string{"Chicken Tikka Masala"}, + Plans: []harness.PlannedDinner{ + {Date: date(2026, 10, 5), Name: "Chicken Fajitas"}, + {Date: date(2026, 10, 6), Name: "Beef Chili"}, + }, + }, + Needed: 2, + Pending: []string{"Shrimp Scampi"}, + Declined: []string{"Chicken Tikka Masala", "Fish Tacos"}, +} + +// sideQuest renders one Side quest of a reply. +func sideQuest(name, reason string, cited ...string) string { + c, _ := json.Marshal(append([]string{}, cited...)) + r, _ := json.Marshal(reason) + return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`, + name, r, c, strings.ToLower(name)+" recipe") +} + +// batch renders a reply holding the Side quests given. +func batch(quests ...string) string { + return `{"suggestions":[` + strings.Join(quests, ",") + `]}` +} + +var ( + shawarma = sideQuest("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas") + ragu = sideQuest("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese") + verde = sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili") + bulgogi = sideQuest("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili") +) + +// want returns the Side quest that sideQuest renders, with cited as the +// household writes them. +func want(name, reason string, cited ...string) SideQuest { + return SideQuest{ + Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy", + Reason: reason, CitedRegulars: cited, SearchQuery: strings.ToLower(name) + " recipe", + } +} + +var ( + wantShawarma = want("Chicken Shawarma Bowls", "Spiced chicken and crisp toppings, like your Chicken Fajitas.", "Chicken Fajitas") + wantRagu = want("Beef Ragu Pappardelle", "A slow beef sauce like your Spaghetti Bolognese, on wide noodles.", "Spaghetti Bolognese") + wantVerde = want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili") + wantBulgogi = want("Beef Bulgogi", "Sweet, quick-seared beef for fans of Chicken Fajitas and Beef Chili.", "Chicken Fajitas", "Beef Chili") +) + +// suggest runs SuggestSideQuests on household with f and checks f was +// called exactly calls times, each at the household temperature. +func suggest(t *testing.T, f *llm.Fake, in SideQuestsRequest, calls int) (SideQuests, []llm.Request, error) { + t.Helper() + out, err := SuggestSideQuests(context.Background(), testConfig(t, f), "2026-W41", 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 != "suggest_side_quests" { + t.Fatalf("request %d schema = %v, want suggest_side_quests", i+1, req.Schema) + } + } + return out, reqs, err +} + +func assertSideQuests(t *testing.T, got, want SideQuests) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("output = %+v\nwant %+v", got, want) + } +} + +func TestSuggestSideQuestsCleanBatch(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(batch(shawarma, ragu, verde, bulgogi))) + out, reqs, err := suggest(t, &f, household, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi}}) + + system, user := reqs[0].System, reqs[0].Messages[0].Content + golden(t, "suggest_side_quests_clean.system.golden", system) + golden(t, "suggest_side_quests_clean.user.golden", user) + for _, part := range []string{ + "## Household context", + "- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite)", + "### Quest log\n- Pad Thai", + "### Passed, oldest first\n- Chicken Tikka Masala", + "- 2026-10-06 Tue: Beef Chili", + "### Pending Side quests\n- Shrimp Scampi", + "## Output contract", + `"cited_regulars"`, + `"search_query"`, + } { + if !strings.Contains(system, part) { + t.Errorf("system prompt lacks %q", part) + } + } + if strings.Index(system, "## Household context") > strings.Index(system, "## Output contract") { + t.Error("the output contract is not after the household context") + } + if user != "Suggest 4 Side quests." { + t.Errorf("user message = %q, want twice the 2 needed", user) + } +} + +func TestSuggestSideQuestsPersonaFirst(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(batch(shawarma))) + cfg := testConfig(t, &f) + cfg.Persona = "You are the household's cheerful kitchen helper." + if _, err := SuggestSideQuests(context.Background(), cfg, "2026-W41", household); err != nil { + t.Fatal(err) + } + if system := f.Requests()[0].System; !strings.HasPrefix(system, cfg.Persona+"\n\n## Instructions\n") { + t.Fatalf("system prompt does not start with the persona then the instructions:\n%s", system) + } +} + +func TestSuggestSideQuestsInventedRegularDropped(t *testing.T) { + invented := sideQuest("Meatloaf Wellington", "Your Grandma's Meatloaf, wrapped in pastry.", "Grandma's Meatloaf") + partly := sideQuest("Fajita Meatballs", "Chicken Fajitas meets your Grandma's Meatloaf.", "Chicken Fajitas", "Grandma's Meatloaf") + none := sideQuest("Mushroom Risotto", "Creamy and comforting.") + var f llm.Fake + f.QueueGenerate(reply(batch(invented, shawarma, partly, none))) + out, _, err := suggest(t, &f, household, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{wantShawarma}, + Dropped: []DroppedSideQuest{ + {Name: "Meatloaf Wellington", Why: `cites "Grandma's Meatloaf", which is not a Regular`}, + {Name: "Fajita Meatballs", Why: `cites "Grandma's Meatloaf", which is not a Regular`}, + {Name: "Mushroom Risotto", Why: "cites no Regular"}, + }, + }) +} + +func TestSuggestSideQuestsSharedNormalizedRegularNotRewritten(t *testing.T) { + in := household + in.Context.Regulars = append(slices.Clone(household.Context.Regulars), + harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30}) + plain := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "beef chili") + easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "EASY beef chili") + either := sideQuest("Tamale Bake", "Your Beef Chili under a cornbread crust.", "Beef Chili!") + var f llm.Fake + f.QueueGenerate(reply(batch(plain, easy, either))) + out, _, err := suggest(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{ + want("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Beef Chili"), + want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"), + }, + Dropped: []DroppedSideQuest{ + {Name: "Tamale Bake", Why: `cites "Beef Chili!", which could be any of the Regulars ["Beef Chili" "Easy Beef Chili"]`}, + }, + }) +} + +func TestSuggestSideQuestsReasonNamesResolvedRegular(t *testing.T) { + in := household + in.Context.Regulars = append(slices.Clone(household.Context.Regulars), + harness.RegularDinner{Name: "Easy Beef Chili", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30}) + other := sideQuest("Chili Verde", "Your Beef Chili gone green, with pork and tomatillos.", "Easy Beef Chili") + easy := sideQuest("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili") + plain := sideQuest("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili") + inside := sideQuest("Tamale Bake", "Your Easy Beef Chili under a cornbread crust.", "Beef Chili") + var f llm.Fake + f.QueueGenerate(reply(batch(other, easy, plain, inside))) + out, _, err := suggest(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{ + want("Frito Pie", "Your Easy Beef Chili over corn chips.", "Easy Beef Chili"), + want("Chili Mac", "The Beef Chili, not the Easy Beef Chili, over macaroni.", "Beef Chili"), + }, + Dropped: []DroppedSideQuest{ + {Name: "Chili Verde", Why: "reason names none of its cited Regulars"}, + {Name: "Tamale Bake", Why: "reason names none of its cited Regulars"}, + }, + }) +} + +func TestSuggestSideQuestsReasonMentionRule(t *testing.T) { + const none = "reason names none of its cited Regulars" + for name, tc := range map[string]struct { + extra []string // Regulars added to household's + reason string + cited string + why string // "" when the Side quest is kept + }{ + "exact name": {nil, "Like your Beef Chili, gone green.", "Beef Chili", ""}, + "other case and spacing": {nil, "Like your beef CHILI, gone green.", "Beef Chili", ""}, + "sentence punctuation": {nil, `Green, like "Beef Chili" (yours).`, "Beef Chili", ""}, + "possessive": {nil, "Beef Chili's spices, gone green.", "Beef Chili", ""}, + "joined by punctuation": {nil, "A Beef Chili-inspired stew.", "Beef Chili", ""}, + "no mention": {nil, "Grilled skewers with a peanut sauce.", "Beef Chili", none}, + "letters run on": {[]string{"Ham"}, "Like your Hamburgers, glazed.", "Ham", none}, + "stored punctuation written": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Easy Beef Chili!", ""}, + "stored punctuation as period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Easy Beef Chili!", ""}, + "inside stored punctuation": {[]string{"Easy Beef Chili!"}, "Your Easy Beef Chili! over corn chips.", "Beef Chili", none}, + "inside before period": {[]string{"Easy Beef Chili!"}, "Corn chips under your Easy Beef Chili.", "Beef Chili", none}, + "longer name cited": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Easy Beef Chili", ""}, + "shorter inside longer": {[]string{"Easy Beef Chili"}, "Your Easy Beef Chili over corn chips.", "Beef Chili", none}, + "longer not mentioned": {[]string{"Easy Beef Chili"}, "Your Beef Chili over corn chips.", "Easy Beef Chili", none}, + "shorter also on its own": {[]string{"Easy Beef Chili"}, "The Beef Chili, not the Easy Beef Chili.", "Beef Chili", ""}, + "punctuation-only difference": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili!", ""}, + "bare name inside punctuated": {[]string{"Beef Chili!"}, "Your Beef Chili! over corn chips.", "Beef Chili", none}, + "spelled alike by both": {[]string{"Beef Chili!"}, "Corn chips under your Beef Chili.", "Beef Chili", none}, + "punctuation then a letter": {[]string{"Beef Chili!"}, "Your Beef Chili!X is our inspiration.", "Beef Chili!", none}, + "letter before punctuation": {[]string{"¡Beef Chili"}, "Your X¡Beef Chili is our inspiration.", "¡Beef Chili", none}, + "spacing before punctuation": {[]string{"Beef Chili!"}, "Your Beef Chili ! over corn chips.", "Beef Chili!", ""}, + "spacing after punctuation": {[]string{"¡Beef Chili"}, "Your ¡ Beef Chili is our inspiration.", "¡Beef Chili", ""}, + "stored spacing left off": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Beef Chili !", ""}, + "inside unspaced punctuation": {[]string{"Beef Chili !", "Chili!"}, "Like your Beef Chili!", "Chili!", none}, + "stored punctuation partly": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Beef Chili!!", ""}, + "inside partial punctuation": {[]string{"Beef Chili!!", "Chili!"}, "Like your Beef Chili!", "Chili!", none}, + "partly then a letter": {[]string{"Beef Chili!!", "Chili!"}, "Your Beef Chili!!X is our inspiration.", "Chili!", none}, + "leading punctuation partly": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡¡Beef Chili", ""}, + "inside partial leading": {[]string{"¡¡Beef Chili", "¡Beef"}, "Like your ¡Beef Chili.", "¡Beef", none}, + "stored symbol left off": {[]string{"Green Chili 🌶"}, "Like your Green Chili, gone green.", "Green Chili 🌶", none}, + "stored accent left off": {[]string{"Chicken Cafe\u0301"}, "Like your Chicken Cafe, gone green.", "Chicken Cafe\u0301", none}, + "accent written after name": {[]string{"Chicken Cafe"}, "Like your Chicken Cafe\u0301, gone green.", "Chicken Cafe", none}, + } { + t.Run(name, func(t *testing.T) { + in := household + in.Context.Regulars = slices.Clone(household.Context.Regulars) + for _, r := range tc.extra { + in.Context.Regulars = append(in.Context.Regulars, harness.RegularDinner{Name: r, Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30}) + } + var f llm.Fake + f.QueueGenerate(reply(batch(sideQuest("Frito Pie", tc.reason, tc.cited)))) + out, _, err := suggest(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + result := SideQuests{Suggestions: []SideQuest{want("Frito Pie", oneLine(tc.reason), tc.cited)}} + if tc.why != "" { + result = SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "Frito Pie", Why: tc.why}}} + } + assertSideQuests(t, out, result) + }) + } +} + +func TestSuggestSideQuestsDuplicatesDropped(t *testing.T) { + declined := sideQuest("The Best Homemade Chicken Tikka-Masala", "Spiced like your Chicken Fajitas.", "Chicken Fajitas") + oldDeclined := sideQuest("Fish Tacos!", "Tortillas like your Chicken Fajitas.", "Chicken Fajitas") + regular := sideQuest("Easy Beef Chili", "Just like your Beef Chili.", "Beef Chili") + questLog := sideQuest("Pad Thai", "Noodles like your Spaghetti Bolognese.", "Spaghetti Bolognese") + pending := sideQuest("Shrimp Scampi", "Pasta like your Spaghetti Bolognese.", "Spaghetti Bolognese") + again := sideQuest("Chicken Shawarma Bowls", "Chicken Fajitas, but Middle Eastern.", "Chicken Fajitas") + var f llm.Fake + f.QueueGenerate(reply(batch(declined, oldDeclined, regular, questLog, pending, shawarma, again))) + in := household + in.Needed = 4 + out, _, err := suggest(t, &f, in, 1) + if err != nil { + t.Fatal(err) + } + known := "repeats a Regular, Quest log dinner, pending or passed Side quest" + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{wantShawarma}, + Dropped: []DroppedSideQuest{ + {Name: "The Best Homemade Chicken Tikka-Masala", Why: known}, + {Name: "Fish Tacos!", Why: known}, + {Name: "Easy Beef Chili", Why: known}, + {Name: "Pad Thai", Why: known}, + {Name: "Shrimp Scampi", Why: known}, + {Name: "Chicken Shawarma Bowls", Why: `repeats the Side quest "Chicken Shawarma Bowls" in the same batch`}, + }, + }) +} + +func TestSuggestSideQuestsReasonRulesDropped(t *testing.T) { + long := sideQuest("Beef Stroganoff", "Your Beef Chili"+strings.Repeat(" and more", 20)+".", "Beef Chili") + unnamed := sideQuest("Chicken Satay", "Grilled skewers with a peanut sauce.", "Chicken Fajitas") + wrongOne := sideQuest("Beef Tacos", "Like your Beef Chili, in a shell.", "Chicken Fajitas") + var f llm.Fake + f.QueueGenerate(reply(batch(long, unnamed, ragu, wrongOne))) + out, _, err := suggest(t, &f, household, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{wantRagu}, + Dropped: []DroppedSideQuest{ + {Name: "Beef Stroganoff", Why: "reason has 196 characters, over 180"}, + {Name: "Chicken Satay", Why: "reason names none of its cited Regulars"}, + {Name: "Beef Tacos", Why: "reason names none of its cited Regulars"}, + }, + }) +} + +func TestSuggestSideQuestsAllDroppedIsNotRepaired(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(batch(sideQuest("Pad Thai", "Like your Beef Chili.", "Beef Chili")))) + out, _, err := suggest(t, &f, household, 1) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{ + Suggestions: []SideQuest{}, + Dropped: []DroppedSideQuest{{Name: "Pad Thai", Why: "repeats a Regular, Quest log dinner, pending or passed Side quest"}}, + }) +} + +func TestSuggestSideQuestsMalformedRepairedOnce(t *testing.T) { + for name, tc := range map[string]struct{ first, want string }{ + "truncated JSON": { + `{"suggestions":[` + shawarma + `,{"name":"Beef`, + "reply does not decode: unexpected EOF", + }, + "omitted field": { + batch(strings.Replace(shawarma, `"cuisine":"Fusion",`, ``, 1)), + `suggestion has no "cuisine" field; give every field the schema requires`, + }, + "unknown field": { + batch(strings.Replace(shawarma, `"easy",`, `"easy","spice":"mild",`, 1)), + `suggestion has unknown field "spice"`, + }, + "bad method": { + batch(strings.Replace(shawarma, `"stovetop"`, `"air fryer"`, 1)), + `suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`, + }, + } { + t.Run(name, func(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(tc.first), reply(batch(shawarma, ragu))) + out, reqs, err := suggest(t, &f, household, 2) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}}) + repair := reqs[1].Messages + if len(repair) != 3 || repair[1].Content != tc.first { + t.Fatalf("repair turn does not quote the first reply: %+v", repair) + } + if !strings.Contains(repair[2].Content, tc.want) { + t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, tc.want) + } + }) + } +} + +func TestSuggestSideQuestsFillerNameRepaired(t *testing.T) { + filler := sideQuest("The.", "Like your Beef Chili.", "Beef Chili") + first := batch(filler, filler) + var f llm.Fake + f.QueueGenerate(reply(first), reply(batch(shawarma, ragu))) + out, reqs, err := suggest(t, &f, household, 2) + if err != nil { + t.Fatal(err) + } + assertSideQuests(t, out, SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu}}) + repair := reqs[1].Messages + if len(repair) != 3 || repair[1].Content != first { + t.Fatalf("repair turn does not quote the first reply: %+v", repair) + } + for _, want := range []string{ + `suggestion 1 name "The." has only filler words; name the dish`, + `suggestion 2 name "The." has only filler words; name the dish`, + } { + if !strings.Contains(repair[2].Content, want) { + t.Fatalf("repair message = %q, want it to contain %q", repair[2].Content, want) + } + } +} + +func TestSuggestSideQuestsMalformedTwiceFails(t *testing.T) { + var f llm.Fake + f.QueueGenerate(reply(`{"suggestions":`), reply(`{"suggestions":[]}`)) + out, _, err := suggest(t, &f, household, 2) + var bad *harness.BadOutputError + if !errors.As(err, &bad) || !errors.Is(err, llm.ErrBadOutput) { + t.Fatalf("err = %v, want a BadOutputError", err) + } + if !slices.Equal(bad.Repair, []string{"suggestions is empty; give 4 Side quests"}) { + t.Fatalf("repair problems = %q", bad.Repair) + } + if out.Suggestions != nil { + t.Fatalf("output = %+v, want none", out) + } +} + +func TestSideQuestsValidate(t *testing.T) { + s := suggestSideQuests{in: household} + if p := s.Validate(SideQuests{Suggestions: []SideQuest{wantShawarma}}); len(p) != 0 { + t.Fatalf("valid output has problems: %q", p) + } + if p := s.Validate(SideQuests{Suggestions: []SideQuest{}, Dropped: []DroppedSideQuest{{Name: "x", Why: "y"}}}); len(p) != 0 { + t.Fatalf("output whose Side quests were all dropped has problems: %q", p) + } + for name, tc := range map[string]struct { + out SideQuests + want []string + }{ + "empty": {SideQuests{}, []string{"suggestions is empty; give 4 Side quests"}}, + "too many": { + SideQuests{Suggestions: []SideQuest{wantShawarma, wantRagu, wantVerde, wantBulgogi, wantShawarma}}, + []string{"suggestions has 5 entries; give at most 4 Side quests"}, + }, + "malformed": { + SideQuests{Suggestions: []SideQuest{{Name: " ", Method: "air fryer", EstMinutes: 0, Difficulty: "trivial", CitedRegulars: []string{}}}}, + []string{ + "suggestion 1 has an empty name; give one", + "suggestion 1 has an empty cuisine; give one", + "suggestion 1 has an empty reason; give one", + "suggestion 1 has an empty search_query; give one", + `suggestion 1 method "air fryer" is not one of stovetop, oven, crockpot, grill, other`, + "suggestion 1 est_minutes 0 is not positive; give the minutes from prep to table", + `suggestion 1 difficulty "trivial" is not one of easy, medium, hard`, + }, + }, + } { + t.Run(name, func(t *testing.T) { + if got := s.Validate(tc.out); !slices.Equal(got, tc.want) { + t.Fatalf("problems =\n%s\nwant\n%s", strings.Join(got, "\n"), strings.Join(tc.want, "\n")) + } + }) + } +} + +func TestSideQuestsPromptInputRejects(t *testing.T) { + noRegulars := household + noRegulars.Context.Regulars = nil + none := household + none.Needed = 0 + for name, tc := range map[string]struct { + in SideQuestsRequest + want string + }{ + "no Regulars": {noRegulars, "household has no Regulars for Side quests to be based on"}, + "none needed": {none, "0 Side quests needed; the number must be positive"}, + } { + t.Run(name, func(t *testing.T) { + _, err := suggestSideQuests{in: tc.in}.PromptInput(tc.in) + if err == nil || err.Error() != tc.want { + t.Fatalf("err = %v, want %q", err, tc.want) + } + }) + } +} diff --git a/internal/ai/skills/testdata/suggest_side_quests_clean.system.golden b/internal/ai/skills/testdata/suggest_side_quests_clean.system.golden new file mode 100644 index 0000000..c7861ee --- /dev/null +++ b/internal/ai/skills/testdata/suggest_side_quests_clean.system.golden @@ -0,0 +1,110 @@ +## Instructions +You suggest Side quests: new dinners for the household to try, based on the dinners it already makes. + +Every Side quest must be a dinner the household has not had: never one of its Regulars, its Quest log, its pending Side quests or the dinners it has passed, and never one of those under another name. Base every Side quest on one or more of the household's Regulars, such as a familiar protein in a new cuisine or familiar flavours cooked a new way. Prefer the favorite Regulars, and vary the cuisines across the batch. + +Give for each Side quest: +- name: the dinner's usual name, as a recipe site would title it. +- cuisine: its cuisine, e.g. "Thai" or "Tex-Mex". +- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other". +- est_minutes: your estimate of the whole minutes from starting prep to the table. +- difficulty: "easy", "medium" or "hard" for a home cook. +- reason: one sentence of at most 180 characters saying why the household would like it. It must name at least one of the Regulars in cited_regulars. +- cited_regulars: the Regulars the reason relies on, at least one, each written exactly as its name appears in the Regulars list of the household context, without the details in parentheses. Cite only Regulars from that list. +- search_query: a web search query that finds a recipe for this dinner. + +One valid Side quest, for a household with a Regular named "Chicken Fajitas" (an example only, not about this household): +{"name": "Chicken Shawarma Bowls", "cuisine": "Middle Eastern", "method": "oven", "est_minutes": 40, "difficulty": "easy", "reason": "Spiced roast chicken and crisp toppings, like your Chicken Fajitas with a Middle Eastern twist.", "cited_regulars": ["Chicken Fajitas"], "search_query": "chicken shawarma bowls recipe"} + +## Household context +### Today +- Tuesday 2026-10-06 (America/New_York) + +### Regulars +- Beef Chili (Tex-Mex, crockpot, 240 min) +- Chicken Fajitas (Tex-Mex, stovetop, 30 min, favorite) +- Spaghetti Bolognese (Italian, stovetop, 45 min) + +### Quest log +- Pad Thai + +### Passed, oldest first +- Chicken Tikka Masala + +### Recent plans +- 2026-10-05 Mon: Chicken Fajitas +- 2026-10-06 Tue: Beef Chili + +### Pending Side quests +- Shrimp Scampi + +## Output contract +This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "suggest_side_quests" below, and nothing else: no prose and no Markdown code fences. + +{ + "type": "object", + "properties": { + "suggestions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cuisine": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "stovetop", + "oven", + "crockpot", + "grill", + "other" + ] + }, + "est_minutes": { + "type": "integer" + }, + "difficulty": { + "type": "string", + "enum": [ + "easy", + "medium", + "hard" + ] + }, + "reason": { + "type": "string" + }, + "cited_regulars": { + "type": "array", + "items": { + "type": "string" + } + }, + "search_query": { + "type": "string" + } + }, + "required": [ + "name", + "cuisine", + "method", + "est_minutes", + "difficulty", + "reason", + "cited_regulars", + "search_query" + ], + "additionalProperties": false + } + } + }, + "required": [ + "suggestions" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/internal/ai/skills/testdata/suggest_side_quests_clean.user.golden b/internal/ai/skills/testdata/suggest_side_quests_clean.user.golden new file mode 100644 index 0000000..0b1aa4f --- /dev/null +++ b/internal/ai/skills/testdata/suggest_side_quests_clean.user.golden @@ -0,0 +1 @@ +Suggest 4 Side quests. \ No newline at end of file diff --git a/internal/db/dinners.sql.go b/internal/db/dinners.sql.go index 2530b37..986c0aa 100644 --- a/internal/db/dinners.sql.go +++ b/internal/db/dinners.sql.go @@ -503,6 +503,63 @@ func (q *Queries) ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDi return items, nil } +const listUnplannedQuestLogDinners = `-- name: ListUnplannedQuestLogDinners :many +SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners +WHERE status = 'quest_log' + AND NOT EXISTS ( + SELECT 1 FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id + WHERE plan_days.dinner_id = dinners.id + AND week_plans.week_start >= ?1 + ) +ORDER BY normalized_name, id +` + +// The Quest log: dinners with status quest_log that no plan for the week +// starting from_week_start or later has on any day. +func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) { + rows, err := q.db.QueryContext(ctx, listUnplannedQuestLogDinners, fromWeekStart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Dinner + for rows.Next() { + var i Dinner + if err := rows.Scan( + &i.ID, + &i.Name, + &i.NormalizedName, + &i.Status, + &i.Origin, + &i.Method, + &i.MinutesTotal, + &i.Difficulty, + &i.Cuisine, + &i.IsFavorite, + &i.RecipeID, + &i.PhotoID, + &i.GroundingStatus, + &i.UpdatedBy, + &i.CreatedAt, + &i.UpdatedAt, + &i.RecipeUrl, + &i.GroundingError, + &i.GroundingRevision, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const setDinnerFavorite = `-- name: SetDinnerFavorite :one UPDATE dinners SET is_favorite = ?, diff --git a/internal/db/household_context.sql.go b/internal/db/household_context.sql.go index d924b27..e26740a 100644 --- a/internal/db/household_context.sql.go +++ b/internal/db/household_context.sql.go @@ -54,16 +54,16 @@ func (q *Queries) ListPlannedDinners(ctx context.Context, arg ListPlannedDinners const listRecentDeclinedNames = `-- name: ListRecentDeclinedNames :many -SELECT dinners.name +SELECT name FROM suggestions -JOIN dinners ON dinners.id = suggestions.dinner_id -WHERE suggestions.status = 'declined' -ORDER BY suggestions.decided_at DESC, suggestions.id DESC +WHERE status = 'declined' +ORDER BY decided_at DESC, id DESC LIMIT ? ` // Reads for the AI household context block (internal/ai/harness). -// The names of the most recently declined suggestions, newest first. +// The names the most recently declined suggestions were suggested under, +// newest first; renaming their dinners does not change them. func (q *Queries) ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) { rows, err := q.db.QueryContext(ctx, listRecentDeclinedNames, limit) if err != nil { diff --git a/internal/db/models.go b/internal/db/models.go index b026169..a75716a 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -289,6 +289,7 @@ type Suggestion struct { ID string DinnerID string BatchID string + Name string Reason string CitedRegularIds string Status string diff --git a/internal/db/querier.go b/internal/db/querier.go index 36966f8..da18d8e 100644 --- a/internal/db/querier.go +++ b/internal/db/querier.go @@ -21,6 +21,8 @@ type Querier interface { // Drops references to the runs created before the cutoff, so the purge // can delete them. ClearWeekPlanAIRunsBefore(ctx context.Context, cutoff string) (int64, error) + // How many suggestions ListDeck returns. + CountDeck(ctx context.Context) (int64, error) // How many Regulars the household has; archived dinners are not counted. CountRegularDinners(ctx context.Context) (int64, error) CreateAIRun(ctx context.Context, arg CreateAIRunParams) error @@ -32,6 +34,13 @@ type Querier interface { CreatePhoto(ctx context.Context, arg CreatePhotoParams) (Photo, error) CreateRecipeIngredient(ctx context.Context, arg CreateRecipeIngredientParams) error CreateRecipeStep(ctx context.Context, arg CreateRecipeStepParams) error + // name is the name the dish is suggested under, kept whatever its dinner is + // later renamed. + CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error + CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) + // Records the household's decision on a pending suggestion; one already + // decided is left as it is (0 rows). + DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) DeleteAIRunsBefore(ctx context.Context, cutoff string) (int64, error) DeleteRecipeIngredients(ctx context.Context, recipeID string) error DeleteRecipeSteps(ctx context.Context, recipeID string) error @@ -42,6 +51,10 @@ type Querier interface { // archived dinner is left as it is; archiving stopped its grounding. Run at // boot, after the job runner fails the jobs a previous process left running. FailOrphanedPendingDinnerGroundings(ctx context.Context, arg FailOrphanedPendingDinnerGroundingsParams) (int64, error) + // Fails every batch still pending or running whose job is no longer queued + // or running: the process stopped, or recording the batch's end failed, + // before its handler recorded a result. Run before reading the active batch. + FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) // Records why the background grounding of an unarchived dinner still // pending at the grounding_revision it was grounded for failed; a dinner // grounded some other way, renamed or archived meanwhile is left as it is @@ -49,8 +62,14 @@ type Querier interface { FailPendingDinnerGrounding(ctx context.Context, arg FailPendingDinnerGroundingParams) (int64, error) // Fails every job left running, at boot before any worker starts. FailRunningJobs(ctx context.Context, arg FailRunningJobsParams) (int64, error) + // Records a pending or running batch as done or failed; a batch already + // finished is left as it is (0 rows). + FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) // Returns sql.ErrNoRows until the row exists. GetAISettings(ctx context.Context) (AiSetting, error) + // The batch still pending or running, oldest first. Returns sql.ErrNoRows + // when there is none. + GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) // Returns sql.ErrNoRows until the row exists. GetChatSettings(ctx context.Context) (ChatSetting, error) GetDinner(ctx context.Context, id string) (Dinner, error) @@ -59,6 +78,9 @@ type Querier interface { // Returns sql.ErrNoRows until the row exists. GetIntegrationsGoogle(ctx context.Context) (IntegrationsGoogle, error) GetJob(ctx context.Context, id string) (Job, error) + // The newest batch, whatever its status. Returns sql.ErrNoRows when there + // is none. + GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) GetPhoto(ctx context.Context, id string) (Photo, error) // Finds stored image bytes by content hash. The schema does not make sha256 // unique, so the earliest-created (lowest id) match is returned. @@ -67,6 +89,10 @@ type Querier interface { GetRecipeBySourceURL(ctx context.Context, sourceUrl string) (Recipe, error) // Returns sql.ErrNoRows until the row exists. GetRecipeSearchSettings(ctx context.Context) (RecipeSearchSetting, error) + // A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. + GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) + // The id of the job that runs batch batch_id. + GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) // Records a dinner's grounding by hand (grounded or manual) and clears any // grounding_error and pasted recipe_url: the recipe chosen replaces the // link. It writes only an unarchived dinner still as it was observed @@ -83,22 +109,39 @@ type Querier interface { // them only when a member edited them while it was grounding. Like // GroundDinner it clears the pasted recipe_url: the recipe replaces the link. GroundPendingDinner(ctx context.Context, arg GroundPendingDinnerParams) (int64, error) + // The pending suggestions whose dinner is still suggested, oldest first, + // with their dinners. + ListDeck(ctx context.Context) ([]ListDeckRow, error) + // The name every declined suggestion was suggested under, oldest decision + // first; renaming its dinner does not change it. + ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) ListDinnersByStatus(ctx context.Context, status string) ([]Dinner, error) // The planned dinners dated from from_date up to but not including // to_date, oldest first. Days with no dinner are left out. ListPlannedDinners(ctx context.Context, arg ListPlannedDinnersParams) ([]ListPlannedDinnersRow, error) // Reads for the AI household context block (internal/ai/harness). - // The names of the most recently declined suggestions, newest first. + // The names the most recently declined suggestions were suggested under, + // newest first; renaming their dinners does not change them. ListRecentDeclinedNames(ctx context.Context, limit int64) ([]string, error) ListRecipeIngredients(ctx context.Context, recipeID string) ([]RecipeIngredient, error) ListRecipeSteps(ctx context.Context, recipeID string) ([]RecipeStep, error) // Every dinner a new or renamed dinner must not repeat, with the // source_url of the recipe it is grounded in (NULL while it has none). ListUnarchivedDinners(ctx context.Context) ([]ListUnarchivedDinnersRow, error) + // The Quest log: dinners with status quest_log that no plan for the week + // starting from_week_start or later has on any day. + ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStart string) ([]Dinner, error) + // Moves a dinner still suggested to status (quest_log or archived), + // recording updated_by; a dinner no longer suggested is left as it is + // (0 rows). + MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) // Stores a partial result of a running job. PublishJobResult(ctx context.Context, arg PublishJobResultParams) (int64, error) // An unknown id returns sql.ErrNoRows. SetDinnerFavorite(ctx context.Context, arg SetDinnerFavoriteParams) (Dinner, error) + // Marks a pending batch running; a batch in any other status is left as + // it is (0 rows). + StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) SucceedJob(ctx context.Context, arg SucceedJobParams) (int64, error) // An unknown id returns sql.ErrNoRows. ToggleDinnerFavorite(ctx context.Context, arg ToggleDinnerFavoriteParams) (Dinner, error) diff --git a/internal/db/suggestions.sql.go b/internal/db/suggestions.sql.go new file mode 100644 index 0000000..3829c5a --- /dev/null +++ b/internal/db/suggestions.sql.go @@ -0,0 +1,431 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: suggestions.sql + +package db + +import ( + "context" + "database/sql" +) + +const countDeck = `-- name: CountDeck :one +SELECT COUNT(*) +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'pending' AND dinners.status = 'suggested' +` + +// How many suggestions ListDeck returns. +func (q *Queries) CountDeck(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countDeck) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createSuggestion = `-- name: CreateSuggestion :exec +INSERT INTO suggestions ( + id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, 'pending', ?, ? +) +` + +type CreateSuggestionParams struct { + ID string + DinnerID string + BatchID string + Name string + Reason string + CitedRegularIds string + CreatedAt string + UpdatedAt string +} + +// name is the name the dish is suggested under, kept whatever its dinner is +// later renamed. +func (q *Queries) CreateSuggestion(ctx context.Context, arg CreateSuggestionParams) error { + _, err := q.db.ExecContext(ctx, createSuggestion, + arg.ID, + arg.DinnerID, + arg.BatchID, + arg.Name, + arg.Reason, + arg.CitedRegularIds, + arg.CreatedAt, + arg.UpdatedAt, + ) + return err +} + +const createSuggestionBatch = `-- name: CreateSuggestionBatch :one +INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at) +VALUES (?, ?, 'pending', ?, ?) +RETURNING id, "trigger", status, ai_run_id, created_at, updated_at +` + +type CreateSuggestionBatchParams struct { + ID string + Trigger string + CreatedAt string + UpdatedAt string +} + +func (q *Queries) CreateSuggestionBatch(ctx context.Context, arg CreateSuggestionBatchParams) (SuggestionBatch, error) { + row := q.db.QueryRowContext(ctx, createSuggestionBatch, + arg.ID, + arg.Trigger, + arg.CreatedAt, + arg.UpdatedAt, + ) + var i SuggestionBatch + err := row.Scan( + &i.ID, + &i.Trigger, + &i.Status, + &i.AiRunID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const decideSuggestion = `-- name: DecideSuggestion :execrows +UPDATE suggestions +SET status = ?1, decided_at = ?2, updated_at = ?2 +WHERE id = ?3 AND status = 'pending' +` + +type DecideSuggestionParams struct { + Status string + Now sql.NullString + ID string +} + +// Records the household's decision on a pending suggestion; one already +// decided is left as it is (0 rows). +func (q *Queries) DecideSuggestion(ctx context.Context, arg DecideSuggestionParams) (int64, error) { + result, err := q.db.ExecContext(ctx, decideSuggestion, arg.Status, arg.Now, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const failOrphanedSuggestionBatches = `-- name: FailOrphanedSuggestionBatches :execrows +UPDATE suggestion_batches +SET status = 'failed', updated_at = ?1 +WHERE status IN ('pending', 'running') + AND NOT EXISTS ( + SELECT 1 FROM jobs + WHERE jobs.kind = ?2 + AND jobs.status IN ('queued', 'running') + AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id + ) +` + +type FailOrphanedSuggestionBatchesParams struct { + UpdatedAt string + Kind string +} + +// Fails every batch still pending or running whose job is no longer queued +// or running: the process stopped, or recording the batch's end failed, +// before its handler recorded a result. Run before reading the active batch. +func (q *Queries) FailOrphanedSuggestionBatches(ctx context.Context, arg FailOrphanedSuggestionBatchesParams) (int64, error) { + result, err := q.db.ExecContext(ctx, failOrphanedSuggestionBatches, arg.UpdatedAt, arg.Kind) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const finishSuggestionBatch = `-- name: FinishSuggestionBatch :execrows +UPDATE suggestion_batches +SET status = ?, updated_at = ? +WHERE id = ? AND status IN ('pending', 'running') +` + +type FinishSuggestionBatchParams struct { + Status string + UpdatedAt string + ID string +} + +// Records a pending or running batch as done or failed; a batch already +// finished is left as it is (0 rows). +func (q *Queries) FinishSuggestionBatch(ctx context.Context, arg FinishSuggestionBatchParams) (int64, error) { + result, err := q.db.ExecContext(ctx, finishSuggestionBatch, arg.Status, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const getActiveSuggestionBatch = `-- name: GetActiveSuggestionBatch :one +SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches +WHERE status IN ('pending', 'running') +ORDER BY id +LIMIT 1 +` + +// The batch still pending or running, oldest first. Returns sql.ErrNoRows +// when there is none. +func (q *Queries) GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error) { + row := q.db.QueryRowContext(ctx, getActiveSuggestionBatch) + var i SuggestionBatch + err := row.Scan( + &i.ID, + &i.Trigger, + &i.Status, + &i.AiRunID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getLatestSuggestionBatch = `-- name: GetLatestSuggestionBatch :one +SELECT id, "trigger", status, ai_run_id, created_at, updated_at FROM suggestion_batches +ORDER BY id DESC +LIMIT 1 +` + +// The newest batch, whatever its status. Returns sql.ErrNoRows when there +// is none. +func (q *Queries) GetLatestSuggestionBatch(ctx context.Context) (SuggestionBatch, error) { + row := q.db.QueryRowContext(ctx, getLatestSuggestionBatch) + var i SuggestionBatch + err := row.Scan( + &i.ID, + &i.Trigger, + &i.Status, + &i.AiRunID, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getSuggestion = `-- name: GetSuggestion :one +SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.id = ? +` + +type GetSuggestionRow struct { + Suggestion Suggestion + Dinner Dinner +} + +// A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. +func (q *Queries) GetSuggestion(ctx context.Context, id string) (GetSuggestionRow, error) { + row := q.db.QueryRowContext(ctx, getSuggestion, id) + var i GetSuggestionRow + err := row.Scan( + &i.Suggestion.ID, + &i.Suggestion.DinnerID, + &i.Suggestion.BatchID, + &i.Suggestion.Name, + &i.Suggestion.Reason, + &i.Suggestion.CitedRegularIds, + &i.Suggestion.Status, + &i.Suggestion.DecidedAt, + &i.Suggestion.CreatedAt, + &i.Suggestion.UpdatedAt, + &i.Dinner.ID, + &i.Dinner.Name, + &i.Dinner.NormalizedName, + &i.Dinner.Status, + &i.Dinner.Origin, + &i.Dinner.Method, + &i.Dinner.MinutesTotal, + &i.Dinner.Difficulty, + &i.Dinner.Cuisine, + &i.Dinner.IsFavorite, + &i.Dinner.RecipeID, + &i.Dinner.PhotoID, + &i.Dinner.GroundingStatus, + &i.Dinner.UpdatedBy, + &i.Dinner.CreatedAt, + &i.Dinner.UpdatedAt, + &i.Dinner.RecipeUrl, + &i.Dinner.GroundingError, + &i.Dinner.GroundingRevision, + ) + return i, err +} + +const getSuggestionBatchJobID = `-- name: GetSuggestionBatchJobID :one +SELECT id FROM jobs +WHERE kind = ?1 + AND json_extract(payload, '$.batch_id') = ?2 +ORDER BY id +LIMIT 1 +` + +type GetSuggestionBatchJobIDParams struct { + Kind string + BatchID string +} + +// The id of the job that runs batch batch_id. +func (q *Queries) GetSuggestionBatchJobID(ctx context.Context, arg GetSuggestionBatchJobIDParams) (string, error) { + row := q.db.QueryRowContext(ctx, getSuggestionBatchJobID, arg.Kind, arg.BatchID) + var id string + err := row.Scan(&id) + return id, err +} + +const listDeck = `-- name: ListDeck :many +SELECT suggestions.id, suggestions.dinner_id, suggestions.batch_id, suggestions.name, suggestions.reason, suggestions.cited_regular_ids, suggestions.status, suggestions.decided_at, suggestions.created_at, suggestions.updated_at, dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'pending' AND dinners.status = 'suggested' +ORDER BY suggestions.created_at, suggestions.id +` + +type ListDeckRow struct { + Suggestion Suggestion + Dinner Dinner +} + +// The pending suggestions whose dinner is still suggested, oldest first, +// with their dinners. +func (q *Queries) ListDeck(ctx context.Context) ([]ListDeckRow, error) { + rows, err := q.db.QueryContext(ctx, listDeck) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListDeckRow + for rows.Next() { + var i ListDeckRow + if err := rows.Scan( + &i.Suggestion.ID, + &i.Suggestion.DinnerID, + &i.Suggestion.BatchID, + &i.Suggestion.Name, + &i.Suggestion.Reason, + &i.Suggestion.CitedRegularIds, + &i.Suggestion.Status, + &i.Suggestion.DecidedAt, + &i.Suggestion.CreatedAt, + &i.Suggestion.UpdatedAt, + &i.Dinner.ID, + &i.Dinner.Name, + &i.Dinner.NormalizedName, + &i.Dinner.Status, + &i.Dinner.Origin, + &i.Dinner.Method, + &i.Dinner.MinutesTotal, + &i.Dinner.Difficulty, + &i.Dinner.Cuisine, + &i.Dinner.IsFavorite, + &i.Dinner.RecipeID, + &i.Dinner.PhotoID, + &i.Dinner.GroundingStatus, + &i.Dinner.UpdatedBy, + &i.Dinner.CreatedAt, + &i.Dinner.UpdatedAt, + &i.Dinner.RecipeUrl, + &i.Dinner.GroundingError, + &i.Dinner.GroundingRevision, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listDeclinedSuggestionNames = `-- name: ListDeclinedSuggestionNames :many +SELECT name +FROM suggestions +WHERE status = 'declined' +ORDER BY decided_at, id +` + +// The name every declined suggestion was suggested under, oldest decision +// first; renaming its dinner does not change it. +func (q *Queries) ListDeclinedSuggestionNames(ctx context.Context) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listDeclinedSuggestionNames) + if err != nil { + return nil, err + } + defer rows.Close() + var items []string + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return nil, err + } + items = append(items, name) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const moveSuggestedDinner = `-- name: MoveSuggestedDinner :execrows +UPDATE dinners +SET status = ?1, updated_by = ?2, updated_at = ?3 +WHERE id = ?4 AND status = 'suggested' +` + +type MoveSuggestedDinnerParams struct { + Status string + UpdatedBy sql.NullString + UpdatedAt string + ID string +} + +// Moves a dinner still suggested to status (quest_log or archived), +// recording updated_by; a dinner no longer suggested is left as it is +// (0 rows). +func (q *Queries) MoveSuggestedDinner(ctx context.Context, arg MoveSuggestedDinnerParams) (int64, error) { + result, err := q.db.ExecContext(ctx, moveSuggestedDinner, + arg.Status, + arg.UpdatedBy, + arg.UpdatedAt, + arg.ID, + ) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + +const startSuggestionBatch = `-- name: StartSuggestionBatch :execrows +UPDATE suggestion_batches +SET status = 'running', updated_at = ? +WHERE id = ? AND status = 'pending' +` + +type StartSuggestionBatchParams struct { + UpdatedAt string + ID string +} + +// Marks a pending batch running; a batch in any other status is left as +// it is (0 rows). +func (q *Queries) StartSuggestionBatch(ctx context.Context, arg StartSuggestionBatchParams) (int64, error) { + result, err := q.db.ExecContext(ctx, startSuggestionBatch, arg.UpdatedAt, arg.ID) + if err != nil { + return 0, err + } + return result.RowsAffected() +} diff --git a/internal/dinners/dinners.go b/internal/dinners/dinners.go index 0e0c096..54f355c 100644 --- a/internal/dinners/dinners.go +++ b/internal/dinners/dinners.go @@ -17,10 +17,12 @@ import ( "slices" "strings" "sync" + "time" "unicode/utf8" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" "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/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" @@ -287,12 +289,20 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str } // List returns the dinners with status, which must be regular or -// quest_log, ordered by normalized name. +// 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 +// household's current week or a later one is left out of it. func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) { if !slices.Contains(listable, status) { return nil, invalid("status", "must be one of "+strings.Join(listable, ", ")) } - rows, err := db.New(s.st.Reader).ListDinnersByStatus(ctx, status) + var rows []db.Dinner + var err error + if status == StatusQuestLog { + rows, err = s.listQuestLog(ctx) + } else { + rows, err = db.New(s.st.Reader).ListDinnersByStatus(ctx, status) + } if err != nil { return nil, fmt.Errorf("dinners: list %s: %w", status, err) } @@ -303,6 +313,29 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) { return out, nil } +// listQuestLog reads the Quest log in one read transaction, so the +// household's current week and the plans come from the same snapshot. +func (s *Service) listQuestLog(ctx context.Context) (rows []db.Dinner, err error) { + tx, err := s.st.Reader.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("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 nil, fmt.Errorf("read household settings: %w", err) + } + loc, err := household.LoadZone(settings.Timezone) + if err != nil { + return nil, fmt.Errorf("household settings: %w", err) + } + thisWeek := household.WeekStart(household.Today(time.Now(), loc)) + return q.ListUnplannedQuestLogDinners(ctx, thisWeek.String()) +} + // Update applies p to dinner id, recording updatedBy. An invalid patch is a // *ValidationError and a new name repeating another unarchived dinner a // *DuplicateError; either changes nothing. Renaming a dinner raises its diff --git a/internal/jobs/jobs.go b/internal/jobs/jobs.go index 337addc..12cb947 100644 --- a/internal/jobs/jobs.go +++ b/internal/jobs/jobs.go @@ -54,14 +54,30 @@ const ( CodeUnknownKind = "unknown_kind" ) -// Claim retry backoff. A worker whose claim fails with a database error -// retries after claimRetryMin, doubling up to claimRetryMax, so a job queued -// when the error clears is claimed without waiting for a wake token. +// Retry backoff. A worker whose claim or final job write fails with a +// database error retries after retryMin, doubling up to retryMax, so a job +// queued when the error clears is claimed without waiting for a wake token, +// and a job the worker has finished is never left running. const ( - claimRetryMin = 50 * time.Millisecond - claimRetryMax = 5 * time.Second + retryMin = 50 * time.Millisecond + retryMax = 5 * time.Second ) +// FinalWriteTimeout bounds each attempt at a job's final write, and at a +// handler's own last write, so a writer connection that stays busy cannot +// hold a stopping worker, and with it shutdown, open. +const FinalWriteTimeout = 5 * time.Second + +// FinalWriteContext returns the context for one attempt at a final write +// of a job: it is not cancelled with ctx, so a job that finishes during +// shutdown is still recorded, but it ends after FinalWriteTimeout. A +// handler makes its own last write, the one recording what the job did, +// with it too, and returns the error when the attempt times out, so its +// worker can stop; the next Start fails the job as interrupted. +func FinalWriteContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), FinalWriteTimeout) +} + // codePattern is the form of every failure code: lower-case snake case. var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) @@ -259,6 +275,21 @@ func (k *Kind[P]) Enqueue(ctx context.Context, payload P) (string, error) { return k.r.enqueue(ctx, k.name, data) } +// EnqueueTx stores a queued job with payload in tx and returns its id, so +// the job exists exactly when what else tx writes does. Once tx has +// committed, call Wake so an idle worker runs it. +func (k *Kind[P]) EnqueueTx(ctx context.Context, tx *sql.Tx, payload P) (string, error) { + data, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("jobs: enqueue %s: encode payload: %w", k.name, err) + } + return createJob(ctx, db.New(tx), k.name, data) +} + +// Wake tells an idle worker to look for a job, as Enqueue does itself: +// call it after committing the transaction a job was enqueued in. +func (k *Kind[P]) Wake() { k.r.wakeOne() } + // Enqueue stores a queued job of kind with its JSON payload and returns its // id, as Kind.Enqueue does for a kind known at compile time. It returns // ErrUnknownKind for a kind with no registered handler and @@ -280,9 +311,20 @@ func (r *Runner) Enqueue(ctx context.Context, kind string, payload json.RawMessa // enqueue stores a queued job of kind with its encoded payload and wakes a // worker. func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (string, error) { + id, err := createJob(ctx, db.New(r.st.Writer), kind, payload) + if err != nil { + return "", err + } + r.wakeOne() + return id, nil +} + +// createJob stores a queued job of kind with its encoded payload through q +// and returns its id. +func createJob(ctx context.Context, q *db.Queries, kind string, payload []byte) (string, error) { id := store.NewID() now := store.Now().String() - if err := db.New(r.st.Writer).CreateJob(ctx, db.CreateJobParams{ + if err := q.CreateJob(ctx, db.CreateJobParams{ ID: id, Kind: kind, Payload: string(payload), @@ -291,11 +333,15 @@ func (r *Runner) enqueue(ctx context.Context, kind string, payload []byte) (stri }); err != nil { return "", fmt.Errorf("jobs: enqueue %s: %w", kind, err) } + return id, nil +} + +// wakeOne gives an idle worker a token to look for a queued job. +func (r *Runner) wakeOne() { select { case r.wake <- struct{}{}: default: // every worker already has a token and will look again } - return id, nil } // Start fails every job a previous process left running with @@ -357,16 +403,16 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) { // already queued is not stranded by a transient error. func (r *Runner) work(ctx context.Context) { defer r.wg.Done() - backoff := claimRetryMin + backoff := retryMin for ctx.Err() == nil { job, err := r.claim(ctx, sql.NullString{String: store.Now().String(), Valid: true}) switch { case err == nil: - backoff = claimRetryMin + backoff = retryMin r.run(ctx, job) continue case errors.Is(err, sql.ErrNoRows): - backoff = claimRetryMin + backoff = retryMin select { case <-r.wake: case <-ctx.Done(): @@ -382,7 +428,7 @@ func (r *Runner) work(ctx context.Context) { case <-ctx.Done(): timer.Stop() } - backoff = min(2*backoff, claimRetryMax) + backoff = min(2*backoff, retryMax) } } @@ -390,14 +436,13 @@ func (r *Runner) work(ctx context.Context) { // ctx has been cancelled, so a handler that finishes during shutdown is // recorded. func (r *Runner) run(ctx context.Context, job db.Job) { - write := context.WithoutCancel(ctx) logger := r.logger.With("job_id", job.ID, "kind", job.Kind) q := db.New(r.st.Writer) h, ok := r.handlers[job.Kind] // read-only once started if !ok { logger.ErrorContext(ctx, "job failed", "code", CodeUnknownKind, "reason", "no handler registered for the kind") - r.fail(write, logger, job.ID, CodeUnknownKind) + r.fail(ctx, logger, job.ID, CodeUnknownKind) return } @@ -419,7 +464,7 @@ func (r *Runner) run(ctx context.Context, job db.Job) { result, code, reason := h.run(ctx, job.Payload, publish) if code != "" { if ctx.Err() != nil { - logger.WarnContext(write, "job interrupted by shutdown; it fails at the next start") + logger.WarnContext(context.WithoutCancel(ctx), "job interrupted by shutdown; it fails at the next start") return } level := slog.LevelInfo @@ -427,35 +472,61 @@ func (r *Runner) run(ctx context.Context, job db.Job) { level = slog.LevelError } logger.Log(ctx, level, "job failed", "code", code, "reason", reason) - r.fail(write, logger, job.ID, code) + r.fail(ctx, logger, job.ID, code) return } - n, err := q.SucceedJob(write, db.SucceedJobParams{ - Result: sql.NullString{String: string(result), Valid: true}, - Now: sql.NullString{String: store.Now().String(), Valid: true}, - ID: job.ID, + r.record(ctx, logger, "record job success", func(write context.Context) (int64, error) { + return q.SucceedJob(write, db.SucceedJobParams{ + Result: sql.NullString{String: string(result), Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: job.ID, + }) }) - if err == nil && n != 1 { - err = errors.New("job is not running") - } - if err != nil { - logger.ErrorContext(write, "record job success", "error", err) - } } // fail records the job as failed with code. The record errors it logs come // from the database, never from a handler. func (r *Runner) fail(ctx context.Context, logger *slog.Logger, id, code string) { - n, err := db.New(r.st.Writer).FailJob(ctx, db.FailJobParams{ - Error: sql.NullString{String: code, Valid: true}, - Now: sql.NullString{String: store.Now().String(), Valid: true}, - ID: id, + r.record(ctx, logger, "record job failure", func(write context.Context) (int64, error) { + return db.New(r.st.Writer).FailJob(write, db.FailJobParams{ + Error: sql.NullString{String: code, Valid: true}, + Now: sql.NullString{String: store.Now().String(), Valid: true}, + ID: id, + }) }) - if err == nil && n != 1 { - err = errors.New("job is not running") - } - if err != nil { - logger.ErrorContext(ctx, "record job failure", "error", err) +} + +// record makes a job's final write, which must change the one running job. +// A database error is retried with backoff until the write lands, so a +// worker never moves on from a job it has finished while leaving it +// running. Each attempt is made with FinalWriteContext, so even when ctx +// has been cancelled, and is bounded by FinalWriteTimeout; once ctx is +// cancelled, a failed or timed-out attempt is not retried and the job +// stays running, so the next Start fails it as interrupted. +func (r *Runner) record(ctx context.Context, logger *slog.Logger, what string, write func(context.Context) (int64, error)) { + backoff := retryMin + for { + attempt, cancel := FinalWriteContext(ctx) + n, err := write(attempt) + cancel() + if err == nil { + if n != 1 { + logger.ErrorContext(ctx, what, "error", "job is not running") + } + return + } + if ctx.Err() != nil { + logger.ErrorContext(context.WithoutCancel(ctx), what+" failed during shutdown; it fails at the next start", "error", err) + return + } + logger.ErrorContext(ctx, what+" failed; retrying", "error", err, "retry_in", backoff) + timer := time.NewTimer(backoff) + select { + case <-timer.C: + case <-ctx.Done(): + timer.Stop() + } + backoff = min(2*backoff, retryMax) } } diff --git a/internal/jobs/jobs_test.go b/internal/jobs/jobs_test.go index 1ab0822..ece6b13 100644 --- a/internal/jobs/jobs_test.go +++ b/internal/jobs/jobs_test.go @@ -187,6 +187,46 @@ func TestJobSucceeds(t *testing.T) { } } +func TestEnqueueTxStoresJobOnlyWithItsTransaction(t *testing.T) { + t.Parallel() + st := migratedStore(t) + r := newRunner(t, st, 1) + count := register(t, r, "count", func(_ context.Context, p countPayload, _ func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + start(t, r) + ctx := context.Background() + + var rolledBack string + refused := errors.New("refused") + if err := st.WithTx(ctx, func(tx *sql.Tx) error { + var err error + rolledBack, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 1}) + if err != nil { + return err + } + return refused + }); !errors.Is(err, refused) { + t.Fatalf("WithTx = %v, want the refusal", err) + } + if _, err := r.Get(ctx, rolledBack); !errors.Is(err, ErrNotFound) { + t.Errorf("job of a rolled-back transaction: Get = %v, want ErrNotFound", err) + } + + var id string + if err := st.WithTx(ctx, func(tx *sql.Tx) error { + var err error + id, err = count.EnqueueTx(ctx, tx, countPayload{Steps: 2}) + return err + }); err != nil { + t.Fatal(err) + } + count.Wake() + if job := waitFor(t, r, id, finished); job.Status != StatusSucceeded || string(job.Result) != `{"done":2}` { + t.Errorf("job = %+v (result %s), want succeeded with {\"done\":2}", job, job.Result) + } +} + func TestJobFailsWithFailureCode(t *testing.T) { t.Parallel() st := migratedStore(t) @@ -569,6 +609,98 @@ func TestClaimErrorIsRetried(t *testing.T) { } } +func TestFinalWriteErrorIsRetried(t *testing.T) { + st := migratedStore(t) + logs := &logBuffer{} + r := newRunnerLogging(t, st, 1, logs) + k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + return countResult{Done: p.Steps}, nil + }) + if _, err := st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'end refused'); END`); err != nil { + t.Fatal(err) + } + start(t, r) + id := enqueue(t, k, countPayload{Steps: 3}) + + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(logs.String(), "record job success") { + if time.Now().After(deadline) { + t.Fatal("job success never recorded") + } + time.Sleep(5 * time.Millisecond) + } + if _, err := st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_end`); err != nil { + t.Fatal(err) + } + + job := waitFor(t, r, id, finished) + if job.Status != StatusSucceeded || string(job.Result) != `{"done":3}` { + t.Fatalf("job = %+v (result %s), want succeeded with {\"done\":3}", job, job.Result) + } + if out := logs.String(); !strings.Contains(out, "record job success failed; retrying") { + t.Fatalf("final write error not logged as retried: %s", out) + } +} + +func TestBlockedFinalWriteDoesNotHoldShutdown(t *testing.T) { + st := migratedStore(t) + r := newRunner(t, st, 1) + started, finish := make(chan struct{}), make(chan struct{}) + k := register(t, r, "count", func(ctx context.Context, p countPayload, publish func(countResult) error) (countResult, error) { + close(started) + <-finish + return countResult{Done: p.Steps}, nil + }) + stop := start(t, r) + id := enqueue(t, k, countPayload{Steps: 3}) + <-started + + // Hold the only writer connection, so the job's final write waits for it. + conn, err := st.Writer.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + waits := st.Writer.Stats().WaitCount + close(finish) + deadline := time.Now().Add(5 * time.Second) + for st.Writer.Stats().WaitCount == waits { + if time.Now().After(deadline) { + t.Fatal("final write never waited for the writer") + } + time.Sleep(5 * time.Millisecond) + } + + stopped := make(chan struct{}) + go func() { + stop() + close(stopped) + }() + // The final write's attempt ends after 5 seconds; well past that, the + // worker is waiting for the writer with no bound. + select { + case <-stopped: + case <-time.After(15 * time.Second): + conn.Close() + <-stopped + t.Fatal("worker waited for a held writer through shutdown") + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + + if job := waitFor(t, r, id, func(Job) bool { return true }); job.Status != StatusRunning { + t.Fatalf("after shutdown job = %+v, want left running", job) + } + second := newRunner(t, st, 1) + register(t, second, "count", func(context.Context, countPayload, func(countResult) error) (countResult, error) { + return countResult{}, nil + }) + start(t, second) + if job := waitFor(t, second, id, finished); job.Status != StatusFailed || job.Error == nil || *job.Error != CodeInterrupted { + t.Errorf("interrupted job = %+v, want failed with %q", job, CodeInterrupted) + } +} + func TestRunnerEnqueueByKindName(t *testing.T) { t.Parallel() st := migratedStore(t) diff --git a/internal/server/aisettings_test.go b/internal/server/aisettings_test.go index 4c58c43..bc4dd39 100644 --- a/internal/server/aisettings_test.go +++ b/internal/server/aisettings_test.go @@ -28,7 +28,7 @@ const ( func newAIRouter(t *testing.T) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/dinners_test.go b/internal/server/dinners_test.go index 0a8edbd..e0c71a7 100644 --- a/internal/server/dinners_test.go +++ b/internal/server/dinners_test.go @@ -11,9 +11,11 @@ import ( "sync" "testing" "testing/fstest" + "time" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "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/jobs" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" @@ -66,6 +68,10 @@ func (b *logBuffer) String() string { func newDinnersEnv(t *testing.T) *dinnersEnv { t.Helper() st := newTestStore(t) + // The Quest log is read against the household's current week. + if err := household.SeedSettings(context.Background(), st, "America/New_York"); err != nil { + t.Fatal(err) + } e := &dinnersEnv{ st: st, search: &recipestest.Search{Results: map[string][]recipes.Candidate{}}, @@ -86,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv { t.Fatal(err) } e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), - svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + svc, newTestSuggest(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } @@ -416,6 +422,61 @@ func TestListDinnersFiltersByStatus(t *testing.T) { } } +// The Quest log leaves out a dinner on the plan for the household's current +// week or a later one: it is scheduled already. A dinner on an earlier +// week's plan only, or on none, is listed. +func TestQuestLogLeavesOutPlannedDinners(t *testing.T) { + t.Parallel() + router, st := newDinnersRouter(t) + ctx := context.Background() + names := []string{"Bibimbap", "Gumbo", "Pho", "Ramen", "Tagine"} + ids := map[string]string{} + for _, name := range names { + id := createDinner(t, router, `{"name":"`+name+`"}`) + if _, err := st.Writer.ExecContext(ctx, `UPDATE dinners SET status = 'quest_log', origin = 'side_quest' WHERE id = ?`, id); err != nil { + t.Fatal(err) + } + ids[name] = id + } + + loc, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatal(err) + } + thisWeek := household.WeekStart(household.Today(time.Now(), loc)) + // day is the date days after the Monday weeks after this week's. + day := func(weeks, days int) string { + y, m, d := thisWeek.Date() + return time.Date(y, m, d+7*weeks+days, 0, 0, 0, 0, time.UTC).Format(time.DateOnly) + } + // Gumbo is on last week's plan only; Pho on this week's; Ramen on the + // week after next; Tagine on last week's and this week's. + plan := func(weeks int, dinners ...string) { + t.Helper() + weekStart := day(weeks, 0) + planID := "plan-" + weekStart + if _, err := st.Writer.ExecContext(ctx, + `INSERT INTO week_plans (id, week_start, status, created_at, updated_at) VALUES (?, ?, 'draft', 't', 't')`, + planID, weekStart); err != nil { + t.Fatal(err) + } + for i, name := range dinners { + if _, err := st.Writer.ExecContext(ctx, + `INSERT INTO plan_days (id, plan_id, date, dinner_id, is_new, created_at, updated_at) VALUES (?, ?, ?, ?, 1, 't', 't')`, + planID+"-"+name, planID, day(weeks, i), ids[name]); err != nil { + t.Fatal(err) + } + } + } + plan(-1, "Gumbo", "Tagine") + plan(0, "Pho", "Tagine") + plan(2, "Ramen") + + if got := strings.Join(listNames(t, router, "quest_log"), ","); got != "Bibimbap,Gumbo" { + t.Errorf("quest_log = %s, want Bibimbap,Gumbo", got) + } +} + func TestPatchDinner(t *testing.T) { t.Parallel() router, st := newDinnersRouter(t) diff --git a/internal/server/login_test.go b/internal/server/login_test.go index b801274..24f6a37 100644 --- a/internal/server/login_test.go +++ b/internal/server/login_test.go @@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler { t.Fatal(err) } r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/media_test.go b/internal/server/media_test.go index e1aed7b..c2ce862 100644 --- a/internal/server/media_test.go +++ b/internal/server/media_test.go @@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) { t.Fatal(err) } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), + r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/origin_test.go b/internal/server/origin_test.go index ef97b38..7242e74 100644 --- a/internal/server/origin_test.go +++ b/internal/server/origin_test.go @@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler { } logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil))) r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/recipesearch_test.go b/internal/server/recipesearch_test.go index ecc0d69..5e20f89 100644 --- a/internal/server/recipesearch_test.go +++ b/internal/server/recipesearch_test.go @@ -22,7 +22,7 @@ const ( func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestRunner(t), + r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) diff --git a/internal/server/server.go b/internal/server/server.go index 8eaf64c..c81b05e 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "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/suggest" ) // reservedPrefixes are owned by the API router and are never answered with the @@ -38,12 +39,14 @@ type RouteMounter interface { // dinnerSvc serves GET and POST /api/v1/dinners, PATCH and DELETE // /api/v1/dinners/{id}, POST /api/v1/dinners/{id}/favorite, GET // /api/v1/dinners/{id}/recipe-candidates and POST /api/v1/dinners/{id}/ground; -// runner serves +// suggestSvc serves POST /api/v1/suggestions/batches, GET +// /api/v1/suggestions/deck and POST /api/v1/suggestions/{id}/accept and +// /decline; runner serves // POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves // GET /media/photos/{id}-{size}.jpg; assets is the web build // served for every other GET. /health, /auth and the web build are public. // Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader. -func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { +func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) { if settings == nil { return nil, errors.New("server: nil household settings service") } @@ -56,6 +59,9 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a if dinnerSvc == nil { return nil, errors.New("server: nil dinners service") } + if suggestSvc == nil { + return nil, errors.New("server: nil suggestions service") + } if runner == nil { return nil, errors.New("server: nil job runner") } @@ -106,6 +112,10 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a apiV1.Post("/dinners/{id}/favorite", postDinnerFavorite(logger, dinnerSvc)) apiV1.Get("/dinners/{id}/recipe-candidates", getDinnerCandidates(logger, dinnerSvc)) apiV1.Post("/dinners/{id}/ground", postDinnerGround(logger, dinnerSvc)) + apiV1.Post("/suggestions/batches", postSuggestionBatch(logger, suggestSvc)) + apiV1.Get("/suggestions/deck", getSuggestionDeck(logger, suggestSvc)) + apiV1.Post("/suggestions/{id}/accept", postSuggestionDecision(logger, suggestSvc, true)) + apiV1.Post("/suggestions/{id}/decline", postSuggestionDecision(logger, suggestSvc, false)) apiV1.Post("/jobs", postJob(logger, runner)) apiV1.Get("/jobs/{id}", getJob(logger, runner)) media.Get("/photos/{name}", getPhoto(logger, photos)) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 5f121ce..05a3807 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/base64" "encoding/json" + "errors" "io" "log/slog" "maps" @@ -18,6 +19,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/aisettings" "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/dinners" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs" @@ -25,6 +27,7 @@ import ( "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/secretbox" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" ) @@ -46,7 +49,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting "index.html": {Data: []byte(fixtureIndex)}, "assets/index-AbC123.js": {Data: []byte(fixtureAsset)}, } - r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), runner, newTestPhotos(t), assets) + r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), runner, newTestPhotos(t), assets) if err != nil { t.Fatal(err) } @@ -160,6 +163,28 @@ func newTestDinners(t *testing.T) *dinners.Service { return svc } +// newTestSuggest returns a suggestions service over a migrated temp +// database, with its own unstarted job runner, a grounder that finds +// nothing and a suggester that always fails. +func newTestSuggest(t *testing.T) *suggest.Service { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + svc, err := suggest.NewService(st, g, fail, runner, logger) + if err != nil { + t.Fatal(err) + } + return svc +} + // newTestBox returns a secret box under a random key. func newTestBox(t *testing.T) *secretbox.Box { t.Helper() @@ -191,7 +216,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil AI settings service") } } @@ -200,7 +225,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil recipe search settings service") } } @@ -209,7 +234,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted nil photo files") } } @@ -218,7 +243,7 @@ func TestNewRouterRequiresSettings(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil settings service") } } @@ -227,16 +252,25 @@ func TestNewRouterRequiresDinners(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil dinners service") } } +func TestNewRouterRequiresSuggestions(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + t.Fatal("NewRouter accepted a nil suggestions service") + } +} + func TestNewRouterRequiresJobRunner(t *testing.T) { t.Parallel() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) - if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { + if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil { t.Fatal("NewRouter accepted a nil job runner") } } diff --git a/internal/server/settings_test.go b/internal/server/settings_test.go index 9570fbc..6586253 100644 --- a/internal/server/settings_test.go +++ b/internal/server/settings_test.go @@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) { } logger := slog.New(slog.NewTextHandler(io.Discard, nil)) router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st), - newTestSearch(t), newTestAI(t), newTestDinners(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) if err != nil { t.Fatal(err) } diff --git a/internal/server/suggestions.go b/internal/server/suggestions.go new file mode 100644 index 0000000..f6e7711 --- /dev/null +++ b/internal/server/suggestions.go @@ -0,0 +1,78 @@ +package server + +import ( + "errors" + "log/slog" + "net/http" + + "github.com/go-chi/chi/v5" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +// postSuggestionBatch starts a manual suggestion batch and answers 202 with +// its batch_id and the job_id that GET /jobs/{id} reports on, without +// waiting for the job. When a batch is already pending or running it +// answers 202 with that batch instead of starting another. +func postSuggestionBatch(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + b, err := svc.StartBatch(r.Context(), suggest.TriggerManual) + if err != nil { + writeSuggestionError(w, r, logger, "start suggestion batch", err) + return + } + writeJSON(w, r, logger, http.StatusAccepted, b) + } +} + +// getSuggestionDeck answers GET /suggestions/deck with the pending +// suggestions, oldest first, and the batch pending or running, or null. +func getSuggestionDeck(logger *slog.Logger, svc *suggest.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + d, err := svc.Deck(r.Context()) + if err != nil { + writeSuggestionError(w, r, logger, "list suggestion deck", err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// postSuggestionDecision records Try it (accept) or Pass (decline) on a +// suggestion, recording the session's actor as its dinner's updated_by. +func postSuggestionDecision(logger *slog.Logger, svc *suggest.Service, accept bool) http.HandlerFunc { + decide, action := svc.Decline, "decline suggestion" + if accept { + decide, action = svc.Accept, "accept suggestion" + } + return func(w http.ResponseWriter, r *http.Request) { + actor, ok := actorOrFail(w, r, logger) + if !ok { + return + } + d, err := decide(r.Context(), chi.URLParam(r, "id"), actor.Subject) + if err != nil { + writeSuggestionError(w, r, logger, action, err) + return + } + writeJSON(w, r, logger, http.StatusOK, d) + } +} + +// writeSuggestionError maps a suggest.Service error to its API error: an +// unknown suggestion is 404, and a decision on one already decided the +// other way, or whose dinner left the deck, is 409. +func writeSuggestionError(w http.ResponseWriter, r *http.Request, logger *slog.Logger, action string, err error) { + var decided *suggest.DecidedError + switch { + case errors.As(err, &decided): + writeError(w, r, logger, http.StatusConflict, "suggestion_decided", decided.Error()) + case errors.Is(err, suggest.ErrDinnerNotSuggested): + writeError(w, r, logger, http.StatusConflict, "dinner_not_suggested", err.Error()) + case errors.Is(err, suggest.ErrNotFound): + writeError(w, r, logger, http.StatusNotFound, "not_found", "suggestion not found") + default: + logger.ErrorContext(r.Context(), action, "error", err) + writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error") + } +} diff --git a/internal/server/suggestions_test.go b/internal/server/suggestions_test.go new file mode 100644 index 0000000..e50448b --- /dev/null +++ b/internal/server/suggestions_test.go @@ -0,0 +1,228 @@ +package server + +import ( + "context" + "database/sql" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + + "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/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" +) + +const suggestionsPath = "/api/v1/suggestions" + +// newSuggestionsRouter returns a router whose suggestions service has an +// unstarted job runner, so batches stay pending, and a deck seeded with +// pending suggestions of dinners named names, in order, whose ids it +// returns. +func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.Store, []string) { + t.Helper() + st := newTestStore(t) + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + runner, err := jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{}) + fail := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("no model in this test") + } + svc, err := suggest.NewService(st, g, fail, runner, logger) + if err != nil { + t.Fatal(err) + } + h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), + newTestDinners(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}}) + if err != nil { + t.Fatal(err) + } + + q := db.New(st.Writer) + ctx := t.Context() + now := store.Now().String() + batch, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + if _, err := q.FinishSuggestionBatch(ctx, db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: batch.ID}); err != nil { + t.Fatal(err) + } + var ids []string + for i, name := range names { + d, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "suggested", Origin: "side_quest", + Difficulty: sql.NullString{String: "easy", Valid: true}, GroundingStatus: "grounded", CreatedAt: now, UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + id := store.NewID() + if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{ + ID: id, DinnerID: d.ID, BatchID: batch.ID, Name: name, Reason: fmt.Sprintf("Reason %d", i), CitedRegularIds: "[]", + CreatedAt: store.Now().String(), UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + return h, st, ids +} + +func TestSuggestionDeck(t *testing.T) { + t.Parallel() + h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen") + + rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body) + } + list, _ := body["suggestions"].([]any) + if len(list) != 2 { + t.Fatalf("deck = %v, want 2 suggestions", body) + } + for i, name := range []string{"Pho", "Ramen"} { + s := list[i].(map[string]any) + if s["id"] != ids[i] || s["name"] != name || s["difficulty"] != "easy" || s["reason"] != fmt.Sprintf("Reason %d", i) || s["status"] != "pending" { + t.Errorf("deck[%d] = %v, want %s", i, s, name) + } + if _, ok := s["photo_url"]; !ok { + t.Errorf("deck[%d] has no photo_url key", i) + } + } +} + +func TestSuggestionDecisions(t *testing.T) { + t.Parallel() + h, _, ids := newSuggestionsRouter(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu", "Udon") + + for _, tc := range []struct { + target string + status int + code string + sugStatus string + dinnerStatus string + }{ + {ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"}, + {ids[0] + "/accept", http.StatusOK, "", "accepted", "quest_log"}, + {ids[0] + "/decline", http.StatusConflict, "suggestion_decided", "", ""}, + {ids[1] + "/decline", http.StatusOK, "", "declined", "archived"}, + {ids[1] + "/decline", http.StatusOK, "", "declined", "archived"}, + {ids[1] + "/accept", http.StatusConflict, "suggestion_decided", "", ""}, + {"nope/accept", http.StatusNotFound, "not_found", "", ""}, + {"nope/decline", http.StatusNotFound, "not_found", "", ""}, + } { + rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+tc.target, "") + if rec.Code != tc.status { + t.Fatalf("POST %s = %d, want %d: %s", tc.target, rec.Code, tc.status, rec.Body) + } + if tc.code != "" { + if errorCode(body) != tc.code { + t.Errorf("POST %s error = %v, want %s", tc.target, body, tc.code) + } + continue + } + s, _ := body["suggestion"].(map[string]any) + if s["status"] != tc.sugStatus || s["dinner_status"] != tc.dinnerStatus { + t.Errorf("POST %s = %v, want %s with dinner %s", tc.target, body, tc.sugStatus, tc.dinnerStatus) + } + if body["replenish_job_id"] != nil { + t.Errorf("POST %s replenished with 5 left: %v", tc.target, body["replenish_job_id"]) + } + } + + // A third decision leaves 4: a replenish batch starts. + rec, body := callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[2]+"/decline", "") + if rec.Code != http.StatusOK { + t.Fatalf("decline = %d: %s", rec.Code, rec.Body) + } + replenish, _ := body["replenish_job_id"].(string) + if replenish == "" { + t.Fatalf("decline leaving 4 = %v, want a replenish_job_id", body) + } + + // A fourth decision joins the batch already pending and reports its job. + rec, body = callDinners(t, h, http.MethodPost, suggestionsPath+"/"+ids[3]+"/accept", "") + if rec.Code != http.StatusOK { + t.Fatalf("accept = %d: %s", rec.Code, rec.Body) + } + if body["replenish_job_id"] != replenish { + t.Errorf("accept leaving 3 = %v, want replenish_job_id %s", body, replenish) + } +} + +func TestSuggestionDeckActiveBatch(t *testing.T) { + t.Parallel() + h, _, _ := newSuggestionsRouter(t, "Pho") + + rec, body := callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body) + } + if active, ok := body["active_batch"]; !ok || active != nil { + t.Fatalf("deck = %v, want active_batch null", body) + } + if failed, ok := body["failed_batch"]; !ok || failed != nil { + t.Fatalf("deck = %v, want failed_batch null", body) + } + + rec, started := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "") + if rec.Code != http.StatusAccepted { + t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body) + } + rec, body = callDinners(t, h, http.MethodGet, suggestionsPath+"/deck", "") + if rec.Code != http.StatusOK { + t.Fatalf("GET deck = %d: %s", rec.Code, rec.Body) + } + active, _ := body["active_batch"].(map[string]any) + if active == nil || active["batch_id"] != started["batch_id"] || active["job_id"] != started["job_id"] || active["status"] != "pending" { + t.Errorf("deck active_batch = %v, want the started batch %v", body["active_batch"], started) + } +} + +func TestPostSuggestionBatch(t *testing.T) { + t.Parallel() + h, st, _ := newSuggestionsRouter(t) + + rec, first := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "") + if rec.Code != http.StatusAccepted { + t.Fatalf("POST batches = %d: %s", rec.Code, rec.Body) + } + if first["batch_id"] == nil || first["job_id"] == nil || first["trigger"] != "manual" || first["status"] != "pending" { + t.Errorf("POST batches = %v, want a pending manual batch with its job", first) + } + var kind string + if err := st.Reader.QueryRow(`SELECT kind FROM jobs WHERE id = ?`, first["job_id"]).Scan(&kind); err != nil || kind != suggest.KindSuggestionBatch { + t.Errorf("job kind = %q (%v), want %s", kind, err, suggest.KindSuggestionBatch) + } + + rec, again := callDinners(t, h, http.MethodPost, suggestionsPath+"/batches", "") + if rec.Code != http.StatusAccepted || again["batch_id"] != first["batch_id"] || again["job_id"] != first["job_id"] { + t.Errorf("second POST batches = %d %v, want the pending batch %v", rec.Code, again, first) + } +} + +func TestSuggestionWritesNeedCSRF(t *testing.T) { + t.Parallel() + h, _, ids := newSuggestionsRouter(t, "Pho") + for _, target := range []string{"/batches", "/" + ids[0] + "/accept", "/" + ids[0] + "/decline"} { + req := httptest.NewRequest(http.MethodPost, suggestionsPath+target, nil) + req.AddCookie(validSessionCookie) + rec, body := serve(t, h, req) + if rec.Code != http.StatusForbidden || errorCode(body) != "csrf" { + t.Errorf("POST %s without CSRF = %d %v, want 403 csrf", target, rec.Code, body) + } + } +} diff --git a/internal/store/migrate_test.go b/internal/store/migrate_test.go index 94b8ec3..4952f3b 100644 --- a/internal/store/migrate_test.go +++ b/internal/store/migrate_test.go @@ -3,6 +3,7 @@ package store import ( "context" "io/fs" + "reflect" "slices" "strings" "testing" @@ -175,8 +176,8 @@ func TestMigrateEmbeddedMigrations(t *testing.T) { if err := Migrate(context.Background(), s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got) } } @@ -196,8 +197,8 @@ func TestMigrateBootstrapThenEmbeddedInitApplies(t *testing.T) { if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { t.Fatal(err) } - if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision" { - t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision", got) + if got := versions(migrationRows(t, s)); got != "0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name" { + t.Errorf("versions = %q, want 0000_bootstrap,0001_init,0002_household_settings_updated_by,0003_jobs_status,0004_ai_provider_presets,0005_ai_provider_xai,0006_ai_runs_status,0007_recipe_grounding,0008_dinner_recipe_url,0009_dinner_grounding_error,0010_dinner_grounding_revision,0011_ground_dinner_job_revision,0012_suggestion_name", got) } if !tableExists(t, s, "dinners") { t.Error("table from 0001_init was not created") @@ -557,3 +558,78 @@ func TestMigrateAIRunsStatus(t *testing.T) { }) } } + +// TestMigrateSuggestionName upgrades a database at 0011 holding a pending +// and a declined suggestion and checks 0012 keeps both, gives each its +// dinner's name, and refuses a suggestion with no name. +func TestMigrateSuggestionName(t *testing.T) { + ctx := context.Background() + s := openTestStore(t) + before := fstest.MapFS{} + for _, name := range []string{ + "0000_bootstrap.sql", "0001_init.sql", "0002_household_settings_updated_by.sql", "0003_jobs_status.sql", + "0004_ai_provider_presets.sql", "0005_ai_provider_xai.sql", "0006_ai_runs_status.sql", "0007_recipe_grounding.sql", + "0008_dinner_recipe_url.sql", "0009_dinner_grounding_error.sql", "0010_dinner_grounding_revision.sql", + "0011_ground_dinner_job_revision.sql", + } { + body, err := fs.ReadFile(migrations.FS, name) + if err != nil { + t.Fatal(err) + } + before[name] = &fstest.MapFile{Data: body} + } + if err := Migrate(ctx, s.Writer, before); err != nil { + t.Fatal(err) + } + for _, stmt := range []string{ + `INSERT INTO dinners (id, name, normalized_name, status, origin, is_favorite, grounding_status, created_at, updated_at) + VALUES ('dinner-1', 'Pho', 'pho', 'suggested', 'side_quest', 0, 'grounded', 'c', 'u'), + ('dinner-2', 'Ramen', 'ramen', 'archived', 'side_quest', 0, 'grounded', 'c', 'u')`, + `INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at) + VALUES ('batch-1', 'manual', 'done', 'c', 'u')`, + `INSERT INTO suggestions (id, dinner_id, batch_id, reason, cited_regular_ids, status, decided_at, created_at, updated_at) + VALUES ('sugg-1', 'dinner-1', 'batch-1', 'why 1', '[]', 'pending', NULL, 'c1', 'u1'), + ('sugg-2', 'dinner-2', 'batch-1', 'why 2', '["dinner-1"]', 'declined', 'd2', 'c2', 'u2')`, + } { + if _, err := s.Writer.Exec(stmt); err != nil { + t.Fatalf("seed: %v", err) + } + } + + if err := Migrate(ctx, s.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + + rows, err := s.Reader.Query(`SELECT id, dinner_id, batch_id, name, reason, cited_regular_ids, status, COALESCE(decided_at, ''), created_at, updated_at + FROM suggestions ORDER BY id`) + if err != nil { + t.Fatal(err) + } + var got [][]string + for rows.Next() { + r := make([]string, 10) + if err := rows.Scan(&r[0], &r[1], &r[2], &r[3], &r[4], &r[5], &r[6], &r[7], &r[8], &r[9]); err != nil { + t.Fatal(err) + } + got = append(got, r) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + want := [][]string{ + {"sugg-1", "dinner-1", "batch-1", "Pho", "why 1", "[]", "pending", "", "c1", "u1"}, + {"sugg-2", "dinner-2", "batch-1", "Ramen", "why 2", `["dinner-1"]`, "declined", "d2", "c2", "u2"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("suggestions after 0012 =\n %v\nwant\n %v", got, want) + } + if _, err := s.Writer.Exec(`UPDATE suggestions SET name = '' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "CHECK constraint failed") { + t.Errorf("empty suggestion name: error %v, want CHECK constraint failure", err) + } + if _, err := s.Writer.Exec(`UPDATE suggestions SET dinner_id = 'nope' WHERE id = 'sugg-1'`); err == nil || !strings.Contains(err.Error(), "FOREIGN KEY constraint failed") { + t.Errorf("unknown dinner: error %v, want FOREIGN KEY constraint failure", err) + } + if tableExists(t, s, "suggestions_copy") { + t.Error("suggestions_copy left behind") + } +} diff --git a/internal/store/repository_test.go b/internal/store/repository_test.go index 8ae01b2..58101e8 100644 --- a/internal/store/repository_test.go +++ b/internal/store/repository_test.go @@ -95,7 +95,7 @@ func TestRepositoryInsertReadUpdate(t *testing.T) { parents: []string{"dinners", "suggestion_batches"}, row: repoRow{"suggestions", map[string]any{ "id": "sugg-1", "dinner_id": "dinner-1", "batch_id": "batch-1", - "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`, + "name": "White Chicken Chili", "reason": "You make chili often.", "cited_regular_ids": `["dinner-1"]`, "status": "pending", "decided_at": nil, "created_at": repoTime, "updated_at": repoTime, }}, diff --git a/internal/store/schema_test.go b/internal/store/schema_test.go index eb735b8..510a752 100644 --- a/internal/store/schema_test.go +++ b/internal/store/schema_test.go @@ -110,6 +110,7 @@ func testRow(table string, i int) map[string]any { case "suggestions": r["dinner_id"] = rowID("dinners", 0) r["batch_id"] = rowID("suggestion_batches", 0) + r["name"] = fmt.Sprintf("Side quest %d", i) r["reason"] = "Like your chili." r["cited_regular_ids"] = "[]" r["status"] = "pending" diff --git a/internal/suggest/batch.go b/internal/suggest/batch.go new file mode 100644 index 0000000..00aeccb --- /dev/null +++ b/internal/suggest/batch.go @@ -0,0 +1,309 @@ +package suggest + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "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/db" + "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" +) + +// BatchJob is the KindSuggestionBatch payload: the batch to run. +type BatchJob struct { + BatchID string `json:"batch_id"` +} + +// Batch stages, as the job's result reports them. +const ( + StageSuggesting = "suggesting" + StageGrounding = "grounding" + StageDone = "done" +) + +// batchResult is the KindSuggestionBatch result, published as the batch +// goes: its stage, the suggestions stored so far, and how many Side +// quests were dropped, by suggest_side_quests' rules, because no recipe +// grounds them, or because they repeat a dinner added meanwhile. +type batchResult struct { + BatchID string `json:"batch_id"` + Stage string `json:"stage"` + SuggestionIDs []string `json:"suggestion_ids"` + Dropped int `json:"dropped"` +} + +func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) } + +// runBatch runs batch p.BatchID: it asks suggest_side_quests for BatchSize +// Side quests given the household context, the deck and every declined +// name, then grounds them in the model's order, at most the number still +// wanted at a time, until BatchSize are kept or none are left. A Side quest +// no recipe grounds is dropped; any other grounding failure fails the +// batch, as does suggest_side_quests failing, with no Side quest made up +// instead. Each round's grounded Side quests are stored before the next +// round starts, so the deck fills as the batch goes. The batch ends done +// or failed; Side quests stored before a failure stay in the deck. When +// recording the batch's end fails, so does the job; when it times out at +// shutdown, the job is left running and the batch with it, for the next +// start to fail. +func (s *Service) runBatch(ctx context.Context, p BatchJob, publish func(batchResult) error) (batchResult, error) { + n, err := db.New(s.st.Writer).StartSuggestionBatch(ctx, db.StartSuggestionBatchParams{ + UpdatedAt: store.Now().String(), + ID: p.BatchID, + }) + if err != nil { + return batchResult{}, fmt.Errorf("suggest: start batch %s: %w", p.BatchID, err) + } + if n == 0 { + return batchResult{}, fmt.Errorf("suggest: batch %s is not pending", p.BatchID) + } + res, err := s.fill(ctx, p.BatchID, publish) + if err == nil { + if err := s.finishBatch(ctx, p.BatchID, BatchDone); err != nil { + return batchResult{}, err + } + return res, nil + } + finishErr := s.finishBatch(ctx, p.BatchID, BatchFailed) + if ctx.Err() != nil { + return batchResult{}, errors.Join(ctx.Err(), finishErr) + } + 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, "suggestion batch failed", "batch_id", p.BatchID, "reason", failure.Code) + } + return batchResult{}, errors.Join(err, finishErr) +} + +// fill does runBatch's work, returning a *jobs.Failure for a failure the +// household may see. +func (s *Service) fill(ctx context.Context, batchID string, publish func(batchResult) error) (batchResult, error) { + res := batchResult{BatchID: batchID, Stage: StageSuggesting, SuggestionIDs: []string{}} + if err := publish(res); err != nil { + return batchResult{}, err + } + in, err := s.request(ctx) + if err != nil { + return batchResult{}, err + } + out, err := s.suggest(ctx, batchID, in) + if err != nil { + if ctx.Err() != nil { + return batchResult{}, ctx.Err() + } + return batchResult{}, &jobs.Failure{Code: ReasonAIFailed} + } + res.Stage = StageGrounding + res.Dropped = len(out.Dropped) + if err := publish(res); err != nil { + return batchResult{}, err + } + left := out.Suggestions + for len(res.SuggestionIDs) < BatchSize && len(left) > 0 { + round := left[:min(BatchSize-len(res.SuggestionIDs), len(left))] + left = left[len(round):] + grounded, err := s.ground(ctx, round) + if err != nil { + return batchResult{}, err + } + res.Dropped += len(round) - len(grounded) + ids, err := s.store(ctx, batchID, grounded) + if err != nil { + return batchResult{}, err + } + res.Dropped += len(grounded) - len(ids) + res.SuggestionIDs = append(res.SuggestionIDs, ids...) + if err := publish(res); err != nil { + return batchResult{}, err + } + } + res.Stage = StageDone + return res, nil +} + +// request reads what suggest_side_quests is given: the household context, +// the deck's names and every declined name. A household with no Regulars +// fails with ReasonNoRegulars. +func (s *Service) request(ctx context.Context) (skills.SideQuestsRequest, error) { + hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget) + if err != nil { + return skills.SideQuestsRequest{}, fmt.Errorf("suggest: %w", err) + } + if len(hctx.Regulars) == 0 { + return skills.SideQuestsRequest{}, &jobs.Failure{Code: ReasonNoRegulars} + } + q := db.New(s.st.Reader) + deck, err := q.ListDeck(ctx) + if err != nil { + return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list deck: %w", err) + } + pending := []string{} + for _, row := range deck { + pending = append(pending, row.Dinner.Name) + } + declined, err := q.ListDeclinedSuggestionNames(ctx) + if err != nil { + return skills.SideQuestsRequest{}, fmt.Errorf("suggest: list declined names: %w", err) + } + return skills.SideQuestsRequest{Context: hctx, Needed: BatchSize, Pending: pending, Declined: declined}, nil +} + +// groundedQuest is a Side quest with the recipe that grounds it. +type groundedQuest struct { + quest skills.SideQuest + grounded recipes.Grounded +} + +// ground grounds quests by their search queries and returns those a +// recipe grounds, in order. A Side quest no recipe grounds is left out; +// any other failure fails the batch. +func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]groundedQuest, error) { + reqs := make([]recipes.GroundRequest, len(quests)) + for i, q := range quests { + reqs[i] = recipes.GroundRequest{DishName: q.Name, Query: q.SearchQuery} + } + results := s.grounder.GroundMany(ctx, reqs) + if err := ctx.Err(); err != nil { + return nil, err + } + var out []groundedQuest + for i, r := range results { + var ungroundable *recipes.ErrUngroundable + switch { + case r.Err == nil: + out = append(out, groundedQuest{quest: quests[i], grounded: r.Grounded}) + case errors.As(r.Err, &ungroundable): + case errors.Is(r.Err, recipes.ErrSearchNotConfigured): + return nil, &jobs.Failure{Code: ReasonSearchNotConfigured} + case errors.Is(r.Err, recipes.ErrSearchFailed): + return nil, &jobs.Failure{Code: ReasonSearchFailed} + case errors.Is(r.Err, recipes.ErrNormalizeFailed): + return nil, &jobs.Failure{Code: ReasonAIFailed} + default: + return nil, fmt.Errorf("suggest: ground %q: %w", quests[i].Name, r.Err) + } + } + return out, nil +} + +// store stores each grounded Side quest of batchID as a dinner with status +// suggested and origin side_quest, grounded in its recipe, and a pending +// suggestion citing its Regulars by id, all in one transaction, and +// returns the suggestion ids. A Side quest is left out when, since +// suggest_side_quests ran, a dinner it repeats was added, a suggestion it +// repeats passed, or a Regular it cites renamed or archived. Declined +// names are read in the same transaction as the store, so a Pass committed +// before it is never missed. +func (s *Service) store(ctx context.Context, batchID string, quests []groundedQuest) ([]string, error) { + ids := []string{} + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + existing, err := q.ListUnarchivedDinners(ctx) + if err != nil { + return fmt.Errorf("suggest: list dinners for dedupe: %w", err) + } + names, err := q.ListDeclinedSuggestionNames(ctx) + if err != nil { + return fmt.Errorf("suggest: list declined names for dedupe: %w", err) + } + regulars := map[string][]string{} + for _, row := range existing { + names = append(names, row.Dinner.Name) + if row.Dinner.Status == dinners.StatusRegular { + regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID) + } + } + for _, gq := range quests { + cited, ok := citedIDs(gq.quest.CitedRegulars, regulars) + if !ok || repeats(gq.quest.Name, names) { + s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID) + continue + } + citedJSON, err := json.Marshal(cited) + if err != nil { + return fmt.Errorf("suggest: encode cited Regulars: %w", err) + } + now := store.Now().String() + gr := gq.grounded + d, err := q.CreateDinner(ctx, db.CreateDinnerParams{ + ID: store.NewID(), + Name: gq.quest.Name, + NormalizedName: harness.Normalize(gq.quest.Name), + Status: dinners.StatusSuggested, + Origin: "side_quest", + Method: sql.NullString{String: gr.Method, Valid: true}, + MinutesTotal: nullInt(gr.MinutesTotal), + Difficulty: sql.NullString{String: gr.Difficulty, Valid: true}, + Cuisine: sql.NullString{String: gq.quest.Cuisine, Valid: true}, + RecipeID: sql.NullString{String: gr.RecipeID, Valid: true}, + PhotoID: sql.NullString{String: gr.PhotoID, Valid: true}, + GroundingStatus: dinners.GroundingGrounded, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return fmt.Errorf("suggest: create dinner: %w", err) + } + id := store.NewID() + if err := q.CreateSuggestion(ctx, db.CreateSuggestionParams{ + ID: id, + DinnerID: d.ID, + BatchID: batchID, + Name: d.Name, + Reason: gq.quest.Reason, + CitedRegularIds: string(citedJSON), + CreatedAt: now, + UpdatedAt: now, + }); err != nil { + return fmt.Errorf("suggest: create suggestion: %w", err) + } + names = append(names, d.Name) + ids = append(ids, id) + } + return nil + }) + if err != nil { + return nil, err + } + return ids, nil +} + +// citedIDs returns the ids of the Regulars named cited, false when one +// names no Regular or more than one. +func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) { + ids := make([]string, len(cited)) + for i, name := range cited { + if len(regulars[name]) != 1 { + return nil, false + } + ids[i] = regulars[name][0] + } + return ids, true +} + +// repeats reports whether name is harness.Similar to any of names. +func repeats(name string, names []string) bool { + for _, n := range names { + if harness.Similar(name, n) { + return true + } + } + return false +} + +func nullInt(v *int) sql.NullInt64 { + if v == nil { + return sql.NullInt64{} + } + return sql.NullInt64{Int64: int64(*v), Valid: true} +} diff --git a/internal/suggest/suggest.go b/internal/suggest/suggest.go new file mode 100644 index 0000000..287ad59 --- /dev/null +++ b/internal/suggest/suggest.go @@ -0,0 +1,482 @@ +// Package suggest keeps the household's Side quest deck: batches of new +// dinners the AI suggests, each grounded in a recipe, and the household's +// Try it / Pass decision on each. A batch runs as a job: suggest_side_quests +// proposes Side quests, each is grounded with the recipe grounding pipeline +// (dropping those no recipe grounds), and every one kept is stored as a +// dinner with status suggested and origin side_quest and a pending +// suggestion. Only one batch is pending or running at a time. +package suggest + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "slices" + + "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/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" +) + +// KindSuggestionBatch is the job that runs a suggestion batch. +const KindSuggestionBatch = "suggestion_batch" + +const ( + // BatchSize is how many Side quests a batch keeps at most. + BatchSize = 5 + // ReplenishBelow is the deck size under which a decision starts a + // replenish batch. + ReplenishBelow = 5 + // ContextBudget is the character budget of the household context + // suggest_side_quests is given (see harness.BuildContext). + ContextBudget = 16000 +) + +// Batch triggers (the suggestion_batches.trigger column). +const ( + TriggerScheduled = "scheduled" + TriggerManual = "manual" + TriggerReplenish = "replenish" +) + +// Batch statuses (the suggestion_batches.status column). +const ( + BatchPending = "pending" + BatchRunning = "running" + BatchDone = "done" + BatchFailed = "failed" +) + +// Suggestion statuses (the suggestions.status column). +const ( + StatusPending = "pending" + StatusAccepted = "accepted" + StatusDeclined = "declined" +) + +// Reasons a batch failed, stored as the KindSuggestionBatch job's failure +// code. Any other failure, such as the database's, is the runner's +// internal_error. +const ( + // ReasonNoRegulars: the household has no Regulars for Side quests to + // be based on. + ReasonNoRegulars = "no_regulars" + // ReasonAIFailed: suggest_side_quests, or normalize_recipe while + // grounding, failed; no Side quest 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" +) + +// Reasons are every batch failure reason. +var Reasons = []string{ReasonNoRegulars, ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed} + +var triggers = []string{TriggerScheduled, TriggerManual, TriggerReplenish} + +// ErrNotFound is returned for an unknown suggestion id. +var ErrNotFound = errors.New("suggestion not found") + +// ErrDinnerNotSuggested is a decision on a pending suggestion whose dinner +// was archived or moved out of the deck some other way. +var ErrDinnerNotSuggested = errors.New("the suggestion's dinner is no longer suggested") + +// DecidedError is a decision on a suggestion already decided the other +// way. +type DecidedError struct { + // Status is the suggestion's decision: accepted or declined. + Status string +} + +func (e *DecidedError) Error() string { + return "the suggestion was already " + e.Status +} + +// Suggester runs suggest_side_quests on in; scope names the batch. In +// production it is skills.SuggestSideQuests with the household's +// harness.Config. +type Suggester func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) + +// Service runs suggestion batches and records decisions. +type Service struct { + st *store.Store + grounder *recipes.Grounder + suggest Suggester + logger *slog.Logger + batchJob *jobs.Kind[BatchJob] +} + +// NewService returns a service over st that suggests with suggest, grounds +// with grounder and registers the KindSuggestionBatch job on runner, which +// must not have started yet. +func NewService(st *store.Store, grounder *recipes.Grounder, suggest Suggester, runner *jobs.Runner, logger *slog.Logger) (*Service, error) { + switch { + case st == nil: + return nil, errors.New("suggest: nil store") + case grounder == nil: + return nil, errors.New("suggest: nil grounder") + case suggest == nil: + return nil, errors.New("suggest: nil suggester") + case runner == nil: + return nil, errors.New("suggest: nil job runner") + case logger == nil: + return nil, errors.New("suggest: nil logger") + } + s := &Service{st: st, grounder: grounder, suggest: suggest, logger: logger} + kind, err := jobs.Register(runner, KindSuggestionBatch, s.runBatch, Reasons...) + if err != nil { + return nil, err + } + s.batchJob = kind + return s, nil +} + +// Batch is a suggestion batch with the id of the job running it. +type Batch struct { + ID string `json:"batch_id"` + JobID string `json:"job_id"` + Trigger string `json:"trigger"` + Status string `json:"status"` + // Created says whether this call created the batch rather than found + // it already pending or running. + Created bool `json:"-"` +} + +// StartBatch creates a batch with trigger and enqueues the +// KindSuggestionBatch job that runs it, unless a batch is already pending +// or running, which it returns instead with Created false. +func (s *Service) StartBatch(ctx context.Context, trigger string) (Batch, error) { + if !slices.Contains(triggers, trigger) { + return Batch{}, fmt.Errorf("suggest: unknown batch trigger %q", trigger) + } + var b Batch + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + var err error + b, err = s.startBatch(ctx, tx, trigger) + return err + }) + if err != nil { + return Batch{}, err + } + if b.Created { + s.batchJob.Wake() + } + return b, nil +} + +// activeBatch returns the batch pending or running in q's transaction, or +// nil when there is none. A batch still pending or running whose job is no +// longer queued or running is failed first, so a batch whose job ended +// without recording its end (the process stopped, or the write failed) is +// never reported as active and never blocks a later one. +func (s *Service) activeBatch(ctx context.Context, q *db.Queries) (*Batch, error) { + n, err := q.FailOrphanedSuggestionBatches(ctx, db.FailOrphanedSuggestionBatchesParams{ + UpdatedAt: store.Now().String(), + Kind: KindSuggestionBatch, + }) + if err != nil { + return nil, fmt.Errorf("suggest: fail orphaned batches: %w", err) + } + if n > 0 { + s.logger.WarnContext(ctx, "failed suggestion batches whose job ended", "count", n) + } + cur, err := q.GetActiveSuggestionBatch(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("suggest: read active batch: %w", err) + } + jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID}) + if err != nil { + return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err) + } + return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil +} + +// failedBatch returns the newest batch in q's transaction when it failed, +// or nil when it did not or there is none: a later batch, done or under +// way, supersedes an earlier failure. Run after activeBatch, so a batch +// whose job ended without recording its end reads as failed. +func failedBatch(ctx context.Context, q *db.Queries) (*Batch, error) { + cur, err := q.GetLatestSuggestionBatch(ctx) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("suggest: read latest batch: %w", err) + } + if cur.Status != BatchFailed { + return nil, nil + } + jobID, err := q.GetSuggestionBatchJobID(ctx, db.GetSuggestionBatchJobIDParams{Kind: KindSuggestionBatch, BatchID: cur.ID}) + if err != nil { + return nil, fmt.Errorf("suggest: read job of batch %s: %w", cur.ID, err) + } + return &Batch{ID: cur.ID, JobID: jobID, Trigger: cur.Trigger, Status: cur.Status}, nil +} + +// startBatch does StartBatch's work in tx: the batch and the job that runs +// it are committed together, so a batch is never seen without its job. The +// caller wakes the job runner once tx has committed and Created is true. +func (s *Service) startBatch(ctx context.Context, tx *sql.Tx, trigger string) (Batch, error) { + q := db.New(tx) + cur, err := s.activeBatch(ctx, q) + if err != nil { + return Batch{}, err + } + if cur != nil { + return *cur, nil + } + now := store.Now().String() + row, err := q.CreateSuggestionBatch(ctx, db.CreateSuggestionBatchParams{ + ID: store.NewID(), + Trigger: trigger, + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + return Batch{}, fmt.Errorf("suggest: create batch: %w", err) + } + jobID, err := s.batchJob.EnqueueTx(ctx, tx, BatchJob{BatchID: row.ID}) + if err != nil { + return Batch{}, fmt.Errorf("suggest: enqueue batch %s: %w", row.ID, err) + } + return Batch{ID: row.ID, JobID: jobID, Trigger: row.Trigger, Status: row.Status, Created: true}, nil +} + +// finishBatch records running batch id as status, with +// jobs.FinalWriteContext: the write is made even when ctx has been +// cancelled, but a writer that stays busy cannot hold its worker past +// jobs.FinalWriteTimeout. Its job's handler returns any error, so the job +// never ends succeeded with the batch still running; the runner keeps only +// a code, so the error is logged too. +func (s *Service) finishBatch(ctx context.Context, id, status string) error { + write, cancel := jobs.FinalWriteContext(ctx) + defer cancel() + n, err := db.New(s.st.Writer).FinishSuggestionBatch(write, db.FinishSuggestionBatchParams{ + Status: status, + UpdatedAt: store.Now().String(), + ID: id, + }) + if err == nil && n != 1 { + err = errors.New("batch is not running") + } + if err != nil { + s.logger.ErrorContext(ctx, "record suggestion batch status", "batch_id", id, "status", status, "error", err) + return fmt.Errorf("suggest: record batch %s %s: %w", id, status, err) + } + return nil +} + +// Suggestion is a Side quest suggestion as the API shows it, with its +// dinner. +type Suggestion struct { + ID string `json:"id"` + DinnerID string `json:"dinner_id"` + Name string `json:"name"` + PhotoURL *string `json:"photo_url"` + Difficulty *string `json:"difficulty"` + Method *string `json:"method"` + MinutesTotal *int64 `json:"minutes_total"` + Cuisine *string `json:"cuisine"` + // Reason is the "Why this?". + Reason string `json:"reason"` + CitedRegularIDs []string `json:"cited_regular_ids"` + Status string `json:"status"` + DinnerStatus string `json:"dinner_status"` + DecidedAt *string `json:"decided_at"` + CreatedAt string `json:"created_at"` +} + +// Deck is the household's deck: the pending suggestions whose dinner is +// still suggested, oldest first; the batch pending or running, if any, so +// a client can follow the job producing more; and the newest batch when it +// failed, so a client that was not following its job still learns of the +// failure. At most one of ActiveBatch and FailedBatch is set: a batch is +// only started when none is pending or running, so the active batch is +// always the newest. +type Deck struct { + Suggestions []Suggestion `json:"suggestions"` + ActiveBatch *Batch `json:"active_batch"` + FailedBatch *Batch `json:"failed_batch"` +} + +// Deck reads the deck and its active and failed batches in one +// transaction, so all come from the same snapshot. It is a write +// transaction because reading the active batch first fails any batch whose +// job has ended (see activeBatch), which is then the failed batch. +func (s *Service) Deck(ctx context.Context) (Deck, error) { + var deck Deck + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + rows, err := q.ListDeck(ctx) + if err != nil { + return fmt.Errorf("suggest: list deck: %w", err) + } + deck.Suggestions = make([]Suggestion, len(rows)) + for i, row := range rows { + deck.Suggestions[i], err = fromRow(row.Suggestion, row.Dinner) + if err != nil { + return err + } + } + deck.ActiveBatch, err = s.activeBatch(ctx, q) + if err != nil || deck.ActiveBatch != nil { + return err + } + deck.FailedBatch, err = failedBatch(ctx, q) + return err + }) + if err != nil { + return Deck{}, err + } + return deck, nil +} + +// Decision is a suggestion once decided, with the id of the replenish +// batch's job when the decision called for one: the batch it started, or +// the one already pending or running that it joined. +type Decision struct { + Suggestion Suggestion `json:"suggestion"` + ReplenishJobID *string `json:"replenish_job_id"` +} + +// Accept records Try it on suggestion id, moving its dinner to the Quest +// log and recording updatedBy on it. +func (s *Service) Accept(ctx context.Context, id, updatedBy string) (Decision, error) { + return s.decide(ctx, id, StatusAccepted, dinners.StatusQuestLog, updatedBy) +} + +// Decline records Pass on suggestion id, archiving its dinner and +// recording updatedBy on it. The suggestion keeps the name its dish was +// suggested under, whatever the dinner is later renamed, and every later +// batch reads it as declined, so the dish is never suggested again. +func (s *Service) Decline(ctx context.Context, id, updatedBy string) (Decision, error) { + return s.decide(ctx, id, StatusDeclined, dinners.StatusArchived, updatedBy) +} + +// decide records decision on suggestion id and moves its dinner to +// dinnerStatus. Repeating a decision changes nothing and returns the +// suggestion as it is; the opposite decision is a *DecidedError. A +// decision that leaves the deck under ReplenishBelow starts a replenish +// batch, or joins the one already pending or running, in the decision's own +// transaction, and returns its job id either way: the decision is never recorded without the replenish it +// calls for, and a failure to start the batch records neither. +func (s *Service) decide(ctx context.Context, id, decision, dinnerStatus, updatedBy string) (Decision, error) { + if updatedBy == "" { + return Decision{}, errors.New("suggest: decide: no member to record as updated_by") + } + var row db.GetSuggestionRow + var replenish *Batch + err := s.st.WithTx(ctx, func(tx *sql.Tx) error { + q := db.New(tx) + cur, err := q.GetSuggestion(ctx, id) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return fmt.Errorf("suggest: read suggestion %s: %w", id, err) + } + switch cur.Suggestion.Status { + case decision: + row = cur + return nil + case StatusPending: + default: + return &DecidedError{Status: cur.Suggestion.Status} + } + if cur.Dinner.Status != dinners.StatusSuggested { + return ErrDinnerNotSuggested + } + now := store.Now().String() + if _, err := q.DecideSuggestion(ctx, db.DecideSuggestionParams{ + Status: decision, + Now: sql.NullString{String: now, Valid: true}, + ID: id, + }); err != nil { + return fmt.Errorf("suggest: decide %s: %w", id, err) + } + if _, err := q.MoveSuggestedDinner(ctx, db.MoveSuggestedDinnerParams{ + Status: dinnerStatus, + UpdatedBy: sql.NullString{String: updatedBy, Valid: true}, + UpdatedAt: now, + ID: cur.Dinner.ID, + }); err != nil { + return fmt.Errorf("suggest: move dinner %s: %w", cur.Dinner.ID, err) + } + row, err = q.GetSuggestion(ctx, id) + if err != nil { + return fmt.Errorf("suggest: read suggestion %s: %w", id, err) + } + left, err := q.CountDeck(ctx) + if err != nil { + return fmt.Errorf("suggest: count deck: %w", err) + } + if left >= ReplenishBelow { + return nil + } + b, err := s.startBatch(ctx, tx, TriggerReplenish) + if err != nil { + return err + } + replenish = &b + return nil + }) + if err != nil { + return Decision{}, err + } + if replenish != nil && replenish.Created { + s.batchJob.Wake() + } + sug, err := fromRow(row.Suggestion, row.Dinner) + if err != nil { + return Decision{}, err + } + d := Decision{Suggestion: sug} + if replenish != nil { + d.ReplenishJobID = &replenish.JobID + } + return d, nil +} + +func fromRow(sg db.Suggestion, d db.Dinner) (Suggestion, error) { + out := Suggestion{ + ID: sg.ID, + DinnerID: d.ID, + Name: d.Name, + Difficulty: nullString(d.Difficulty), + Method: nullString(d.Method), + Cuisine: nullString(d.Cuisine), + Reason: sg.Reason, + Status: sg.Status, + DinnerStatus: d.Status, + DecidedAt: nullString(sg.DecidedAt), + CreatedAt: sg.CreatedAt, + } + if err := json.Unmarshal([]byte(sg.CitedRegularIds), &out.CitedRegularIDs); err != nil || out.CitedRegularIDs == nil { + return Suggestion{}, fmt.Errorf("suggest: suggestion %s cited_regular_ids is not a JSON array: %q", sg.ID, sg.CitedRegularIds) + } + if d.MinutesTotal.Valid { + out.MinutesTotal = &d.MinutesTotal.Int64 + } + if d.PhotoID.Valid { + u := dinners.PhotoURL(d.PhotoID.String) + out.PhotoURL = &u + } + return out, nil +} + +func nullString(s sql.NullString) *string { + if !s.Valid { + return nil + } + return &s.String +} diff --git a/internal/suggest/suggest_test.go b/internal/suggest/suggest_test.go new file mode 100644 index 0000000..915b789 --- /dev/null +++ b/internal/suggest/suggest_test.go @@ -0,0 +1,1253 @@ +package suggest_test + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "slices" + "strings" + "sync" + "testing" + "time" + + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/jobs" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/suggest" + "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations" +) + +const member = "member-1" + +type env struct { + svc *suggest.Service + st *store.Store + runner *jobs.Runner + search *recipestest.Search + pages *recipestest.Pages + images *recipestest.Images + // model answers normalize_recipe while grounding; side answers + // suggest_side_quests. + model *llm.Fake + side *llm.Fake + // regulars are the Regulars' ids by name. + regulars map[string]string + grounder *recipes.Grounder + + // logs collects the service's and job runner's log output. + logs *logBuffer + + mu sync.Mutex + inputs []skills.SideQuestsRequest + // stub, when set, answers in place of suggest_side_quests. + stub func(in skills.SideQuestsRequest) skills.SideQuests +} + +// newEnv returns a suggestions service over a migrated store with the +// Regulars Beef Chili and Chicken Fajitas and an unstarted job runner. It +// suggests through the real harness and suggest_side_quests with the +// scripted e.side, and grounds with fake search, pages and images and the +// scripted e.model. +func newEnv(t *testing.T) *env { + t.Helper() + ctx := t.Context() + dataDir := t.TempDir() + st, err := store.Open(ctx, dataDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := st.Close(); err != nil { + t.Error(err) + } + }) + if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil { + t.Fatal(err) + } + if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil { + t.Fatal(err) + } + e := &env{ + st: st, + 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{}, + side: &llm.Fake{}, + regulars: map[string]string{}, + logs: &logBuffer{}, + } + for _, name := range []string{"Beef Chili", "Chicken Fajitas"} { + e.regulars[name] = e.dinner(t, name, "regular") + } + g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.model) + e.grounder = g + guard, err := harness.NewGuard(harness.DefaultMaxCalls) + if err != nil { + t.Fatal(err) + } + cfg := harness.Config{ + Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7}, + NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.side, nil }, + Store: st, + Guard: guard, + } + suggester := func(ctx context.Context, scope string, in skills.SideQuestsRequest) (skills.SideQuests, error) { + e.mu.Lock() + e.inputs = append(e.inputs, in) + stub := e.stub + e.mu.Unlock() + if stub != nil { + return stub(in), nil + } + return skills.SuggestSideQuests(ctx, cfg, scope, in) + } + logger := slog.New(slog.NewTextHandler(e.logs, nil)) + e.runner, err = jobs.NewRunner(st, logger, 1) + if err != nil { + t.Fatal(err) + } + e.svc, err = suggest.NewService(st, g, suggester, e.runner, logger) + if err != nil { + t.Fatal(err) + } + return e +} + +// logBuffer collects log output written by concurrent goroutines. +type logBuffer struct { + mu sync.Mutex + buf strings.Builder +} + +func (b *logBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *logBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// start starts the env's job runner, stopping it when the test ends. +func (e *env) start(t *testing.T) { + t.Helper() + 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() + }) +} + +// dinner stores a dinner named name with status and returns its id. +func (e *env) dinner(t *testing.T, name, status string) string { + t.Helper() + now := store.Now().String() + origin := "regular" + if status == "suggested" { + origin = "side_quest" + } + d, err := db.New(e.st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{ + ID: store.NewID(), + Name: name, + NormalizedName: harness.Normalize(name), + Status: status, + Origin: origin, + GroundingStatus: "manual", + CreatedAt: now, + UpdatedAt: now, + }) + if err != nil { + t.Fatal(err) + } + return d.ID +} + +// seedDeck stores a finished batch holding pending suggestions of +// dinners named names, in order, and returns their ids. +func (e *env) seedDeck(t *testing.T, names ...string) []string { + t.Helper() + q := db.New(e.st.Writer) + now := store.Now().String() + b, err := q.CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + if _, err := q.FinishSuggestionBatch(t.Context(), db.FinishSuggestionBatchParams{Status: "done", UpdatedAt: now, ID: b.ID}); err != nil { + t.Fatal(err) + } + var ids []string + for _, name := range names { + id := store.NewID() + if err := q.CreateSuggestion(t.Context(), db.CreateSuggestionParams{ + ID: id, + DinnerID: e.dinner(t, name, "suggested"), + BatchID: b.ID, + Name: name, + Reason: "Like your Beef Chili.", + CitedRegularIds: fmt.Sprintf("[%q]", e.regulars["Beef Chili"]), + CreatedAt: store.Now().String(), + UpdatedAt: now, + }); err != nil { + t.Fatal(err) + } + ids = append(ids, id) + } + return ids +} + +// recipe makes the search for name find one page that grounds it, and +// scripts the normalize_recipe reply grounding it takes. +func (e *env) recipe(t *testing.T, name string) { + t.Helper() + slug := strings.ReplaceAll(strings.ToLower(name), " ", "-") + page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg" + e.search.Results[name] = []recipes.Candidate{{URL: page}} + e.pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 35) + e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1)) + e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 35)) +} + +// sideQuest renders one Side quest of a suggest_side_quests reply whose +// search query is its name. +func sideQuest(name, reason string, cited ...string) string { + c, _ := json.Marshal(cited) + r, _ := json.Marshal(reason) + return fmt.Sprintf(`{"name":%q,"cuisine":"Fusion","method":"stovetop","est_minutes":35,"difficulty":"easy","reason":%s,"cited_regulars":%s,"search_query":%q}`, + name, r, c, name) +} + +// reply scripts one suggest_side_quests reply holding quests. +func (e *env) reply(quests ...string) { + js := `{"suggestions":[` + strings.Join(quests, ",") + `]}` + e.side.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}}) +} + +// waitJob waits for job id to finish and returns it. +func (e *env) waitJob(t *testing.T, id string) jobs.Job { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + job, err := e.runner.Get(t.Context(), id) + if err != nil { + t.Fatal(err) + } + if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed { + return job + } + if time.Now().After(deadline) { + t.Fatalf("job %s still %s", id, job.Status) + } + time.Sleep(5 * time.Millisecond) + } +} + +type batchResult struct { + BatchID string `json:"batch_id"` + Stage string `json:"stage"` + SuggestionIDs []string `json:"suggestion_ids"` + Dropped int `json:"dropped"` +} + +// runBatch starts a manual batch, waits for it to succeed and returns its +// result. +func (e *env) runBatch(t *testing.T) batchResult { + t.Helper() + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !b.Created { + t.Fatalf("batch %s was already active", b.ID) + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s, error %v", job.Status, *job.Error) + } + var res batchResult + if err := json.Unmarshal(job.Result, &res); err != nil { + t.Fatal(err) + } + if res.BatchID != b.ID || res.Stage != suggest.StageDone { + t.Fatalf("batch result = %+v, want batch %s done", res, b.ID) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchDone { + t.Fatalf("batch status = %s, want done", got) + } + return res +} + +func (e *env) batchStatus(t *testing.T, id string) string { + t.Helper() + var status string + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT status FROM suggestion_batches WHERE id = ?`, id).Scan(&status); err != nil { + t.Fatal(err) + } + return status +} + +func (e *env) activeBatches(t *testing.T) int { + t.Helper() + var n int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM suggestion_batches WHERE status IN ('pending', 'running')`).Scan(&n); err != nil { + t.Fatal(err) + } + return n +} + +func (e *env) dinnerStatus(t *testing.T, id string) string { + t.Helper() + row, err := db.New(e.st.Reader).GetDinner(t.Context(), id) + if err != nil { + t.Fatal(err) + } + return row.Status +} + +// deckNames returns the names Deck lists, in order. +func (e *env) deckNames(t *testing.T) []string { + t.Helper() + deck, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, s := range deck.Suggestions { + names = append(names, s.Name) + } + return names +} + +func TestBatchStoresGroundedSideQuests(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chicken Shawarma Bowls") + e.recipe(t, "Chili Verde") + e.reply( + sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Mystery Stew", "Hearty like your Beef Chili.", "Beef Chili"), // no recipe grounds it + sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), + sideQuest("Lamb Tagine", "Slow-cooked like your Pot Roast.", "Pot Roast"), // cites no Regular + ) + + res := e.runBatch(t) + if len(res.SuggestionIDs) != 2 || res.Dropped != 2 { + t.Fatalf("result = %+v, want 2 suggestions and 2 dropped", res) + } + if g, _, _ := e.model.Remaining(); g != 0 { + t.Errorf("%d normalize_recipe replies unused", g) + } + if q := e.search.Queries(); slices.Contains(q, "Lamb Tagine") { + t.Errorf("searched %q for a Side quest the skill dropped", q) + } + + deck, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(deck.Suggestions) != 2 { + t.Fatalf("deck has %d suggestions, want 2", len(deck.Suggestions)) + } + want := []struct{ name, reason, regular string }{ + {"Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"}, + {"Chili Verde", "Your Beef Chili gone green.", "Beef Chili"}, + } + for i, w := range want { + s := deck.Suggestions[i] + if s.ID != res.SuggestionIDs[i] || s.Name != w.name || s.Reason != w.reason || s.Status != suggest.StatusPending { + t.Errorf("deck[%d] = %+v, want %s pending with reason %q", i, s, w.name, w.reason) + } + if !slices.Equal(s.CitedRegularIDs, []string{e.regulars[w.regular]}) { + t.Errorf("deck[%d] cites %v, want %s's id", i, s.CitedRegularIDs, w.regular) + } + if s.PhotoURL == nil || !strings.HasPrefix(*s.PhotoURL, "/media/photos/") || s.Difficulty == nil { + t.Errorf("deck[%d] photo %v, difficulty %v; want both", i, s.PhotoURL, s.Difficulty) + } + row, err := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID) + if err != nil { + t.Fatal(err) + } + if row.Status != "suggested" || row.Origin != "side_quest" || row.GroundingStatus != "grounded" || !row.RecipeID.Valid || row.Cuisine.String != "Fusion" { + t.Errorf("dinner %s = %+v, want suggested side_quest grounded in a recipe", w.name, row) + } + } + var dinners int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners WHERE name IN ('Mystery Stew', 'Lamb Tagine')`).Scan(&dinners); err != nil { + t.Fatal(err) + } + if dinners != 0 { + t.Errorf("%d dropped Side quests stored as dinners, want none", dinners) + } +} + +func TestBatchKeepsAtMostBatchSize(t *testing.T) { + e := newEnv(t) + e.start(t) + var quests []string + for i := range suggest.BatchSize + 2 { + name := fmt.Sprintf("Fajita Dish %c", 'A'+i) + e.search.Results[name] = nil // ungroundable unless recipe is called + quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas")) + } + // The first round grounds BatchSize Side quests; one fails, so the + // second round grounds one more, and the last is never grounded. + for i := range suggest.BatchSize + 1 { + if i != 1 { + e.recipe(t, fmt.Sprintf("Fajita Dish %c", 'A'+i)) + } + } + e.reply(quests...) + + res := e.runBatch(t) + if len(res.SuggestionIDs) != suggest.BatchSize { + t.Fatalf("kept %d, want %d", len(res.SuggestionIDs), suggest.BatchSize) + } + if slices.Contains(e.search.Queries(), fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize+1)) { + t.Error("grounded a Side quest after the batch was full") + } + names := e.deckNames(t) + if names[0] != "Fajita Dish A" || names[1] != "Fajita Dish C" || names[len(names)-1] != fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize) { + t.Errorf("deck = %v, want the model's order without B", names) + } +} + +func TestDeckIsOldestFirst(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chicken Shawarma Bowls") + e.recipe(t, "Chili Verde") + e.reply( + sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili"), + ) + e.runBatch(t) + e.recipe(t, "Beef Bulgogi") + e.reply(sideQuest("Beef Bulgogi", "Sweet beef for Beef Chili fans.", "Beef Chili")) + e.runBatch(t) + + want := []string{"Chicken Shawarma Bowls", "Chili Verde", "Beef Bulgogi"} + if got := e.deckNames(t); !slices.Equal(got, want) { + t.Errorf("deck = %v, want %v", got, want) + } + // The second batch was told the first one's Side quests are pending. + if got := e.inputs[1].Pending; !slices.Equal(got, want[:2]) { + t.Errorf("second batch pending = %v, want %v", got, want[:2]) + } +} + +func TestDeckReportsTheActiveBatch(t *testing.T) { + e := newEnv(t) // runner not started: the batch stays pending + e.seedDeck(t, "Pho") + deck, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if deck.ActiveBatch != nil { + t.Fatalf("active batch = %+v with none pending or running, want nil", deck.ActiveBatch) + } + if deck.FailedBatch != nil { + t.Fatalf("failed batch = %+v with none failed, want nil", deck.FailedBatch) + } + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + deck, err = e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(deck.Suggestions) != 1 { + t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions)) + } + want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchPending} + if deck.ActiveBatch == nil || *deck.ActiveBatch != want { + t.Errorf("active batch = %+v, want %+v", deck.ActiveBatch, want) + } + if deck.FailedBatch != nil { + t.Errorf("failed batch = %+v with a batch pending, want nil", deck.FailedBatch) + } +} + +func TestDeckReportsTheLatestFailedBatch(t *testing.T) { + e := newEnv(t) + e.start(t) + e.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed { + t.Fatalf("job status = %s, want failed", job.Status) + } + deck, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchFailed} + if deck.ActiveBatch != nil || deck.FailedBatch == nil || *deck.FailedBatch != want { + t.Fatalf("active batch = %+v, failed batch = %+v; want no active batch and failed %+v", deck.ActiveBatch, deck.FailedBatch, want) + } + + // A later batch that succeeds supersedes the failure. + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + e.runBatch(t) + deck, err = e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if deck.ActiveBatch != nil || deck.FailedBatch != nil { + t.Errorf("active batch = %+v, failed batch = %+v after a batch succeeded, want neither", deck.ActiveBatch, deck.FailedBatch) + } +} + +func TestDeckFailsBatchInterruptedByRestart(t *testing.T) { + e := newEnv(t) + e.seedDeck(t, "Pho") + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + // A previous process claimed the job and started the batch, then + // stopped before recording its end. + now := store.Now().String() + if _, err := db.New(e.st.Writer).ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil { + t.Fatal(err) + } + if n, err := db.New(e.st.Writer).StartSuggestionBatch(t.Context(), db.StartSuggestionBatchParams{UpdatedAt: now, ID: b.ID}); err != nil || n != 1 { + t.Fatalf("start batch: %d rows, %v", n, err) + } + e.start(t) // fails the running job as interrupted + if job := e.waitJob(t, b.JobID); job.Status != jobs.StatusFailed { + t.Fatalf("job status = %s, want failed", job.Status) + } + + deck, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if deck.ActiveBatch != nil { + t.Errorf("active batch = %+v after its job failed, want nil", deck.ActiveBatch) + } + want := suggest.Batch{ID: b.ID, JobID: b.JobID, Trigger: suggest.TriggerManual, Status: suggest.BatchFailed} + if deck.FailedBatch == nil || *deck.FailedBatch != want { + t.Errorf("failed batch = %+v after its job was interrupted, want %+v", deck.FailedBatch, want) + } + if len(deck.Suggestions) != 1 { + t.Errorf("deck has %d suggestions, want 1", len(deck.Suggestions)) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("interrupted batch status = %s, want failed", got) + } +} + +func TestDeckLeavesOutDinnersNoLongerSuggested(t *testing.T) { + e := newEnv(t) + ids := e.seedDeck(t, "Pho", "Ramen") + sug, err := e.svc.Deck(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: sug.Suggestions[0].DinnerID, UpdatedAt: store.Now().String()}); err != nil { + t.Fatal(err) + } + if got := e.deckNames(t); !slices.Equal(got, []string{"Ramen"}) { + t.Errorf("deck = %v, want [Ramen]", got) + } + if _, err := e.svc.Accept(t.Context(), ids[0], member); !errors.Is(err, suggest.ErrDinnerNotSuggested) { + t.Errorf("accept a suggestion whose dinner was archived: %v, want ErrDinnerNotSuggested", err) + } +} + +func TestAcceptIsIdempotent(t *testing.T) { + e := newEnv(t) + ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu") + + first, err := e.svc.Accept(t.Context(), ids[0], member) + if err != nil { + t.Fatal(err) + } + s := first.Suggestion + if s.Status != suggest.StatusAccepted || s.DinnerStatus != "quest_log" || s.DecidedAt == nil { + t.Fatalf("accepted = %+v, want accepted with its dinner in the Quest log", s) + } + row, err := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID) + if err != nil { + t.Fatal(err) + } + if row.Status != "quest_log" || row.UpdatedBy.String != member { + t.Errorf("dinner status %s, updated_by %q; want quest_log by %s", row.Status, row.UpdatedBy.String, member) + } + + again, err := e.svc.Accept(t.Context(), ids[0], "member-2") + if err != nil { + t.Fatal(err) + } + if *again.Suggestion.DecidedAt != *s.DecidedAt || again.ReplenishJobID != nil { + t.Errorf("repeat accept = %+v, want the first decision unchanged", again) + } + if row, _ := db.New(e.st.Reader).GetDinner(t.Context(), s.DinnerID); row.UpdatedBy.String != member { + t.Errorf("repeat accept recorded updated_by %q", row.UpdatedBy.String) + } + + var decided *suggest.DecidedError + if _, err := e.svc.Decline(t.Context(), ids[0], member); !errors.As(err, &decided) || decided.Status != suggest.StatusAccepted { + t.Fatalf("decline an accepted suggestion: %v, want *DecidedError accepted", err) + } + if got := e.dinnerStatus(t, s.DinnerID); got != "quest_log" { + t.Errorf("dinner status after refused decline = %s, want quest_log", got) + } + if got := e.deckNames(t); slices.Contains(got, "Pho") { + t.Errorf("deck %v still holds the accepted suggestion", got) + } +} + +func TestDeclineIsIdempotent(t *testing.T) { + e := newEnv(t) + ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu") + + first, err := e.svc.Decline(t.Context(), ids[1], member) + if err != nil { + t.Fatal(err) + } + s := first.Suggestion + if s.Status != suggest.StatusDeclined || s.DinnerStatus != "archived" || s.DecidedAt == nil { + t.Fatalf("declined = %+v, want declined with its dinner archived", s) + } + again, err := e.svc.Decline(t.Context(), ids[1], member) + if err != nil { + t.Fatal(err) + } + if *again.Suggestion.DecidedAt != *s.DecidedAt || again.ReplenishJobID != nil { + t.Errorf("repeat decline = %+v, want the first decision unchanged", again) + } + var decided *suggest.DecidedError + if _, err := e.svc.Accept(t.Context(), ids[1], member); !errors.As(err, &decided) || decided.Status != suggest.StatusDeclined { + t.Fatalf("accept a declined suggestion: %v, want *DecidedError declined", err) + } + if got := e.dinnerStatus(t, s.DinnerID); got != "archived" { + t.Errorf("dinner status after refused accept = %s, want archived", got) + } + names, err := db.New(e.st.Reader).ListDeclinedSuggestionNames(t.Context()) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(names, []string{"Ramen"}) { + t.Errorf("declined names = %v, want [Ramen]", names) + } +} + +func TestDecideUnknownSuggestion(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.Accept(t.Context(), "nope", member); !errors.Is(err, suggest.ErrNotFound) { + t.Errorf("accept unknown: %v, want ErrNotFound", err) + } + if _, err := e.svc.Decline(t.Context(), "nope", member); !errors.Is(err, suggest.ErrNotFound) { + t.Errorf("decline unknown: %v, want ErrNotFound", err) + } +} + +func TestReplenishWhenDeckFallsBelowFive(t *testing.T) { + e := newEnv(t) // runner not started: a replenish batch stays pending + ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu", "Tonkatsu") + + d, err := e.svc.Accept(t.Context(), ids[0], member) // 5 left + if err != nil { + t.Fatal(err) + } + if d.ReplenishJobID != nil || e.activeBatches(t) != 0 { + t.Fatalf("replenished with 5 left: job %v, %d active batches", d.ReplenishJobID, e.activeBatches(t)) + } + + d, err = e.svc.Decline(t.Context(), ids[1], member) // 4 left + if err != nil { + t.Fatal(err) + } + if d.ReplenishJobID == nil { + t.Fatal("no replenish batch with 4 left") + } + job, err := e.runner.Get(t.Context(), *d.ReplenishJobID) + if err != nil { + t.Fatal(err) + } + if job.Kind != suggest.KindSuggestionBatch || job.Status != jobs.StatusQueued { + t.Errorf("replenish job = %+v, want a queued %s", job, suggest.KindSuggestionBatch) + } + var trigger string + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT trigger FROM suggestion_batches WHERE status = 'pending'`).Scan(&trigger); err != nil { + t.Fatal(err) + } + if trigger != suggest.TriggerReplenish { + t.Errorf("batch trigger = %s, want replenish", trigger) + } +} + +func TestReplenishIsSingleFlight(t *testing.T) { + e := newEnv(t) // runner not started: the replenish batch stays pending + ids := e.seedDeck(t, "Pho", "Ramen", "Laksa") + + first, err := e.svc.Decline(t.Context(), ids[0], member) + if err != nil { + t.Fatal(err) + } + if first.ReplenishJobID == nil { + t.Fatal("no replenish batch with 2 left") + } + second, err := e.svc.Accept(t.Context(), ids[1], member) + if err != nil { + t.Fatal(err) + } + // The second decision joins the pending batch and reports its job, so a + // client that did not start it can follow it. + if second.ReplenishJobID == nil || *second.ReplenishJobID != *first.ReplenishJobID { + t.Errorf("second decision replenish job = %v, want the pending batch's job %s", second.ReplenishJobID, *first.ReplenishJobID) + } + manual, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if manual.Created || manual.JobID != *first.ReplenishJobID || manual.Trigger != suggest.TriggerReplenish { + t.Errorf("manual batch = %+v, want the pending replenish batch with job %s", manual, *first.ReplenishJobID) + } + if n := e.activeBatches(t); n != 1 { + t.Errorf("%d active batches, want 1", n) + } + var jobsQueued int + if err := e.st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, suggest.KindSuggestionBatch).Scan(&jobsQueued); err != nil { + t.Fatal(err) + } + if jobsQueued != 1 { + t.Errorf("%d batch jobs, want 1", jobsQueued) + } +} + +func TestStartBatchIsSingleFlightUnderConcurrency(t *testing.T) { + e := newEnv(t) + var wg sync.WaitGroup + batches := make([]suggest.Batch, 8) + for i := range batches { + wg.Add(1) + go func() { + defer wg.Done() + b, err := e.svc.StartBatch(context.Background(), suggest.TriggerManual) + if err != nil { + t.Error(err) + } + batches[i] = b + }() + } + wg.Wait() + created := 0 + for _, b := range batches { + if b.Created { + created++ + } + if b.ID != batches[0].ID || b.JobID != batches[0].JobID || b.JobID == "" { + t.Errorf("batch %+v, want %s with job %s", b, batches[0].ID, batches[0].JobID) + } + } + if created != 1 { + t.Errorf("%d calls created a batch, want 1", created) + } +} + +func TestLaterBatchNeverResuggestsDeclined(t *testing.T) { + e := newEnv(t) + e.start(t) + shawarma := sideQuest("Chicken Shawarma Bowls", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas") + e.recipe(t, "Chicken Shawarma Bowls") + e.reply(shawarma) + res := e.runBatch(t) + + // Passing it leaves the deck empty, so a replenish batch runs; the + // model suggests the passed dish again, respelled, with a new one. + e.recipe(t, "Beef Ragu") + e.reply( + sideQuest("chicken shawarma bowls!", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"), + ) + d, err := e.svc.Decline(t.Context(), res.SuggestionIDs[0], member) + if err != nil { + t.Fatal(err) + } + if d.ReplenishJobID == nil { + t.Fatal("passing the last Side quest started no replenish batch") + } + job := e.waitJob(t, *d.ReplenishJobID) + if job.Status != jobs.StatusSucceeded { + t.Fatalf("replenish job %s: %v", job.Status, *job.Error) + } + var replenished batchResult + if err := json.Unmarshal(job.Result, &replenished); err != nil { + t.Fatal(err) + } + if len(replenished.SuggestionIDs) != 1 || replenished.Dropped != 1 { + t.Errorf("replenish result = %+v, want 1 kept and the passed dish dropped", replenished) + } + if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) { + t.Errorf("deck = %v, want [Beef Ragu]", got) + } + e.mu.Lock() + in := e.inputs[1] + e.mu.Unlock() + if !slices.Equal(in.Declined, []string{"Chicken Shawarma Bowls"}) || !slices.Equal(in.Context.Passed, []string{"Chicken Shawarma Bowls"}) { + t.Errorf("replenish batch declined %v, passed %v; want the passed dish in both", in.Declined, in.Context.Passed) + } +} + +// TestBatchNeverStoresDishPassedWhileRunning passes a Side quest after a +// batch has read its input but before it stores, with suggest_side_quests +// answering from that input: the batch must not store the passed dish. +func TestBatchNeverStoresDishPassedWhileRunning(t *testing.T) { + e := newEnv(t) + e.start(t) + ids := e.seedDeck(t, "Chicken Shawarma Bowls") + e.recipe(t, "Chicken Shawarma Bowls") + e.recipe(t, "Beef Ragu") + held, release := make(chan struct{}), make(chan struct{}) + stub := func(skills.SideQuestsRequest) skills.SideQuests { + close(held) + <-release + quest := func(name, cited string) skills.SideQuest { + return skills.SideQuest{Name: name, Cuisine: "Fusion", Method: "stovetop", EstMinutes: 35, Difficulty: "easy", + Reason: "Like your " + cited + ".", CitedRegulars: []string{cited}, SearchQuery: name} + } + return skills.SideQuests{Suggestions: []skills.SideQuest{ + quest("Chicken Shawarma Bowls", "Chicken Fajitas"), + quest("Beef Ragu", "Beef Chili"), + }} + } + e.mu.Lock() + e.stub = stub + e.mu.Unlock() + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + <-held + if _, err := e.svc.Decline(t.Context(), ids[0], member); err != nil { + close(release) + t.Fatal(err) + } + close(release) + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusSucceeded { + t.Fatalf("batch job %s: %v", job.Status, *job.Error) + } + var res batchResult + if err := json.Unmarshal(job.Result, &res); err != nil { + t.Fatal(err) + } + if len(res.SuggestionIDs) != 1 || res.Dropped != 1 { + t.Errorf("batch result = %+v, want 1 kept and the passed dish dropped", res) + } + if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) { + t.Errorf("deck = %v, want [Beef Ragu]", got) + } +} + +// TestLaterBatchNeverResuggestsRenamedPass passes a Side quest, renames +// its archived dinner, and checks the next batch still refuses the name it +// was suggested under. +func TestLaterBatchNeverResuggestsRenamedPass(t *testing.T) { + e := newEnv(t) // runner not started: the replenish batch waits for the rename + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + dinnerSvc, err := dinners.NewService(e.st, e.grounder, e.runner, logger) + if err != nil { + t.Fatal(err) + } + ids := e.seedDeck(t, "Chicken Shawarma Bowls") + d, err := e.svc.Decline(t.Context(), ids[0], member) + if err != nil { + t.Fatal(err) + } + if d.ReplenishJobID == nil { + t.Fatal("passing the last Side quest started no replenish batch") + } + renamed := "Leftover Wrap" + if _, err := dinnerSvc.Update(t.Context(), d.Suggestion.DinnerID, dinners.Patch{Name: &renamed}, member); err != nil { + t.Fatal(err) + } + + // The respelled passed dish would ground, so only the declined names + // keep it out of the deck. + e.recipe(t, "chicken shawarma bowls!") + e.recipe(t, "Beef Ragu") + e.reply( + sideQuest("chicken shawarma bowls!", "Spiced chicken, like your Chicken Fajitas.", "Chicken Fajitas"), + sideQuest("Beef Ragu", "Slow beef like your Beef Chili.", "Beef Chili"), + ) + e.start(t) + job := e.waitJob(t, *d.ReplenishJobID) + if job.Status != jobs.StatusSucceeded { + t.Fatalf("replenish job %s: %v", job.Status, *job.Error) + } + if got := e.deckNames(t); !slices.Equal(got, []string{"Beef Ragu"}) { + t.Errorf("deck = %v, want [Beef Ragu]", got) + } + e.mu.Lock() + in := e.inputs[0] + e.mu.Unlock() + if !slices.Equal(in.Declined, []string{"Chicken Shawarma Bowls"}) || !slices.Equal(in.Context.Passed, []string{"Chicken Shawarma Bowls"}) { + t.Errorf("replenish batch declined %v, passed %v; want the name it was suggested under in both", in.Declined, in.Context.Passed) + } +} + +// TestDecisionNeverCommitsWithoutItsReplenish makes the replenish batch's +// job fail to store: the decision must not be recorded either, so the +// retry decides again and starts the replenish. +func TestDecisionNeverCommitsWithoutItsReplenish(t *testing.T) { + e := newEnv(t) // runner not started: the replenish batch stays pending + ids := e.seedDeck(t, "Pho", "Ramen", "Laksa", "Bibimbap", "Katsu") + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_jobs BEFORE INSERT ON jobs BEGIN SELECT RAISE(ABORT, 'jobs refused'); END`); err != nil { + t.Fatal(err) + } + if _, err := e.svc.Decline(t.Context(), ids[0], member); err == nil { + t.Fatal("decline succeeded with no replenish job stored") + } + if got := e.deckNames(t); len(got) != 5 { + t.Errorf("deck after the failed decline = %v, want all 5 still pending", got) + } + if n := e.activeBatches(t); n != 0 { + t.Errorf("%d active batches after the failed decline, want 0", n) + } + + if _, err := e.st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_jobs`); err != nil { + t.Fatal(err) + } + d, err := e.svc.Decline(t.Context(), ids[0], member) + if err != nil { + t.Fatal(err) + } + if d.Suggestion.Status != suggest.StatusDeclined || d.ReplenishJobID == nil { + t.Fatalf("retried decline = %+v, want declined with a replenish job", d) + } + job, err := e.runner.Get(t.Context(), *d.ReplenishJobID) + if err != nil { + t.Fatal(err) + } + if job.Kind != suggest.KindSuggestionBatch || job.Status != jobs.StatusQueued { + t.Errorf("replenish job = %+v, want a queued %s", job, suggest.KindSuggestionBatch) + } + if n := e.activeBatches(t); n != 1 { + t.Errorf("%d active batches, want 1", n) + } +} + +func TestBatchFailsWhenAIFails(t *testing.T) { + e := newEnv(t) + e.start(t) + e.side.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")}) + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonAIFailed { + t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonAIFailed) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s, want failed", got) + } + if got := e.deckNames(t); len(got) != 0 { + t.Errorf("deck = %v after a failed batch, want empty", got) + } + // A failed batch does not block the next one. + next, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !next.Created || next.ID == b.ID { + t.Errorf("next batch = %+v, want a new one", next) + } +} + +func TestBatchFailsWhenSearchFails(t *testing.T) { + e := newEnv(t) + e.start(t) + e.search.Err = errors.New("search down") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonSearchFailed { + t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonSearchFailed) + } + if got := e.deckNames(t); len(got) != 0 { + t.Errorf("deck = %v, want empty", got) + } +} + +func TestBatchFailsWithNoRegulars(t *testing.T) { + e := newEnv(t) + e.start(t) + for _, id := range e.regulars { + if _, err := db.New(e.st.Writer).ArchiveDinner(t.Context(), db.ArchiveDinnerParams{ID: id, UpdatedAt: store.Now().String()}); err != nil { + t.Fatal(err) + } + } + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed || *job.Error != suggest.ReasonNoRegulars { + t.Fatalf("job = %s %v, want failed %s", job.Status, job.Error, suggest.ReasonNoRegulars) + } + if reqs := e.side.Requests(); len(reqs) != 0 { + t.Errorf("model called %d times with no Regulars", len(reqs)) + } +} + +func TestStartBatchFailsOrphanedBatch(t *testing.T) { + e := newEnv(t) + now := store.Now().String() + orphan, err := db.New(e.st.Writer).CreateSuggestionBatch(t.Context(), db.CreateSuggestionBatchParams{ID: store.NewID(), Trigger: "manual", CreatedAt: now, UpdatedAt: now}) + if err != nil { + t.Fatal(err) + } + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !b.Created || b.ID == orphan.ID { + t.Fatalf("batch = %+v, want a new one past the orphan", b) + } + if got := e.batchStatus(t, orphan.ID); got != suggest.BatchFailed { + t.Errorf("orphan status = %s, want failed", got) + } + // A batch whose job is still queued is not an orphan. + again, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if again.Created || again.ID != b.ID || e.batchStatus(t, b.ID) != suggest.BatchPending { + t.Errorf("second batch = %+v, status %s; want batch %s still pending", again, e.batchStatus(t, b.ID), b.ID) + } +} + +func TestBatchJobFailsWhenRecordingItsEndFails(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_done BEFORE UPDATE OF status ON suggestion_batches WHEN NEW.status = 'done' BEGIN SELECT RAISE(ABORT, 'done refused'); END`); err != nil { + t.Fatal(err) + } + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed { + t.Fatalf("job %s with its batch %s, want failed", job.Status, e.batchStatus(t, b.ID)) + } + + if _, err := e.st.Writer.ExecContext(t.Context(), `DROP TRIGGER refuse_done`); err != nil { + t.Fatal(err) + } + next, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !next.Created || next.ID == b.ID { + t.Errorf("next batch = %+v, want a new one", next) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s with its job failed, want failed", got) + } +} + +func TestBatchRecoversWhenRecordingItsEndAndItsJobsEndFail(t *testing.T) { + e := newEnv(t) + e.start(t) + e.recipe(t, "Chili Verde") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + for _, trigger := range []string{ + `CREATE TRIGGER refuse_done BEFORE UPDATE OF status ON suggestion_batches WHEN NEW.status = 'done' BEGIN SELECT RAISE(ABORT, 'done refused'); END`, + `CREATE TRIGGER refuse_job_end BEFORE UPDATE OF status ON jobs WHEN NEW.status IN ('succeeded', 'failed') BEGIN SELECT RAISE(ABORT, 'job end refused'); END`, + } { + if _, err := e.st.Writer.ExecContext(t.Context(), trigger); err != nil { + t.Fatal(err) + } + } + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for !strings.Contains(e.logs.String(), "record job failure") { + if time.Now().After(deadline) { + t.Fatalf("job end never recorded; batch %s", e.batchStatus(t, b.ID)) + } + time.Sleep(5 * time.Millisecond) + } + // While neither end is recorded the batch is still the active one. + again, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if again.Created || again.ID != b.ID { + t.Fatalf("batch = %+v while batch %s is running, want it", again, b.ID) + } + + // The database recovers; the service is not restarted. + for _, trigger := range []string{"refuse_done", "refuse_job_end"} { + if _, err := e.st.Writer.ExecContext(t.Context(), "DROP TRIGGER "+trigger); err != nil { + t.Fatal(err) + } + } + job := e.waitJob(t, b.JobID) + if job.Status != jobs.StatusFailed { + t.Fatalf("job %s, want failed", job.Status) + } + next, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !next.Created || next.ID == b.ID { + t.Errorf("next batch = %+v, want a new one", next) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("batch status = %s with its job failed, want failed", got) + } +} + +func TestBlockedBatchEndDoesNotHoldShutdown(t *testing.T) { + e := newEnv(t) + ctx, cancel := context.WithCancel(context.Background()) + if err := e.runner.Start(ctx); err != nil { + cancel() + t.Fatal(err) + } + stopped := make(chan struct{}) + t.Cleanup(func() { + cancel() + <-stopped + }) + e.search.Gate = make(chan struct{}) + e.search.Err = errors.New("search down") + e.reply(sideQuest("Chili Verde", "Your Beef Chili gone green.", "Beef Chili")) + + b, err := e.svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(10 * time.Second) + for len(e.search.Queries()) == 0 { + if time.Now().After(deadline) { + t.Fatal("batch never searched") + } + time.Sleep(5 * time.Millisecond) + } + + // Hold the only writer connection, so recording the batch's end + // waits for it once the search fails. + conn, err := e.st.Writer.Conn(t.Context()) + if err != nil { + t.Fatal(err) + } + waits := e.st.Writer.Stats().WaitCount + close(e.search.Gate) + for e.st.Writer.Stats().WaitCount == waits { + if time.Now().After(deadline) { + conn.Close() + t.Fatal("batch end never waited for the writer") + } + time.Sleep(5 * time.Millisecond) + } + + go func() { + e.runner.Wait() + close(stopped) + }() + cancel() + // Each final write's attempt ends after jobs.FinalWriteTimeout; well + // past that, the worker is waiting for the writer with no bound. + select { + case <-stopped: + case <-time.After(jobs.FinalWriteTimeout + 10*time.Second): + conn.Close() + <-stopped + t.Fatal("worker waited for a held writer through shutdown") + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchRunning { + t.Fatalf("after shutdown batch status = %s, want left running", got) + } + + // The next process fails the job as interrupted, and the batch no + // longer blocks the next one. + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + next, err := jobs.NewRunner(e.st, logger, 1) + if err != nil { + t.Fatal(err) + } + refuse := func(context.Context, string, skills.SideQuestsRequest) (skills.SideQuests, error) { + return skills.SideQuests{}, errors.New("not wanted") + } + svc, err := suggest.NewService(e.st, e.grounder, refuse, next, logger) + if err != nil { + t.Fatal(err) + } + nextCtx, nextCancel := context.WithCancel(context.Background()) + if err := next.Start(nextCtx); err != nil { + nextCancel() + t.Fatal(err) + } + t.Cleanup(func() { + nextCancel() + next.Wait() + }) + job, err := next.Get(t.Context(), b.JobID) + if err != nil { + t.Fatal(err) + } + if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInterrupted { + t.Fatalf("interrupted job = %+v, want failed with %q", job, jobs.CodeInterrupted) + } + again, err := svc.StartBatch(t.Context(), suggest.TriggerManual) + if err != nil { + t.Fatal(err) + } + if !again.Created || again.ID == b.ID { + t.Errorf("next batch = %+v, want a new one", again) + } + if got := e.batchStatus(t, b.ID); got != suggest.BatchFailed { + t.Errorf("interrupted batch status = %s, want failed", got) + } +} + +func TestStartBatchRefusesUnknownTrigger(t *testing.T) { + e := newEnv(t) + if _, err := e.svc.StartBatch(t.Context(), "whenever"); err == nil { + t.Fatal("unknown trigger accepted") + } +} diff --git a/migrations/0012_suggestion_name.sql b/migrations/0012_suggestion_name.sql new file mode 100644 index 0000000..d54cc3b --- /dev/null +++ b/migrations/0012_suggestion_name.sql @@ -0,0 +1,37 @@ +-- 0012_suggestion_name: a suggestion keeps the name its dish was suggested +-- under. Its dinner can be renamed, even once archived by a Pass, so the +-- declined names every later batch refuses are read from here, never from +-- the dinner. A suggestion stored before this takes its dinner's name now. +-- SQLite cannot add a NOT NULL column without a default, so the table is +-- rebuilt as 0005 does: copy the rows aside, drop the table, create it +-- again, copy the rows back and drop the copy. Nothing references +-- suggestions. + +CREATE TABLE suggestions_copy AS SELECT * FROM suggestions; + +DROP TABLE suggestions; + +CREATE TABLE suggestions ( + id TEXT NOT NULL PRIMARY KEY, + dinner_id TEXT NOT NULL REFERENCES dinners (id), + batch_id TEXT NOT NULL REFERENCES suggestion_batches (id), + name TEXT NOT NULL CHECK (name <> ''), + reason TEXT NOT NULL, + cited_regular_ids TEXT NOT NULL, -- JSON array of dinner ids + status TEXT NOT NULL CHECK (status IN ('pending', 'accepted', 'declined')), + decided_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +INSERT INTO suggestions ( + id, dinner_id, batch_id, name, reason, cited_regular_ids, status, decided_at, created_at, updated_at +) +SELECT + suggestions_copy.id, suggestions_copy.dinner_id, suggestions_copy.batch_id, dinners.name, + suggestions_copy.reason, suggestions_copy.cited_regular_ids, suggestions_copy.status, + suggestions_copy.decided_at, suggestions_copy.created_at, suggestions_copy.updated_at +FROM suggestions_copy +JOIN dinners ON dinners.id = suggestions_copy.dinner_id; + +DROP TABLE suggestions_copy; diff --git a/migrations/queries/dinners.sql b/migrations/queries/dinners.sql index d2cf5d9..98795bf 100644 --- a/migrations/queries/dinners.sql +++ b/migrations/queries/dinners.sql @@ -19,6 +19,19 @@ SELECT * FROM dinners WHERE status = ? ORDER BY normalized_name, id; +-- name: ListUnplannedQuestLogDinners :many +-- The Quest log: dinners with status quest_log that no plan for the week +-- starting from_week_start or later has on any day. +SELECT * FROM dinners +WHERE status = 'quest_log' + AND NOT EXISTS ( + SELECT 1 FROM plan_days + JOIN week_plans ON week_plans.id = plan_days.plan_id + WHERE plan_days.dinner_id = dinners.id + AND week_plans.week_start >= sqlc.arg(from_week_start) + ) +ORDER BY normalized_name, id; + -- name: CountRegularDinners :one -- How many Regulars the household has; archived dinners are not counted. SELECT COUNT(*) FROM dinners diff --git a/migrations/queries/household_context.sql b/migrations/queries/household_context.sql index 37ff2d7..fbea66d 100644 --- a/migrations/queries/household_context.sql +++ b/migrations/queries/household_context.sql @@ -1,12 +1,12 @@ -- Reads for the AI household context block (internal/ai/harness). -- name: ListRecentDeclinedNames :many --- The names of the most recently declined suggestions, newest first. -SELECT dinners.name +-- The names the most recently declined suggestions were suggested under, +-- newest first; renaming their dinners does not change them. +SELECT name FROM suggestions -JOIN dinners ON dinners.id = suggestions.dinner_id -WHERE suggestions.status = 'declined' -ORDER BY suggestions.decided_at DESC, suggestions.id DESC +WHERE status = 'declined' +ORDER BY decided_at DESC, id DESC LIMIT ?; -- name: ListPlannedDinners :many diff --git a/migrations/queries/suggestions.sql b/migrations/queries/suggestions.sql new file mode 100644 index 0000000..2d81299 --- /dev/null +++ b/migrations/queries/suggestions.sql @@ -0,0 +1,110 @@ +-- name: CreateSuggestionBatch :one +INSERT INTO suggestion_batches (id, trigger, status, created_at, updated_at) +VALUES (?, ?, 'pending', ?, ?) +RETURNING *; + +-- name: GetActiveSuggestionBatch :one +-- The batch still pending or running, oldest first. Returns sql.ErrNoRows +-- when there is none. +SELECT * FROM suggestion_batches +WHERE status IN ('pending', 'running') +ORDER BY id +LIMIT 1; + +-- name: GetLatestSuggestionBatch :one +-- The newest batch, whatever its status. Returns sql.ErrNoRows when there +-- is none. +SELECT * FROM suggestion_batches +ORDER BY id DESC +LIMIT 1; + +-- name: GetSuggestionBatchJobID :one +-- The id of the job that runs batch batch_id. +SELECT id FROM jobs +WHERE kind = sqlc.arg(kind) + AND json_extract(payload, '$.batch_id') = sqlc.arg(batch_id) +ORDER BY id +LIMIT 1; + +-- name: StartSuggestionBatch :execrows +-- Marks a pending batch running; a batch in any other status is left as +-- it is (0 rows). +UPDATE suggestion_batches +SET status = 'running', updated_at = ? +WHERE id = ? AND status = 'pending'; + +-- name: FinishSuggestionBatch :execrows +-- Records a pending or running batch as done or failed; a batch already +-- finished is left as it is (0 rows). +UPDATE suggestion_batches +SET status = ?, updated_at = ? +WHERE id = ? AND status IN ('pending', 'running'); + +-- name: FailOrphanedSuggestionBatches :execrows +-- Fails every batch still pending or running whose job is no longer queued +-- or running: the process stopped, or recording the batch's end failed, +-- before its handler recorded a result. Run before reading the active batch. +UPDATE suggestion_batches +SET status = 'failed', updated_at = sqlc.arg(updated_at) +WHERE status IN ('pending', 'running') + AND NOT EXISTS ( + SELECT 1 FROM jobs + WHERE jobs.kind = sqlc.arg(kind) + AND jobs.status IN ('queued', 'running') + AND json_extract(jobs.payload, '$.batch_id') = suggestion_batches.id + ); + +-- name: CreateSuggestion :exec +-- name is the name the dish is suggested under, kept whatever its dinner is +-- later renamed. +INSERT INTO suggestions ( + id, dinner_id, batch_id, name, reason, cited_regular_ids, status, created_at, updated_at +) VALUES ( + ?, ?, ?, ?, ?, ?, 'pending', ?, ? +); + +-- name: GetSuggestion :one +-- A suggestion with its dinner. Returns sql.ErrNoRows for an unknown id. +SELECT sqlc.embed(suggestions), sqlc.embed(dinners) +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.id = ?; + +-- name: ListDeck :many +-- The pending suggestions whose dinner is still suggested, oldest first, +-- with their dinners. +SELECT sqlc.embed(suggestions), sqlc.embed(dinners) +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'pending' AND dinners.status = 'suggested' +ORDER BY suggestions.created_at, suggestions.id; + +-- name: CountDeck :one +-- How many suggestions ListDeck returns. +SELECT COUNT(*) +FROM suggestions +JOIN dinners ON dinners.id = suggestions.dinner_id +WHERE suggestions.status = 'pending' AND dinners.status = 'suggested'; + +-- name: ListDeclinedSuggestionNames :many +-- The name every declined suggestion was suggested under, oldest decision +-- first; renaming its dinner does not change it. +SELECT name +FROM suggestions +WHERE status = 'declined' +ORDER BY decided_at, id; + +-- name: DecideSuggestion :execrows +-- Records the household's decision on a pending suggestion; one already +-- decided is left as it is (0 rows). +UPDATE suggestions +SET status = sqlc.arg(status), decided_at = sqlc.arg(now), updated_at = sqlc.arg(now) +WHERE id = sqlc.arg(id) AND status = 'pending'; + +-- name: MoveSuggestedDinner :execrows +-- Moves a dinner still suggested to status (quest_log or archived), +-- recording updated_by; a dinner no longer suggested is left as it is +-- (0 rows). +UPDATE dinners +SET status = sqlc.arg(status), updated_by = sqlc.arg(updated_by), updated_at = sqlc.arg(updated_at) +WHERE id = sqlc.arg(id) AND status = 'suggested'; diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx index 3e25759..1d2a4d9 100644 --- a/web/src/App.test.tsx +++ b/web/src/App.test.tsx @@ -30,6 +30,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) { // The routes that render a built screen rather than a placeholder page. const SCREENS = { '/onboarding': true, + '/discover': true, '/settings': true, '/settings/ai': true, '/settings/household': true, @@ -102,6 +103,19 @@ describe('App routes', () => { expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument() }) + it('/discover renders the Side quests deck inside the shell', async () => { + mockViewport(390) + stubFetchByPath({ + ...SIGNED_IN, + '/api/v1/suggestions/deck': () => jsonResponse(200, { suggestions: [], active_batch: null, failed_batch: null }), + '/api/v1/dinners?status=quest_log': () => jsonResponse(200, { dinners: [] }), + }) + renderAt('/discover') + const heading = await screen.findByRole('heading', { level: 1, name: 'Side quests' }) + expect(screen.getByRole('main')).toContainElement(heading) + expect(await screen.findByText('Quest log · 0')).toBeInTheDocument() + }) + it('/settings/household renders the household settings screen inside the shell', async () => { mockViewport(390) stubFetchByPath(SIGNED_IN) diff --git a/web/src/App.tsx b/web/src/App.tsx index 1becaca..4a2d297 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,6 +3,7 @@ import { QueryClientProvider } from '@tanstack/react-query' import { Route, Routes } from 'react-router' import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts' import { AuthBootstrap } from './AuthBootstrap.tsx' +import { DiscoverPage } from './discover/DiscoverPage.tsx' import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx' import { OnboardingPage } from './onboarding/OnboardingPage.tsx' import { PlaceholderPage } from './pages/PlaceholderPage.tsx' @@ -35,6 +36,7 @@ export const routes = [ // The screens built so far; every other route shows its placeholder page. const screens: Partial> = { + '/discover': , '/settings': , '/settings/ai': , '/settings/household': , diff --git a/web/src/api/useJob.test.tsx b/web/src/api/useJob.test.tsx index 21d1359..6c114f8 100644 --- a/web/src/api/useJob.test.tsx +++ b/web/src/api/useJob.test.tsx @@ -14,7 +14,7 @@ function job(status: string, result: Draft | null, error: string | null = null) function renderJob(jobId: string | null) { const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage)) const wrapper = ({ children }: { children: ReactNode }) => {children} - return renderHook(({ id }) => useJob(id), { wrapper, initialProps: { id: jobId } }) + return renderHook(({ id }) => useJob('plan.draft', id), { wrapper, initialProps: { id: jobId } }) } // advance runs the timers due in ms and lets the polls they start answer. @@ -42,7 +42,7 @@ describe('useJob', () => { job('succeeded', { dinners: ['Tacos', 'Chili', 'Soup'] }), ) const { result } = renderJob('j1') - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) await advance(0) expect(result.current.status).toBe('queued') @@ -51,7 +51,7 @@ describe('useJob', () => { await advance(499) expect(fetchMock).toHaveBeenCalledTimes(1) await advance(1) - expect(result.current).toEqual({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) await advance(999) expect(fetchMock).toHaveBeenCalledTimes(2) @@ -62,7 +62,7 @@ describe('useJob', () => { expect(fetchMock).toHaveBeenCalledTimes(4) await advance(2000) expect(fetchMock).toHaveBeenCalledTimes(5) - expect(result.current).toEqual({ + expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili', 'Soup'] }, error: null, @@ -106,10 +106,76 @@ describe('useJob', () => { expect(fetchMock).toHaveBeenCalledTimes(2) await advance(1) expect(fetchMock).toHaveBeenCalledTimes(3) - expect(result.current).toEqual({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) + expect(result.current).toMatchObject({ status: 'succeeded', result: { dinners: ['Tacos', 'Chili'] }, error: null }) expect(vi.getTimerCount()).toBe(0) }) + it.each([ + ['an unknown status', { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }], + ['another job', { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }], + ['another kind', { id: 'j1', kind: 'suggestion_batch', status: 'running', result: null, error: null }], + ['no result', { id: 'j1', kind: 'plan.draft', status: 'running', error: null }], + ['an error that is not text', { id: 'j1', kind: 'plan.draft', status: 'failed', result: null, error: 500 }], + ['not a job', ['j1']], + ])('exposes an answer with %s as the error and stops, keeping the last status', async (_, body) => { + const fetchMock = stubFetch(job('running', { dinners: ['Tacos'] }), jsonResponse(200, body)) + const { result } = renderJob('j1') + + await advance(0) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] } }) + expect(result.current.error?.message).toBe('GET /api/v1/jobs/j1: response is not the plan.draft job j1') + expect(vi.getTimerCount()).toBe(0) + await advance(10_000) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) + + it('polls again at once on poll after stopping on an answer that is not the job, then keeps polling', async () => { + const fetchMock = stubFetch( + jsonResponse(200, { id: 'j1', kind: 'plan.draft', status: 'stuck', result: null, error: null }), + job('running', { dinners: ['Tacos'] }), + job('succeeded', { dinners: ['Tacos', 'Chili'] }), + ) + const { result } = renderJob('j1') + await advance(0) + expect(result.current.error).not.toBeNull() + expect(vi.getTimerCount()).toBe(0) + + act(() => result.current.poll()) + await advance(0) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(3) + expect(result.current).toMatchObject({ status: 'succeeded', error: null }) + }) + + it('polls at once on poll during the backoff after a failed poll, starting the backoff again', async () => { + const fetchMock = stubFetch( + job('running', null), + errorResponse(503, 'unavailable', true), + errorResponse(503, 'unavailable', true), + job('running', { dinners: ['Tacos'] }), + job('succeeded', { dinners: ['Tacos'] }), + ) + const { result } = renderJob('j1') + await advance(0) + await advance(500) + await advance(1000) + expect(fetchMock).toHaveBeenCalledTimes(3) + + // The next poll was due 2 s later; poll asks now. + act(() => result.current.poll()) + await advance(0) + expect(fetchMock).toHaveBeenCalledTimes(4) + expect(result.current).toMatchObject({ status: 'running', result: { dinners: ['Tacos'] }, error: null }) + expect(vi.getTimerCount()).toBe(1) + await advance(500) + expect(fetchMock).toHaveBeenCalledTimes(5) + expect(result.current.status).toBe('succeeded') + }) + it('stops polling on unmount after a failed poll', async () => { const fetchMock = stubFetch(errorResponse(503, 'unavailable', true)) const { result, unmount } = renderJob('j1') @@ -125,12 +191,15 @@ describe('useJob', () => { }) it('stops polling the old job when the job id changes', async () => { - const fetchMock = stubFetch(job('running', null), job('running', null)) + const fetchMock = stubFetch( + job('running', null), + jsonResponse(200, { id: 'j2', kind: 'plan.draft', status: 'running', result: null, error: null }), + ) const { result, rerender } = renderJob('j1') await advance(0) rerender({ id: 'j2' }) - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) await advance(0) expect(request(fetchMock, 1).path).toBe('/api/v1/jobs/j2') expect(vi.getTimerCount()).toBe(1) @@ -165,6 +234,6 @@ describe('useJob', () => { const { result } = renderJob(null) await advance(5000) expect(fetchMock).not.toHaveBeenCalled() - expect(result.current).toEqual({ status: undefined, result: null, error: null }) + expect(result.current).toMatchObject({ status: undefined, result: null, error: null }) }) }) diff --git a/web/src/api/useJob.ts b/web/src/api/useJob.ts index 2a26c2c..b918742 100644 --- a/web/src/api/useJob.ts +++ b/web/src/api/useJob.ts @@ -1,8 +1,11 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useApi } from './ApiContext.tsx' +import { isRecord } from './errors.ts' export type JobStatus = 'queued' | 'running' | 'succeeded' | 'failed' +const STATUSES: readonly string[] = ['queued', 'running', 'succeeded', 'failed'] satisfies JobStatus[] + // Job is the GET /api/v1/jobs/{id} response. result may be set while the job // is running (a partial result); error is set only when it failed. export type Job = { @@ -18,28 +21,56 @@ export type JobState = { status: JobStatus | undefined // The latest result, partial while the job runs. result: R | null - // The job's failure, or the latest polling request's own failure (cleared - // by the next poll that answers). + // The job's failure, the latest polling request's own failure (cleared + // by the next poll that answers), or an answer that is not this job. error: Error | null + // poll asks for the job now, starting the backoff again — also after + // polling stopped on an answer that is not this job. + poll: () => void } // Polling starts at FIRST_DELAY_MS and doubles up to MAX_DELAY_MS. export const FIRST_DELAY_MS = 500 export const MAX_DELAY_MS = 2000 +type JobData = Omit, 'poll'> + const idle = { status: undefined, result: null, error: null } +// parseJob checks a GET /api/v1/jobs/{id} answer is the kind of job asked +// for, with a known status. result is passed on unread for the caller to +// check. +function parseJob(where: string, kind: string, jobId: string, body: unknown): Job { + if ( + !isRecord(body) || + body.id !== jobId || + body.kind !== kind || + typeof body.status !== 'string' || + !STATUSES.includes(body.status) || + !('result' in body) || + (body.error !== null && typeof body.error !== 'string') + ) { + throw new Error(`${where}: response is not the ${kind} job ${jobId}`) + } + return { id: jobId, kind, status: body.status as JobStatus, result: body.result as R | null, error: body.error as string | null } +} + function isTerminal(status: JobStatus): boolean { return status === 'succeeded' || status === 'failed' } -// useJob polls GET /api/v1/jobs/{jobId} until the job succeeds or fails, -// backing off from 500 ms to 2 s. A failed poll is exposed as the error and -// polling continues. Polling stops only at a terminal status, when jobId -// changes and on unmount. A null jobId polls nothing. -export function useJob(jobId: string | null): JobState { +// useJob polls GET /api/v1/jobs/{jobId}, a job of the given kind, until it +// succeeds or fails, backing off from 500 ms to 2 s. A failed poll is +// exposed as the error and polling continues. An answer that is not this +// job is exposed as the error and stops polling until poll is called. +// Polling also stops at a terminal status, when jobId changes and on +// unmount. A null jobId polls nothing. +export function useJob(kind: string, jobId: string | null): JobState { const { apiFetch } = useApi() - const [state, setState] = useState<{ jobId: string | null; job: JobState }>({ jobId, job: idle }) + const [state, setState] = useState<{ jobId: string | null; job: JobData }>({ jobId, job: idle }) + // Each poll call bumps round, which restarts the polling effect. + const [round, setRound] = useState(0) + const poll = useCallback(() => setRound((r) => r + 1), []) useEffect(() => { if (jobId === null) return @@ -47,37 +78,51 @@ export function useJob(jobId: string | null): JobState { let timer: ReturnType | undefined let delay = FIRST_DELAY_MS - function next() { - timer = setTimeout(poll, delay) - delay = Math.min(delay * 2, MAX_DELAY_MS) - } - - async function poll() { + async function fetchJob() { + const path = `/api/v1/jobs/${encodeURIComponent(jobId!)}` + let body: unknown try { - const job = await apiFetch>(`/api/v1/jobs/${encodeURIComponent(jobId!)}`) - if (!active) return - const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null - setState({ jobId, job: { status: job.status, result: job.result, error } }) - if (isTerminal(job.status)) return + body = await apiFetch(path) } catch (error) { if (!active) return // A failed poll is not the job's end: keep the last known status and // result, expose the request's error, and poll again. - setState((prev) => ({ - jobId, - job: { ...(prev.jobId === jobId ? prev.job : idle), error: error instanceof Error ? error : new Error(String(error)) }, - })) + fail(error instanceof Error ? error : new Error(String(error))) + next() + return } - next() + if (!active) return + let job: Job + try { + job = parseJob(`GET ${path}`, kind, jobId!, body) + } catch (error) { + // An answer that is not this job will not become one by asking + // again: expose it and stop. + fail(error as Error) + return + } + const error = job.status === 'failed' ? new Error(job.error ?? `job ${job.id} failed without an error message`) : null + setState({ jobId, job: { status: job.status, result: job.result, error } }) + if (!isTerminal(job.status)) next() } - void poll() + // fail keeps the last known status and result, with error. + function fail(error: Error) { + setState((prev) => ({ jobId, job: { ...(prev.jobId === jobId ? prev.job : idle), error } })) + } + + function next() { + timer = setTimeout(fetchJob, delay) + delay = Math.min(delay * 2, MAX_DELAY_MS) + } + + void fetchJob() return () => { active = false clearTimeout(timer) } - }, [apiFetch, jobId]) + }, [apiFetch, kind, jobId, round]) // State left from a previous jobId is not this job's. - return state.jobId === jobId ? state.job : idle + return { ...(state.jobId === jobId ? state.job : idle), poll } } diff --git a/web/src/dinners/api.ts b/web/src/dinners/api.ts index 657d1de..06e040c 100644 --- a/web/src/dinners/api.ts +++ b/web/src/dinners/api.ts @@ -79,7 +79,7 @@ export const REGULARS_KEY = ['dinners', 'regular'] as const const REGULARS_LIST_KEY = [...REGULARS_KEY, 'list'] as const const REGULARS_MUTATION_KEY = ['dinners', 'regular', 'change'] as const -function oneOf(where: string, field: string, values: readonly T[], value: unknown): T { +export function oneOf(where: string, field: string, values: readonly T[], value: unknown): T { if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) { throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not one of ${values.join(', ')}`) } @@ -99,14 +99,15 @@ const PHOTO_URL = /^\/media\/photos\/([^/]+)-400\.jpg$/ // photoId reads the photo id out of a photo_url, which the server always // sends as /media/photos/{id}-400.jpg or null. -function photoId(where: string, value: unknown): string | undefined { +export function photoId(where: string, value: unknown): string | undefined { if (value === null) return undefined const match = typeof value === 'string' ? PHOTO_URL.exec(value) : null if (!match?.[1]) throw new Error(`${where}: photo_url ${JSON.stringify(value)} is not a stored photo`) return decodeURIComponent(match[1]) } -function parseDinner(where: string, body: unknown): Regular { +// parseDinner checks a dinner as the dinners API sends it. +export function parseDinner(where: string, body: unknown): Regular { if (!isRecord(body) || typeof body.id !== 'string' || typeof body.name !== 'string') { throw new Error(`${where}: response is not a dinner`) } @@ -141,7 +142,7 @@ function parseCandidate(where: string, body: unknown): RecipeCandidate { } } -function dinnerPath(id: string): string { +export function dinnerPath(id: string): string { return `${DINNERS_PATH}/${encodeURIComponent(id)}` } diff --git a/web/src/discover/DeckCard.tsx b/web/src/discover/DeckCard.tsx new file mode 100644 index 0000000..26758be --- /dev/null +++ b/web/src/discover/DeckCard.tsx @@ -0,0 +1,75 @@ +import type { DOMAttributes } from 'react' +import { DIFFICULTIES, type Difficulty } from '../dinners/api.ts' +import { Photo } from '../ui/Photo.tsx' +import type { SideQuest } from './api.ts' +import styles from './DiscoverPage.module.css' + +// DECK_DEPTH is the depths the deck shows, top card first. +export const DECK_DEPTH = [0, 1, 2] as const + +export const DIFFICULTY_LABEL: Record = { easy: 'Easy', medium: 'Medium', hard: 'Hard' } + +// SwipeHandlers are the pointer handlers that drag the top card. +export type SwipeHandlers = Pick< + DOMAttributes, + 'onPointerDown' | 'onPointerMove' | 'onPointerUp' | 'onPointerCancel' | 'onLostPointerCapture' +> + +export type DeckCardProps = { + quest: SideQuest + // 0 is the top card; 1 and 2 stack behind it. + depth: number + // swipe makes the card draggable; only the top card gets it. + swipe?: SwipeHandlers +} + +// DeckCard is one Side quest in the deck. Only the top card is read out and +// reachable; the cards behind it are the stack's edge, kept rendered so they +// can be promoted. Its "Try it!" and "Pass" stamps stay hidden until a +// decision or a drag shows them, and its shade until Pass folds it (see +// motion.ts). +export function DeckCard({ quest, depth, swipe }: DeckCardProps) { + const top = depth === 0 + const titleId = `side-quest-${quest.id}` + return ( +
+ +
+

+ {quest.name} +

+ {quest.difficulty !== null && } +

+ Why this? {quest.reason} +

+
+ + + +
+ ) +} + +function DifficultyDots({ difficulty }: { difficulty: Difficulty }) { + const level = DIFFICULTIES.indexOf(difficulty) + 1 + return ( +

+

+ ) +} diff --git a/web/src/discover/DiscoverPage.module.css b/web/src/discover/DiscoverPage.module.css new file mode 100644 index 0000000..d3d53ef --- /dev/null +++ b/web/src/discover/DiscoverPage.module.css @@ -0,0 +1,249 @@ +/* Discover · Side quests (mock 1a). */ +.page { + box-sizing: border-box; + max-width: 440px; + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 8px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + justify-content: space-between; + gap: 16px; +} + +.kicker { + margin: 0; + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-accent-700); +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 30px; + font-weight: 400; + line-height: 1.1; +} + +/* The Quest log count pill, in a 44 px tall hit box. */ +.questLogButton { + flex: none; + display: inline-flex; + align-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + border: 0; + border-radius: var(--radius-pill); + background: transparent; + cursor: pointer; +} + +.questLog { + flex: none; + padding: 7px 12px; + border-radius: var(--radius-pill); + background: var(--color-accent-2-200); + color: var(--color-accent-2-800); + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; +} + +/* The deck: three cards stacked in the same box, the top one in front. */ +.deck { + position: relative; + height: 440px; + perspective: 1000px; +} + +.card { + position: absolute; + inset: 0; + box-sizing: border-box; + display: flex; + flex-direction: column; + border-radius: var(--radius-card); + overflow: hidden; + color: var(--color-text); +} + +.depth0 { + z-index: 3; + transform: none; + background: var(--color-neutral-100); + box-shadow: var(--shadow-lg); +} + +/* The top card follows a drag instead of scrolling the page. */ +.draggable { + touch-action: none; + cursor: grab; +} + +.depth1 { + z-index: 2; + transform: translateY(8px) scale(0.95); + background: var(--color-surface); +} + +.depth2 { + z-index: 1; + transform: translateY(16px) scale(0.9); + background: var(--color-neutral-300); +} + +.photo { + flex: none; + width: calc(100% - 20px); + height: 220px; +} + +.content { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 20px 0; +} + +.name { + margin: 0; + font-family: var(--font-heading); + font-size: 24px; + font-weight: 400; + line-height: 1.1; +} + +.difficulty { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: var(--font-body); + font-size: 12px; + font-weight: 700; + color: var(--color-neutral-700); +} + +.dots { + display: flex; + gap: 3px; +} + +.dot, +.dotFilled { + width: 8px; + height: 8px; + border-radius: var(--radius-pill); + background: var(--color-neutral-300); +} + +.dotFilled { + background: var(--color-accent); +} + +/* The "Why this?" box. */ +.why { + margin: 0; + padding: 12px 14px; + border-radius: 18px; + background: var(--color-accent-2-100); + color: var(--color-accent-2-900); + font-family: var(--font-body); + font-size: 13px; + line-height: 1.45; +} + +/* The decision stamps, hidden until a decision shows them. */ +.stamp { + position: absolute; + top: 26px; + padding: 6px 14px; + border: 3px solid; + border-radius: 14px; + font-family: var(--font-heading); + font-size: 24px; + opacity: 0; + pointer-events: none; +} + +.tryStamp { + left: 22px; + transform: rotate(-12deg); + border-color: var(--color-accent-2-700); + background: var(--color-accent-2-100); + color: var(--color-accent-2-800); +} + +.passStamp { + right: 22px; + transform: rotate(12deg); + border-color: var(--color-neutral-800); + background: var(--color-neutral-100); + color: var(--color-neutral-900); +} + +/* The shade Pass darkens the card with as it folds. */ +.shade { + position: absolute; + inset: 0; + border-radius: inherit; + background: var(--color-text); + opacity: 0; + pointer-events: none; +} + +/* Pass and Try it: IconButtons with their 11 px labels shown. */ +.actions { + display: flex; + justify-content: center; + gap: 28px; + margin-top: 4px; +} + +/* Skeleton cards while the deck loads or a batch finds Side quests. */ +.loading { + display: flex; + flex-direction: column; + gap: 22px; +} + +.deck > .skeletonCard { + position: absolute; + inset: 0; + height: auto; + border-radius: var(--radius-card); +} + +/* A blank card holds a depth a short deck has no card for, as the stack's + edge, in that depth's place and colour. */ +.blankCard { + position: absolute; + inset: 0; + border-radius: var(--radius-card); +} + +.skeletonCard.depth0, +.skeletonCard.depth1, +.skeletonCard.depth2 { + background: var(--color-surface); + box-shadow: none; +} + +.finding { + margin: 0; + text-align: center; + font-family: var(--font-body); + font-size: 14px; + font-weight: 700; + color: var(--color-neutral-700); +} diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx new file mode 100644 index 0000000..dec24cd --- /dev/null +++ b/web/src/discover/DiscoverPage.test.tsx @@ -0,0 +1,2364 @@ +import { QueryClientProvider } from '@tanstack/react-query' +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { createMemoryRouter, RouterProvider, useParams } from 'react-router' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi, type Mock } from 'vitest' +import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' +import { createNetworkStatus } from '../pwa/networkStatus.ts' +import { stubAnimate, type FakeAnimation } from '../test/animate.ts' +import { declaredStyle } from '../test/cssRules.ts' +import { dinnerBody, type DinnerBody } from '../test/dinners.ts' +import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts' +import { expectMinHitArea } from '../test/hitArea.ts' +import { mockViewport } from '../test/matchMedia.ts' +import { DECK_KEY } from './api.ts' +import { DiscoverPage } from './DiscoverPage.tsx' + +const DECK = '/api/v1/suggestions/deck' +const QUEST_LOG = '/api/v1/dinners?status=quest_log' + +// questLogBody is a Quest-log dinner as GET /api/v1/dinners?status=quest_log +// sends it. +function questLogBody(id: string, name: string, overrides: Partial = {}) { + return dinnerBody(id, name, { status: 'quest_log', origin: 'side_quest', ...overrides }) +} + +// SuggestionBody is a suggestion as GET /api/v1/suggestions/deck sends it. +function suggestionBody(id: string, name: string, overrides: Record = {}) { + return { + id, + dinner_id: `dinner-${id}`, + name, + photo_url: null, + difficulty: 'easy', + method: 'stovetop', + minutes_total: 25, + cuisine: 'Korean', + reason: 'You make Chicken stir-fry and Beef tacos most weeks. Same flavors, sweet-savory twist.', + cited_regular_ids: ['r1', 'r2'], + status: 'pending', + dinner_status: 'suggested', + decided_at: null, + created_at: '2026-10-04T08:00:00.000Z', + ...overrides, + } +} + +const BOWLS = suggestionBody('s1', 'Korean beef bowls', { photo_url: '/media/photos/p1-400.jpg' }) +const TIKKA = suggestionBody('s2', 'Crock pot chicken tikka masala', { difficulty: 'medium', reason: 'Butter chicken night is a favorite.' }) +const ORZO = suggestionBody('s3', 'Lemon garlic shrimp orzo', { difficulty: 'hard' }) + +type Responder = () => Response | Promise + +// stubServer stands in for the API: GET of the deck answers the current +// server.deck, server.activeBatch and server.failedBatch, and the Quest log the current +// server.questLog dinners; every +// other request is answered by responders, keyed "METHOD path". Any other +// request fails the test. +function stubServer(deck: unknown[], responders: Record = {}, questLog = 6) { + const server = { + deck, + questLog, + activeBatch: null as { batch_id: string; job_id: string } | null, + failedBatch: null as { batch_id: string; job_id: string } | null, + fetchMock: undefined as unknown as Mock, + } + const all: Record = { + 'GET /api/v1/me': () => jsonResponse(200, ME), + [`GET ${DECK}`]: () => jsonResponse(200, { suggestions: server.deck, active_batch: server.activeBatch, failed_batch: server.failedBatch }), + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { dinners: Array.from({ length: server.questLog }, (_, i) => questLogBody(`q${i}`, `Quest ${i}`)) }), + ...responders, + } + server.fetchMock = vi.fn((input, init) => { + const key = `${init?.method ?? 'GET'} ${String(input)}` + const respond = Object.hasOwn(all, key) ? all[key] : undefined + if (respond === undefined) throw new Error(`unexpected fetch: ${key}`) + return Promise.resolve(respond()) + }) + vi.stubGlobal('fetch', server.fetchMock) + return server +} + +// sent lists the requests other than GETs, as "METHOD path". +function sent(fetchMock: Mock) { + return fetchMock.mock.calls + .filter(([, init]) => (init?.method ?? 'GET') !== 'GET') + .map(([input, init]) => `${init?.method} ${String(input)}`) +} + +// deckReads counts the GET /api/v1/suggestions/deck requests. +function deckReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === DECK).length +} + +// jobPolls counts the GET /api/v1/jobs/j1 requests. +function jobPolls(fetchMock: Mock) { + return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === '/api/v1/jobs/j1').length +} + +// batchJob is the suggestion batch job j as GET /api/v1/jobs/{id} answers +// it, having stored ids. +function batchJob(j: string, status: string, ids: string[] = [], error: string | null = null) { + const result = status === 'failed' ? null : { batch_id: `b-${j}`, stage: status === 'succeeded' ? 'done' : 'grounding', suggestion_ids: ids, dropped: 0 } + return { id: j, kind: 'suggestion_batch', status, result, error } +} + +// Every animation finishes as it starts unless a test stubs animate again. +beforeEach(() => { + mockViewport(390) + stubAnimate({ autoFinish: true }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(Element.prototype, 'animate') + Reflect.deleteProperty(window, 'matchMedia') +}) + +// finishRunning finishes the animations running now, then lets what they +// start begin. +async function finishRunning(animations: FakeAnimation[]) { + await act(async () => { + animations.filter((a) => a.playState === 'running').forEach((a) => a.finish()) + await Promise.resolve() + }) +} + +// DinnerProbe stands in for the dinner screen, showing the id it opened. +function DinnerProbe() { + return

Dinner screen for {useParams().id}

+} + +// renderPage loads /me (for the CSRF token every write needs) and renders +// the Discover page with queryClient at /discover, with /dinner/:id beside it. +async function renderPage(queryClient = createQueryClient()) { + const api = createApiClient(fakeLocation('/discover'), createNetworkStatus(window), createVerifiedSession(localStorage)) + await api.loadMe() + const router = createMemoryRouter( + [ + { path: '/discover', element: }, + { path: '/dinner/:id', element: }, + ], + { initialEntries: ['/discover'] }, + ) + return render( + + + + + , + ) +} + +function deferred() { + let resolve!: (response: Response) => void + const promise = new Promise((r) => (resolve = r)) + return { promise, resolve } +} + +describe('DiscoverPage', () => { + it('renders mock 1a from the deck: header, Quest log pill and three stacked cards', async () => { + stubServer([BOWLS, TIKKA, ORZO]) + const { container } = await renderPage() + + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(screen.getByText('Discover')).toBeInTheDocument() + expect(screen.getByRole('heading', { level: 1, name: 'Side quests' })).toBeInTheDocument() + const pill = await screen.findByText('Quest log · 6') + expect(declaredStyle(pill).background).toBe('var(--color-accent-2-200)') + expect(declaredStyle(pill).color).toBe('var(--color-accent-2-800)') + + // The deck: 440 px, perspective 1000px, three cards stacked by depth. + const cards = container.querySelectorAll('article') + expect(cards).toHaveLength(3) + const deck = cards[0]!.parentElement! + expect(getComputedStyle(deck).height).toBe('440px') + expect(getComputedStyle(deck).perspective).toBe('1000px') + const [top, second, third] = [...cards].map((c) => declaredStyle(c)) + expect(top!.transform).toBe('none') + expect(top!.background).toBe('var(--color-neutral-100)') + expect(top!.boxShadow).toBe('var(--shadow-lg)') + expect(second!.transform).toBe('translateY(8px) scale(0.95)') + expect(second!.background).toBe('var(--color-surface)') + expect(third!.transform).toBe('translateY(16px) scale(0.9)') + expect(third!.background).toBe('var(--color-neutral-300)') + for (const style of [top, second, third]) expect(style!.borderRadius).toBe('var(--radius-card)') + + // Only the top card is read out; the ones behind it are the stack's edge. + expect(cards[1]).toHaveAttribute('aria-hidden', 'true') + expect(cards[2]).toHaveAttribute('aria-hidden', 'true') + expect(screen.queryByRole('article', { name: 'Crock pot chicken tikka masala' })).not.toBeInTheDocument() + + // The top card: washed photo, Bagel name, difficulty dots, "Why this?". + const photo = within(card).getByRole('img', { name: 'Korean beef bowls' }) + expect(photo).toHaveAttribute('src', '/media/photos/p1-400.jpg') + expect(getComputedStyle(photo).height).toBe('220px') + expect(photo.style.getPropertyValue('--photo-inset')).toBe('10px') + expect(photo.style.getPropertyValue('--photo-radius')).toBe('24px') + const name = within(card).getByRole('heading', { level: 2, name: 'Korean beef bowls' }) + expect(getComputedStyle(name).fontSize).toBe('24px') + const difficulty = within(card).getByText('Easy') + const dots = difficulty.querySelectorAll('[data-filled]') + expect([...dots].map((dot) => dot.getAttribute('data-filled'))).toEqual(['true', 'false', 'false']) + expect(getComputedStyle(dots[0]!).width).toBe('8px') + const why = within(card).getByText('Why this?').parentElement! + expect(why).toHaveTextContent('Why this? You make Chicken stir-fry and Beef tacos most weeks.') + expect(declaredStyle(why).background).toBe('var(--color-accent-2-100)') + + // The stamps are there, hidden until a decision shows them. + const tryStamp = within(card).getByText('Try it!') + const passStamp = within(card).getByText('Pass') + expect(getComputedStyle(tryStamp).opacity).toBe('0') + expect(getComputedStyle(tryStamp).transform).toBe('rotate(-12deg)') + expect(getComputedStyle(passStamp).opacity).toBe('0') + expect(getComputedStyle(passStamp).transform).toBe('rotate(12deg)') + + // Pass and Try it: 62 px buttons with 11 px labels. + for (const label of ['Pass', 'Try it']) { + const button = screen.getByRole('button', { name: label }) + expect(getComputedStyle(button).fontSize).toBe('11px') + expectMinHitArea(button) + expect(getComputedStyle(button.querySelector('span')!).width).toBe('62px') + } + }) + + it.each([ + [TIKKA, 'Medium', 2], + [ORZO, 'Hard', 3], + ] as const)('shows $1 with filled dots', async (quest, label, filled) => { + stubServer([quest]) + await renderPage() + const card = await screen.findByRole('article', { name: quest.name }) + expect(within(card).getByText(label).querySelectorAll('[data-filled="true"]')).toHaveLength(filled) + }) + + it('records Try it on → and Pass on ←, for the top card', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [TIKKA, ORZO] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }) + }, + 'POST /api/v1/suggestions/s2/decline': () => { + server.deck = [ORZO] + return jsonResponse(200, { suggestion: { ...TIKKA, status: 'declined' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await screen.findByRole('article', { name: 'Lemon garlic shrimp orzo' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']) + }) + + it('records Pass and Try it from the buttons', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: null }) + }, + 'POST /api/v1/suggestions/s2/accept': () => { + server.deck = [] + return jsonResponse(200, { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await screen.findByRole('heading', { name: 'All caught up' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/s2/accept']) + }) + + it('locks input while a decision is in flight', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled() + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + fireEvent.keyDown(window, { key: 'ArrowRight' }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + + server.deck = [TIKKA] + accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + + it('leaves ← / → alone with a modifier or while typing in a field', async () => { + const server = stubServer([BOWLS]) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight', shiftKey: true }) + fireEvent.keyDown(window, { key: 'ArrowLeft', metaKey: true }) + const field = document.createElement('input') + document.body.append(field) + fireEvent.keyDown(field, { key: 'ArrowRight' }) + field.remove() + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('shows a refused decision with a retry, keeping the card', async () => { + let refuse = true + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => { + if (refuse) return errorResponse(500, 'internal') + server.deck = [] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('heading', { name: 'All caught up' }) + }) + + it('shows "All caught up" when the deck is empty', async () => { + stubServer([]) + await renderPage() + expect(await screen.findByRole('heading', { level: 2, name: 'All caught up' })).toBeInTheDocument() + expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Try it' })).not.toBeInTheDocument() + }) + + it('Get more now starts a batch and shows skeleton cards until its Side quests arrive', async () => { + const jobs = [ + { id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null }, + { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, error: null }, + ] + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + const job = jobs.shift()! + if (job.status === 'succeeded') server.deck = [BOWLS] + return jsonResponse(200, job) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + + await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it('shows a failed batch with a retry over the empty state', async () => { + stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => + jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'failed', result: null, error: 'model unavailable' }), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable") + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) + + it('shows a deck that fails to load with a retry', async () => { + let fail = true + stubServer([BOWLS], { [`GET ${DECK}`]: () => (fail ? errorResponse(500, 'internal') : jsonResponse(200, { suggestions: [BOWLS], active_batch: null, failed_batch: null })) }) + await renderPage() + const alert = await screen.findByRole('alert', {}, { timeout: 5000 }) + expect(alert).toHaveTextContent("Couldn't load your side quests.") + fail = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + }) + + it('sends one decision for presses made before the page re-renders', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.keyDown(window, { key: 'ArrowRight' }) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(sent(server.fetchMock)).toContain('POST /api/v1/suggestions/s1/accept')) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + server.deck = [TIKKA] + accept.resolve(jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null })) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s2/decline']), + ) + }) + + it.each([ + ['not a decision', {}], + ['another suggestion', { suggestion: { ...TIKKA, status: 'accepted' }, replenish_job_id: null }], + ['not accepted', { suggestion: { ...BOWLS, status: 'pending' }, replenish_job_id: null }], + ])('shows a decision answered with %s as unsaved, keeping the card', async (_, answer) => { + stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => jsonResponse(200, answer) }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice. POST /api/v1/suggestions/s1/accept: response is not the accepted suggestion") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + }) + + it('shows a batch whose job cannot be polled with a retry, keeping the skeleton cards', async () => { + let failPoll = true + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + if (failPoll) return errorResponse(500, 'internal') + server.deck = [BOWLS] + return jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status: 'succeeded', + result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, + error: null, + }) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on new side quests.") + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + + // Retry polls the job at once, well before the next poll is due; it + // does not ask for another batch. + failPoll = false + const polls = jobPolls(server.fetchMock) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(polls + 1), { timeout: 200 }) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it('shows a batch whose job answers with an unknown status as an error, and Retry polls the job again', async () => { + let status = 'stuck' + const server = stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => { + if (status === 'succeeded') server.deck = [BOWLS] + return jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status, + result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s1'], dropped: 0 }, + error: null, + }) + }, + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent( + "Couldn't check on new side quests. GET /api/v1/jobs/j1: response is not the suggestion_batch job j1", + ) + // Polling has stopped: nothing more is asked until Retry. + await new Promise((resolve) => setTimeout(resolve, 700)) + expect(jobPolls(server.fetchMock)).toBe(1) + + status = 'succeeded' + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(jobPolls(server.fetchMock)).toBe(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it('shows a batch whose job result is unreadable with a retry', async () => { + stubServer([], { + 'POST /api/v1/suggestions/batches': () => + jsonResponse(202, { batch_id: 'b1', job_id: 'j1', trigger: 'manual', status: 'pending' }), + 'GET /api/v1/jobs/j1': () => + jsonResponse(200, { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1' }, error: null }), + }) + await renderPage() + fireEvent.click(await screen.findByRole('button', { name: 'Get more now' })) + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find new side quests. GET /api/v1/jobs/j1: result is not a suggestion batch") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) + + describe('batch and deck depth', () => { + const PAELLA = suggestionBody('s4', 'Sheet pan paella') + const SOUP = suggestionBody('s5', 'Tortellini soup') + const CURRY = suggestionBody('s6', 'Green curry') + const declined = (quest: typeof BOWLS, replenish: string | null) => + jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: replenish }) + // deckLayers is the deck's three depths, top first: its cards, then the + // blank or skeleton cards standing in for those it has none for. + const deckLayers = (container: HTMLElement) => [...container.querySelector('article')!.parentElement!.children] + + it('shows a replenish batch that fails while cards remain, beside the deck, with a retry that asks again', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' } + return declined(BOWLS, 'j1') + }, + 'GET /api/v1/jobs/j1': () => { + server.activeBatch = null + server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' } + return jsonResponse(200, batchJob('j1', 'failed', [], 'model unavailable')) + }, + 'POST /api/v1/suggestions/batches': () => { + server.failedBatch = null + server.activeBatch = { batch_id: 'b-j2', job_id: 'j2' } + return jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' }) + }, + 'GET /api/v1/jobs/j2': () => { + server.activeBatch = null + server.deck = [TIKKA, ORZO] + return jsonResponse(200, batchJob('j2', 'succeeded', ['s3'])) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't find new side quests. model unavailable") + // The deck stays in play beside it. + expect(screen.getByRole('article', { name: 'Crock pot chicken tikka masala' })).toBeInTheDocument() + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline', 'POST /api/v1/suggestions/batches']) + }) + + it('shows a replenish batch whose job cannot be polled while cards remain, with a retry that polls it again', async () => { + let failPoll = true + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + return declined(BOWLS, 'j1') + }, + 'GET /api/v1/jobs/j1': () => { + if (failPoll) return errorResponse(500, 'internal') + server.deck = [TIKKA, ORZO] + return jsonResponse(200, batchJob('j1', 'succeeded', ['s3'])) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't check on new side quests.") + expect(screen.getByRole('article', { name: 'Crock pot chicken tikka masala' })).toBeInTheDocument() + + failPoll = false + const polls = jobPolls(server.fetchMock) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(polls + 1), { timeout: 200 }) + await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(2)) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + }) + + it.each([ + ['the AI failed', 'an empty deck', 'ai_failed', []], + ['the AI failed', 'cards left', 'ai_failed', [BOWLS, TIKKA]], + ['a restart interrupted it', 'an empty deck', 'interrupted by restart', []], + ['a restart interrupted it', 'cards left', 'interrupted by restart', [BOWLS, TIKKA]], + ])('shows the failed batch the deck reports when the page opens after %s, with %s, and a retry that asks again', async (_, __, reason, deck) => { + const server = stubServer(deck, { + 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'failed', [], reason)), + 'POST /api/v1/suggestions/batches': () => { + server.failedBatch = null + server.activeBatch = { batch_id: 'b-j2', job_id: 'j2' } + return jsonResponse(202, { batch_id: 'b-j2', job_id: 'j2', trigger: 'manual', status: 'pending' }) + }, + 'GET /api/v1/jobs/j2': () => { + server.activeBatch = null + server.deck = [...deck, ORZO] + return jsonResponse(200, batchJob('j2', 'succeeded', ['s3'])) + }, + }) + // The batch failed while the page was closed. + server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' } + await renderPage() + + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent(`Couldn't find new side quests. ${reason}`) + if (deck.length === 0) { + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + } else { + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getAllByTestId('blank-card')).toHaveLength(1) + } + // A failed batch is not finding anything. + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + // The new batch's Side quest joins the deck. + await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(deck.length + 1), { timeout: 3000 }) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/batches']) + }) + + it.each([ + ['once its job has been polled', false], + ['before its job has been polled', true], + ])('drops a failed batch %s once a read of the deck reports a later batch another session finished', async (_, slowPoll) => { + const poll = deferred() + const server = stubServer([BOWLS, TIKKA], { + 'GET /api/v1/jobs/j1': () => (slowPoll ? poll.promise : jsonResponse(200, batchJob('j1', 'failed', [], 'ai_failed'))), + }) + server.failedBatch = { batch_id: 'b-j1', job_id: 'j1' } + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + if (slowPoll) await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(1)) + else expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't find new side quests. ai_failed") + + // Another session's batch j2 succeeds, superseding j1's failure. + server.failedBatch = null + server.deck = [BOWLS, TIKKA, ORZO] + const reads = deckReads(server.fetchMock) + await act(() => queryClient.invalidateQueries({ queryKey: DECK_KEY })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + await waitFor(() => expect(screen.getAllByRole('article', { hidden: true })).toHaveLength(3)) + // A poll of j1 still out when the deck moved on does not bring it back. + await act(async () => poll.resolve(jsonResponse(200, batchJob('j1', 'failed', [], 'ai_failed')))) + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('follows the batch the deck reports running when the page opens, with skeleton cards rather than "All caught up"', async () => { + const jobs = [batchJob('j1', 'running'), batchJob('j1', 'succeeded', ['s1'])] + const server = stubServer([], { + 'GET /api/v1/jobs/j1': () => { + const job = jobs.length > 1 ? jobs.shift()! : jobs[0]! + if (job.status === 'succeeded') { + server.deck = [BOWLS] + server.activeBatch = null + } + return jsonResponse(200, job) + }, + }) + server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' } + await renderPage() + + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + + await screen.findByRole('article', { name: 'Korean beef bowls' }, { timeout: 3000 }) + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('follows a batch another session started once a read of the deck reports it', async () => { + let done = false + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA, SOUP, CURRY], { + // Five are left: this decision calls for no batch of its own. + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA, ORZO, PAELLA, SOUP, CURRY] + return declined(BOWLS, null) + }, + 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', done ? 'succeeded' : 'running')), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(jobPolls(server.fetchMock)).toBe(0) + + // Another session asks for more meanwhile. + server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' } + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + await waitFor(() => expect(jobPolls(server.fetchMock)).toBeGreaterThan(0)) + + server.activeBatch = null + done = true + await waitFor(() => expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument(), { timeout: 3000 }) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + }) + + it.each([ + ['one card', [BOWLS], ['translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)']], + ['two cards', [BOWLS, TIKKA], ['translateY(16px) scale(0.9)']], + ])('keeps three deck depths with %s, filling the rest with blank cards while no batch runs', async (_, deck, blankTransforms) => { + stubServer(deck) + const { container } = await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + const layers = deckLayers(container) + expect(layers).toHaveLength(3) + expect(layers.filter((layer) => layer.tagName === 'ARTICLE')).toHaveLength(deck.length) + const blanks = screen.getAllByTestId('blank-card') + expect(blanks).toEqual(layers.slice(deck.length)) + expect(blanks.map((blank) => declaredStyle(blank).transform)).toEqual(blankTransforms) + for (const blank of blanks) expect(blank).toHaveAttribute('aria-hidden', 'true') + // Nothing is coming: no skeleton cards and no "Finding" line. + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + }) + + it('fills a short deck with skeleton cards while a batch finds more Side quests', async () => { + const server = stubServer([BOWLS], { 'GET /api/v1/jobs/j1': () => jsonResponse(200, batchJob('j1', 'running')) }) + server.activeBatch = { batch_id: 'b-j1', job_id: 'j1' } + const { container } = await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + const layers = deckLayers(container) + expect(layers).toHaveLength(3) + expect(screen.getAllByTestId('skeleton-card')).toEqual(layers.slice(1)) + expect(screen.queryByTestId('blank-card')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + }) + + describe('Quest log sheet', () => { + const PAD_THAI = questLogBody('q1', 'Pad thai', { photo_url: '/media/photos/p9-400.jpg', difficulty: 'medium', minutes_total: 35 }) + const GUMBO = questLogBody('q2', 'Shrimp gumbo', { difficulty: 'hard', minutes_total: null }) + const RISOTTO = questLogBody('q3', 'Mushroom risotto') + + // stubQuestLog serves the deck and server.dinners as the Quest log. + function stubQuestLog(dinners: unknown[], responders: Record = {}) { + const questLog = { dinners } + const server = stubServer([BOWLS], { [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog.dinners }), ...responders }) + return Object.assign(server, { questLogDinners: questLog }) + } + + async function openSheet(count: number) { + fireEvent.click(await screen.findByRole('button', { name: `Quest log · ${count}` })) + return screen.getByRole('dialog', { name: 'Quest log' }) + } + + function rows(sheet: HTMLElement) { + return within(within(sheet).getByRole('list', { name: 'Quest log' })).getAllByRole('listitem') + } + + // names is the dinners the sheet lists, read off their remove buttons. + function names(sheet: HTMLElement) { + return rows(sheet).map((row) => within(row).getByRole('button', { name: /^Remove / }).getAttribute('aria-label')!.slice('Remove '.length)) + } + + it('opens from the "Quest log · N" pill and lists each dinner: 48 px washed photo, name, difficulty and minutes', async () => { + stubQuestLog([PAD_THAI, GUMBO, RISOTTO]) + await renderPage() + const pill = await screen.findByRole('button', { name: 'Quest log · 3' }) + expect(pill).toHaveAttribute('aria-haspopup', 'dialog') + expect(pill).toHaveAttribute('aria-expanded', 'false') + expectMinHitArea(pill) + pill.focus() + + fireEvent.click(pill) + const sheet = screen.getByRole('dialog', { name: 'Quest log' }) + expect(pill).toHaveAttribute('aria-expanded', 'true') + const [padThai, gumbo, risotto] = rows(sheet) + expect(names(sheet)).toEqual(['Pad thai', 'Shrimp gumbo', 'Mushroom risotto']) + expect(within(padThai!).getByText('Medium · 35 min')).toBeInTheDocument() + expect(within(gumbo!).getByText('Hard')).toBeInTheDocument() + // Nothing known, no meta line. + expect(within(risotto!).getByRole('button', { name: /^Mushroom risotto/ })).toHaveTextContent(/^dish photoMushroom risotto$/) + + // The photo is the stored one, through the washed treatment, at 48 px. + const photo = padThai!.querySelector('img')! + expect(photo).toHaveAttribute('src', '/media/photos/p9-400.jpg') + expect(declaredStyle(photo).filter).toBe('saturate(0.6) contrast(0.85) brightness(1.1) opacity(0.94)') + expect(declaredStyle(photo).width).toBe('48px') + expect(declaredStyle(photo).height).toBe('48px') + // Without a photo, the striped placeholder stands in. + expect(gumbo!.querySelector('img')).toBeNull() + expect(within(gumbo!).getByText('dish photo')).toBeInTheDocument() + + for (const row of [padThai!, gumbo!, risotto!]) { + for (const button of within(row).getAllByRole('button')) expectMinHitArea(button) + } + + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(pill).toHaveAttribute('aria-expanded', 'false') + expect(pill).toHaveFocus() + }) + + it('lists only the dinners the Quest log read returns, so ones already on the current or a future plan stay out', async () => { + // The server leaves out dinners on the current or a later week's plan; + // the sheet adds none and works out nothing of its own. + const server = stubQuestLog([RISOTTO]) + await renderPage() + const sheet = await openSheet(1) + expect(names(sheet)).toEqual(['Mushroom risotto']) + expect(server.fetchMock.mock.calls.map(([input]) => String(input)).filter((path) => path.startsWith('/api/v1/plans'))).toEqual([]) + }) + + it('opens /dinner/:id from a row', async () => { + stubQuestLog([PAD_THAI, GUMBO]) + await renderPage() + const sheet = await openSheet(2) + fireEvent.click(within(sheet).getByRole('button', { name: /^Shrimp gumbo/ })) + expect(await screen.findByText('Dinner screen for q2')).toBeInTheDocument() + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('removes a dinner only once confirmed, archiving it and taking one off the count', async () => { + const server = stubQuestLog([PAD_THAI, GUMBO], { + 'DELETE /api/v1/dinners/q1': () => { + server.questLogDinners.dinners = [GUMBO] + return jsonResponse(200, { ...PAD_THAI, status: 'archived' }) + }, + }) + await renderPage() + const sheet = await openSheet(2) + + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) + expect(confirm).toHaveTextContent('It leaves your Quest log for good.') + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' })) + expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument() + expect(rows(sheet)).toHaveLength(2) + expect(sent(server.fetchMock)).toEqual([]) + + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + fireEvent.click(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1']) + expect(names(sheet)).toEqual(['Shrimp gumbo']) + expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument() + // The Quest log is read again after the removal, and agrees. + await waitFor(() => + expect(server.fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG)).toHaveLength(2), + ) + expect(screen.getByRole('button', { name: 'Quest log · 1' })).toBeInTheDocument() + }) + + it('keeps focus in the open Quest log after a removal: on the next row, the one before the last, then the sheet', async () => { + const archive = (id: string) => () => { + server.questLogDinners.dinners = server.questLogDinners.dinners.filter((dinner) => dinner !== byId[id]) + return jsonResponse(200, { ...byId[id]!, status: 'archived' }) + } + const byId: Record = { q1: PAD_THAI, q2: GUMBO, q3: RISOTTO } + const server = stubQuestLog([PAD_THAI, GUMBO, RISOTTO], { + 'DELETE /api/v1/dinners/q1': archive('q1'), + 'DELETE /api/v1/dinners/q2': archive('q2'), + 'DELETE /api/v1/dinners/q3': archive('q3'), + }) + await renderPage() + const sheet = await openSheet(3) + + async function removeAndExpectFocus(name: string, expected: () => HTMLElement) { + within(sheet).getByRole('button', { name: `Remove ${name}` }).focus() + fireEvent.click(within(sheet).getByRole('button', { name: `Remove ${name}` })) + fireEvent.click(within(screen.getByRole('dialog', { name: `Remove ${name}?` })).getByRole('button', { name: 'Remove' })) + await waitFor(() => expect(screen.queryByRole('dialog', { name: `Remove ${name}?` })).not.toBeInTheDocument()) + expect(expected()).toHaveFocus() + expect(sheet).toContainElement(document.activeElement as HTMLElement) + } + + // A middle row: focus goes to the row now in its place. + await removeAndExpectFocus('Shrimp gumbo', () => within(sheet).getByRole('button', { name: 'Remove Mushroom risotto' })) + // The last row: focus goes to the row before it. + await removeAndExpectFocus('Mushroom risotto', () => within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + // The only row: focus goes to the sheet itself. + await removeAndExpectFocus('Pad thai', () => sheet) + expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument() + + // From there Escape still reaches the Quest log, and closes it. + fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it.each([ + ['Keep it', (confirm: HTMLElement) => fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' }))], + ['Escape', (confirm: HTMLElement) => fireEvent.keyDown(confirm, { key: 'Escape' })], + ['the scrim', (confirm: HTMLElement) => fireEvent.click(confirm.previousElementSibling!)], + ])('keeps the confirmation and sends one removal once Remove is activated: %s and a second Remove at once do nothing', async (_, dismiss) => { + const archive = deferred() + const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise }) + await renderPage() + const sheet = await openSheet(2) + + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) + const removeButton = within(confirm).getByRole('button', { name: 'Remove' }) + // Straight after Remove, without waiting for the pending request to render. + fireEvent.click(removeButton) + fireEvent.click(removeButton) + dismiss(confirm) + expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm) + + expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled() + expect(within(confirm).getByRole('button', { name: 'Keep it' })).toBeDisabled() + dismiss(confirm) + expect(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).toBe(confirm) + + server.questLogDinners.dinners = [GUMBO] + await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' }))) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()) + expect(names(sheet)).toEqual(['Shrimp gumbo']) + expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1']) + }) + + it.each([ + ['keeps a disabled button focused', false], + ['moves focus off a disabled button to the page', true], + ])('keeps focus in the confirmation, off its disabled buttons, while a removal is under way when the browser %s', async (_, fixup) => { + // Some browsers move focus from a control to the page as it is disabled. + const setAttribute = Element.prototype.setAttribute + if (fixup) { + vi.spyOn(Element.prototype, 'setAttribute').mockImplementation(function (this: Element, name, value) { + // jsdom ignores blur() on a disabled control, so focus leaves just before. + if (name === 'disabled' && this === document.activeElement && this instanceof HTMLElement) this.blur() + setAttribute.call(this, name, value) + }) + } + onTestFinished(() => { + vi.restoreAllMocks() + }) + const archive = deferred() + const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => archive.promise }) + await renderPage() + const sheet = await openSheet(2) + + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) + const removeButton = within(confirm).getByRole('button', { name: 'Remove' }) + removeButton.focus() + fireEvent.click(removeButton) + expect(removeButton).toBeDisabled() + expect(confirm).toHaveFocus() + + expect(await within(confirm).findByRole('button', { name: 'Removing…' })).toBeDisabled() + expect(confirm).toHaveFocus() + + server.questLogDinners.dinners = [GUMBO] + await act(async () => archive.resolve(jsonResponse(200, { ...PAD_THAI, status: 'archived' }))) + await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Remove Pad thai?' })).not.toBeInTheDocument()) + expect(within(sheet).getByRole('button', { name: 'Remove Shrimp gumbo' })).toHaveFocus() + }) + + it('keeps a dinner whose removal the server refuses, saying why', async () => { + const server = stubQuestLog([PAD_THAI, GUMBO], { 'DELETE /api/v1/dinners/q1': () => errorResponse(500, 'internal') }) + await renderPage() + const sheet = await openSheet(2) + + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) + fireEvent.click(within(confirm).getByRole('button', { name: 'Remove' })) + expect(await within(confirm).findByRole('alert')).toHaveTextContent("Couldn't remove it. internal message") + expect(sent(server.fetchMock)).toEqual(['DELETE /api/v1/dinners/q1']) + expect(rows(sheet)).toHaveLength(2) + expect(screen.getByRole('button', { name: 'Quest log · 2' })).toBeInTheDocument() + + // Keep it closes the confirmation and clears the error. + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' })) + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + expect(within(screen.getByRole('dialog', { name: 'Remove Pad thai?' })).queryByRole('alert')).not.toBeInTheDocument() + }) + + it('says how to fill an empty Quest log', async () => { + stubQuestLog([]) + await renderPage() + const sheet = await openSheet(0) + expect(within(sheet).getByText('Your Quest log is empty — swipe right on a side quest to add one.')).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + }) + + it('leaves ← / → to the Quest log sheet and its confirmation while they are open', async () => { + const server = stubQuestLog([PAD_THAI, GUMBO], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'accepted' }, replenish_job_id: null }) + }, + }) + const { container } = await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + const pressArrows = (target: Element | Window) => { + for (const key of ['ArrowRight', 'ArrowLeft']) fireEvent.keyDown(target, { key }) + } + + const sheet = await openSheet(2) + pressArrows(sheet) + pressArrows(window) + fireEvent.click(within(sheet).getByRole('button', { name: 'Remove Pad thai' })) + const confirm = screen.getByRole('dialog', { name: 'Remove Pad thai?' }) + pressArrows(confirm) + pressArrows(window) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(sent(server.fetchMock)).toEqual([]) + expect(container.querySelector('article')).toHaveAccessibleName('Korean beef bowls') + + // Once both are closed, the keys are the deck's again. + fireEvent.click(within(confirm).getByRole('button', { name: 'Keep it' })) + fireEvent.keyDown(sheet, { key: 'Escape' }) + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + fireEvent.keyDown(window, { key: 'ArrowRight' }) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + }) + }) + + describe('optimistic decisions', () => { + const PAELLA = suggestionBody('s4', 'Sheet pan paella') + const SOUP = suggestionBody('s5', 'Tortellini soup') + const accepted = (quest: typeof BOWLS, replenish: string | null = null) => + jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: replenish }) + const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null }) + // topCard is the deck's top card, the one read out. + const topCard = () => screen.getAllByRole('article')[0] + + it('Try it takes the card off the deck and bumps the Quest log count once the exit finishes, before the answer', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + // Nothing is applied on the press: the card is still leaving. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual([]) + + // Once it is gone the decision is applied, and sent. + await finishRunning(animations) + await finishRunning(animations) + await screen.findByText('Quest log · 7') + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + accept.resolve(accepted(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + // The deck is read again once the decision is saved. + expect(deckReads(server.fetchMock)).toBe(2) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('Pass takes the card off the deck before the answer, leaving the Quest log count', async () => { + const decline = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/decline': () => decline.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + + server.deck = [TIKKA, ORZO, PAELLA] + decline.resolve(declined(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()) + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(deckReads(server.fetchMock)).toBe(2) + }) + + it('puts a refused Try it back on top of the deck, reverts the Quest log count and offers Retry', async () => { + const accept = deferred() + let refuse = true + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => (refuse ? accept.promise : accepted(BOWLS)), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await screen.findByText('Quest log · 7') + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // The server refuses the request itself: the deck read again after it + // still has the Side quest pending, in the server's order. + server.deck = [TIKKA, BOWLS, ORZO, PAELLA] + accept.resolve(errorResponse(400, 'invalid_request')) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(deckReads(server.fetchMock)).toBe(2) + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Korean beef bowls', + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + ]) + expect(screen.getByText('Quest log · 6')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + + // Retry sends the same decision again, applied again at once. + refuse = false + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument()) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + }) + + it('keeps a saved decision off the deck when a read started during it answers late', async () => { + const decline = deferred() + let late: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => decline.promise, + [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // A read of the deck starts before the decision is saved, and answers + // only after: with the Side quest still pending. + late = deferred() + const stale = late + await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY })) + expect(deckReads(server.fetchMock)).toBe(2) + late = null + server.deck = [TIKKA, ORZO, PAELLA] + decline.resolve(declined(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()) + await act(async () => stale.resolve(jsonResponse(200, { suggestions: [BOWLS, TIKKA, ORZO, PAELLA], active_batch: null, failed_batch: null }))) + + // The late answer is dropped, and the deck is read again after the save. + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(3)) + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + 'Sheet pan paella', + ]) + }) + + it('keeps the card off the deck while its decision is unanswered when a read of the deck lands with it still pending, then reads the deck again once it is saved', async () => { + const decline = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/decline': () => decline.promise }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // A read of the deck lands before the decision is saved, with the Side + // quest still pending: the card stays off the deck. + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(deckReads(server.fetchMock)).toBe(2) + expect(queryClient.getQueryData<{ suggestions: { id: string }[] }>(DECK_KEY)?.suggestions.map((q) => q.id)).toEqual(['s1', 's2', 's3', 's4']) + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + 'Sheet pan paella', + ]) + + // Once it is saved, the deck read after it is the deck shown. + server.deck = [TIKKA, ORZO, PAELLA] + decline.resolve(declined(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled()) + expect(deckReads(server.fetchMock)).toBe(3) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + 'Sheet pan paella', + ]) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('keeps the card off the deck while its decision is unanswered when a read of the deck lands with it still pending, then puts it back on top with a Retry once it is refused', async () => { + const accept = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // A read of the deck lands mid-decision, in the server's order. + server.deck = [TIKKA, BOWLS, ORZO, PAELLA] + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(deckReads(server.fetchMock)).toBe(2) + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + 'Sheet pan paella', + ]) + + // The server refuses it; the deck read after it still has it pending. + accept.resolve(errorResponse(400, 'invalid_request')) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(deckReads(server.fetchMock)).toBe(3) + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Korean beef bowls', + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + ]) + }) + + it('leaves the Quest log count alone for a dinner already in it, on Try it and on its refusal', async () => { + const accept = deferred() + stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + // A read of the Quest log already has the dinner the stale deck offers. + [`GET ${QUEST_LOG}`]: () => + jsonResponse(200, { + dinners: [ + questLogBody('q0', 'Quest 0'), + questLogBody('dinner-s1', 'Korean beef bowls'), + ], + }), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 2') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + + accept.resolve(errorResponse(500, 'internal')) + await screen.findByRole('alert') + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + }) + + it('takes out of the Quest log only the entry a refused Try it added, after a read of the Quest log lands during it', async () => { + const accept = deferred() + const questLog = [questLogBody('q0', 'Quest 0')] + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + [`GET ${QUEST_LOG}`]: () => jsonResponse(200, { dinners: questLog }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 1') + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + + // The decision is saved, and a read of the Quest log lands with its + // dinner in it; then the answer to the decision is lost. + questLog.push(questLogBody('dinner-s1', 'Korean beef bowls')) + await act(async () => await queryClient.invalidateQueries({ queryKey: ['dinners', 'quest_log'] })) + accept.resolve(errorResponse(500, 'internal')) + await screen.findByRole('alert') + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + // The answer was lost, so the Quest log is read again. + const questLogReads = () => server.fetchMock.mock.calls.filter(([input]) => String(input) === QUEST_LOG).length + await waitFor(() => expect(questLogReads()).toBe(3)) + expect(screen.getByText('Quest log · 2')).toBeInTheDocument() + }) + + it('sends a decision for the card thrown out even when a read of the deck took it away during the exit, offering no Retry for it', async () => { + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal'), + }) + const queryClient = createQueryClient() + const animations = stubAnimate({ autoFinish: false }) + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // While the card leaves, a read of the deck takes the last card away. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + server.deck = [] + await act(async () => await queryClient.invalidateQueries({ queryKey: DECK_KEY })) + await screen.findByRole('heading', { name: 'All caught up' }) + + await finishRunning(animations) + await finishRunning(animations) + const alert = await screen.findByRole('alert') + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + // The deck read after it confirms the Side quest is decided: the card + // is not put back, and there is nothing to retry. + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + expect(screen.queryByRole('article')).not.toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() + }) + + it('keeps a card off the deck when a read during its decision left it out and the answer is lost, and reads the deck again', async () => { + let lose!: (error: Error) => void + const accept = new Promise((_, reject) => (lose = reject)) + let late: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept, + [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + + // The decision is saved, and a read of the deck started during it + // answers without the Side quest; then the answer to the decision is + // lost on the way back. + late = deferred() + const delayed = late + await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY })) + late = null + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }))) + const reads = deckReads(server.fetchMock) + await act(async () => lose(new TypeError('Failed to fetch'))) + + await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + // The decision may have been saved: the deck is read again. + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('keeps a card off the deck when another session already decided it (409), and reads the deck again', async () => { + const accept = deferred() + let read: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept.promise, + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + + // Another session passed on it first; no read of the deck lands + // during the decision. + read = deferred() + const pending = read + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => accept.resolve(errorResponse(409, 'suggestion_decided'))) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + // The decision is not over until that read lands. + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + await act(async () => pending.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }))) + const alert = await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + // It was decided: there is no decision to retry. + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + + it('reads the Quest log again after a Pass another session beat with Try it (409), counting its dinner', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => { + // Another session tried it first: its dinner is in the Quest log. + server.deck = [TIKKA, ORZO, PAELLA] + server.questLog = 7 + return errorResponse(409, 'suggestion_decided') + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(screen.getByText('Quest log · 7')).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('locks the next decision until the reads after a failed one land, then puts the still-pending card back on top with a Retry', async () => { + let read: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => { + read = deferred() + return errorResponse(500, 'internal') + }, + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }), + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // The decision failed and was not saved; the deck read after it is slow. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + const slow = read! + read = null + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // Deciding the next card does nothing while that read is out. + expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled() + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + + await act(async () => slow.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }))) + const alert = await screen.findByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + + // Retry sends it again. + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept'])) + }) + + it('settles a failed decision on its own read of the deck when a batch poll reads the deck again during it', async () => { + const job = (suggestionIds: string[]) => + jsonResponse(200, { + id: 'j1', + kind: 'suggestion_batch', + status: 'running', + result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: suggestionIds, dropped: 0 }, + error: null, + }) + let poll: ReturnType | null = null + let read: ReturnType | null = null + let refuse = true + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA, ORZO, PAELLA] + return jsonResponse(200, { suggestion: { ...BOWLS, status: 'declined' }, replenish_job_id: 'j1' }) + }, + 'POST /api/v1/suggestions/s2/accept': () => { + if (!refuse) return accepted(TIKKA) + read = deferred() + return errorResponse(500, 'internal') + }, + 'GET /api/v1/jobs/j1': () => poll?.promise ?? job([]), + [`GET ${DECK}`]: () => read?.promise ?? jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // A Pass starts a replenish batch, whose job is polled. + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(1)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + poll = deferred() + const polled = poll + await waitFor(() => expect(jobPolls(server.fetchMock)).toBe(2), { timeout: 2000 }) + + // Try it fails without being saved; the deck read it makes is slow. + const reads = deckReads(server.fetchMock) + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + const reconciling = read! + read = deferred() + const after = read + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + // The batch stores a Side quest while that read is out: its read of + // the deck waits for the decision's rather than replacing it. + const SOUP = suggestionBody('s5', 'Tortellini soup') + server.deck = [TIKKA, ORZO, PAELLA, SOUP] + await act(async () => polled.resolve(job(['s5']))) + await act(async () => await new Promise((resolve) => setTimeout(resolve, 0))) + expect(deckReads(server.fetchMock)).toBe(reads + 1) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + + // The decision's read has the Side quest still pending: the card goes + // back on top with a Retry, and the decision settles on that read. + await act(async () => reconciling.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }))) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + const alert = screen.getByRole('alert') + expect(alert).toHaveTextContent("Couldn't save that choice.") + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // The batch's read goes out after it, and brings its Side quest. + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 2)) + read = null + await act(async () => after.resolve(jsonResponse(200, { suggestions: server.deck, active_batch: null, failed_batch: null }))) + await waitFor(() => expect(queryClient.getQueryData<{ suggestions: { id: string }[] }>(DECK_KEY)?.suggestions.map((q) => q.id)).toEqual(['s2', 's3', 's4', 's5'])) + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + + // Retry sends it again. + refuse = false + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual([ + 'POST /api/v1/suggestions/s1/decline', + 'POST /api/v1/suggestions/s2/accept', + 'POST /api/v1/suggestions/s2/accept', + ]), + ) + }) + + it('does not put a failed decision back on the deck when a later read, landing before it settles, has it decided', async () => { + let log: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => { + log = deferred() + return errorResponse(500, 'internal') + }, + [`GET ${QUEST_LOG}`]: () => log?.promise ?? jsonResponse(200, { dinners: [] }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + // The decision fails; the deck read after it still has the Side quest + // pending, and the Quest log read after it is slow. + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(log).not.toBeNull()) + const slow = log! + log = null + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + + // Another session decides it, and a read of the deck lands meanwhile. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await act(async () => slow.resolve(jsonResponse(200, { dinners: [] }))) + + const alert = await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + expect(alert).toHaveTextContent('That side quest has been decided already.') + expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('withdraws the Retry for a failed decision once a later read of the deck has it decided', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal'), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Korean beef bowls') + + // Another session decides it, and the next read of the deck says so. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY })) + await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')) + expect(screen.getByRole('alert')).toHaveTextContent('That side quest has been decided already.') + expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument() + }) + + it('reads the deck again after a 500 to a decision, rather than putting the card back', async () => { + const decline = deferred() + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/decline': () => decline.promise, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline'])) + + // The decision was saved, and the answer to it is a 500 all the same. + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => decline.resolve(errorResponse(500, 'internal'))) + await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + + it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA, ORZO, PAELLA, SOUP] + return declined(BOWLS) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + expect(deckReads(server.fetchMock)).toBe(1) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2)) + await waitFor(() => + expect(screen.getAllByRole('article', { hidden: true }).map((card) => card.querySelector('h2')!.textContent)).toEqual([ + 'Crock pot chicken tikka masala', + 'Lemon garlic shrimp orzo', + 'Sheet pan paella', + ]), + ) + }) + + it('shows skeleton cards, not "All caught up", while the replenish job a decision started runs', async () => { + const polls = [ + { id: 'j1', kind: 'suggestion_batch', status: 'running', result: { batch_id: 'b1', stage: 'suggesting', suggestion_ids: [], dropped: 0 }, error: null }, + { id: 'j1', kind: 'suggestion_batch', status: 'succeeded', result: { batch_id: 'b1', stage: 'done', suggestion_ids: ['s2'], dropped: 0 }, error: null }, + ] + const server = stubServer([BOWLS], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [] + return accepted(BOWLS, 'j1') + }, + 'GET /api/v1/jobs/j1': () => { + const job = polls.shift()! + if (job.status === 'succeeded') server.deck = [TIKKA] + return jsonResponse(200, job) + }, + }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + expect(await screen.findByText('Finding new side quests…')).toBeInTheDocument() + expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3) + expect(screen.getByRole('region', { name: 'Side quests loading' })).toHaveAttribute('aria-busy', 'true') + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }, { timeout: 3000 }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + // The decision's replenish is followed; no batch is asked for. + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + }) + + it('shows skeleton cards, not "All caught up", while the decision that emptied the deck is unanswered', async () => { + const accept = deferred() + const server = stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/accept': () => accept.promise }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)) + expect(screen.queryByRole('heading', { name: 'All caught up' })).not.toBeInTheDocument() + expect(screen.queryByText('Finding new side quests…')).not.toBeInTheDocument() + + // Saved without a replenish, the deck is caught up. + server.deck = [] + accept.resolve(accepted(BOWLS)) + await screen.findByRole('heading', { name: 'All caught up' }) + expect(screen.queryByTestId('skeleton-card')).not.toBeInTheDocument() + }) + }) + + describe('motion', () => { + const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null }) + const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null }) + const on = (animations: FakeAnimation[], target: Element) => animations.filter((a) => a.target === target) + + it('Try it bumps the button, pops the stamp, wiggles and throws the card, then records it, bumps the Quest log pill and promotes the deck', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [TIKKA, ORZO] + return accepted(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const pill = await screen.findByText('Quest log · 6') + const tryButton = screen.getByRole('button', { name: 'Try it' }) + + fireEvent.click(tryButton) + expect(animations.map((a) => a.target)).toEqual([tryButton, within(card).getByText('Try it!'), card]) + const [bumped, pop, wiggle] = animations + expect(bumped!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)']) + expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' }) + expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' }) + expect(wiggle!.keyframes.map((k) => k.transform)).toEqual(['rotate(0deg)', 'rotate(-4deg)', 'rotate(4deg)', 'rotate(-3deg)', 'rotate(2deg)', 'rotate(0deg)']) + expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' }) + + // Input is locked while the card leaves, and nothing is sent yet. + await waitFor(() => expect(tryButton).toBeDisabled()) + expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled() + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + await finishRunning(animations) + const fly = animations[3]! + expect(fly.target).toBe(card) + expect(fly.keyframes).toEqual([ + { transform: 'translate(0px,0px) rotate(0deg)' }, + { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 }, + { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }, + ]) + expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }) + expect(animations).toHaveLength(4) + expect(sent(server.fetchMock)).toEqual([]) + + // Once it is gone: the decision, the pill bump, then the deck moves up. + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(on(animations, pill).map((a) => a.options)).toEqual([{ duration: 300, easing: 'ease-out' }]) + expect(on(animations, pill)[0]!.keyframes.map((k) => k.transform)).toEqual(['scale(1)', 'scale(.88)', 'scale(1.06)', 'scale(1)']) + const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + const behind = next.parentElement!.querySelectorAll('article')[1]! + expect(on(animations, next).map((a) => [a.keyframes, a.options])).toEqual([ + [[{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }], { duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }], + ]) + expect(on(animations, behind).map((a) => a.keyframes)).toEqual([ + [{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }], + ]) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + }) + + it('Pass bumps the button and folds the card under its shade, then records it and promotes the deck', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + return declined(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + const { container } = await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const pill = await screen.findByText('Quest log · 6') + const passButton = screen.getByRole('button', { name: 'Pass' }) + const shade = card.querySelector('[data-part="shade"]')! + expect(declaredStyle(shade).background).toBe('var(--color-text)') + expect(getComputedStyle(shade).opacity).toBe('0') + + fireEvent.keyDown(window, { key: 'ArrowLeft' }) + expect(animations.map((a) => a.target)).toEqual([passButton, shade, card]) + const [bumped, darken, fold] = animations + expect(bumped!.options).toEqual({ duration: 300, easing: 'ease-out' }) + expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }]) + expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' }) + expect(fold!.keyframes).toEqual([ + { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' }, + { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)', offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' }, + { transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }, + ]) + expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' }) + await waitFor(() => expect(passButton).toBeDisabled()) + expect(sent(server.fetchMock)).toEqual([]) + + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(on(animations, next).map((a) => a.options)).toEqual([{ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }]) + expect(on(animations, pill)).toEqual([]) + expect(container.querySelectorAll('article')).toHaveLength(1) + await waitFor(() => expect(passButton).toBeEnabled()) + }) + + it.each([ + ['Try it', 'ArrowRight', 'accept'], + ['Pass', 'ArrowLeft', 'decline'], + ] as const)('%s with reduced motion is a 200 ms fade, with no bump, wiggle, fold or promotion', async (_, key, action) => { + mockViewport(390, { reducedMotion: true }) + const server = stubServer([BOWLS, TIKKA, ORZO], { + [`POST /api/v1/suggestions/s1/${action}`]: () => { + server.deck = [TIKKA, ORZO] + return action === 'accept' ? accepted(BOWLS) : declined(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + await screen.findByText('Quest log · 6') + + fireEvent.keyDown(window, { key }) + expect(animations).toHaveLength(1) + expect(animations[0]!.target).toBe(card) + expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]) + expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled()) + expect(sent(server.fetchMock)).toEqual([]) + + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`]) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + expect(animations).toHaveLength(1) + }) + + it('puts the card back when its decision is refused, and unlocks', async () => { + stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal') }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await finishRunning(animations) + await finishRunning(animations) + await screen.findByRole('alert') + // The stamp, wiggle and throw are undone; the bump had ended. + expect(animations.slice(1, 4).map((a) => a.playState)).toEqual(['idle', 'idle', 'idle']) + expect(screen.getByRole('article', { name: 'Korean beef bowls' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + }) + + it('stops the exit and sends nothing when the page goes away mid-exit', async () => { + const server = stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + const { unmount } = await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + unmount() + await act(async () => { + await Promise.resolve() + }) + expect(animations.slice(1).map((a) => a.playState)).toEqual(['idle', 'idle']) + expect(sent(server.fetchMock)).toEqual([]) + }) + }) + + describe('swipe', () => { + const accepted = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'accepted' }, replenish_job_id: null }) + const declined = (quest: typeof BOWLS) => jsonResponse(200, { suggestion: { ...quest, status: 'declined' }, replenish_job_id: null }) + const on = (animations: FakeAnimation[], target: Element) => animations.filter((a) => a.target === target) + const SPRING = 'cubic-bezier(.34,1.4,.64,1)' + let capture: Mock<(pointerId: number) => void> + + // jsdom has no pointer capture. + beforeEach(() => { + capture = vi.fn() + Element.prototype.setPointerCapture = capture + }) + + afterEach(() => { + Reflect.deleteProperty(Element.prototype, 'setPointerCapture') + }) + + // press, drag and release put a pointer down on the card at (200, 300), + // move it by dx, dy, and let go where it is, or dx, dy from the press. + let pointer = { dx: 0, dy: 0 } + function press(card: Element) { + pointer = { dx: 0, dy: 0 } + fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200, clientY: 300 }) + } + function drag(card: Element, dx: number, dy: number) { + pointer = { dx, dy } + fireEvent.pointerMove(card, { pointerId: 7, isPrimary: true, clientX: 200 + dx, clientY: 300 + dy }) + } + function release(card: Element, dx = pointer.dx, dy = pointer.dy) { + fireEvent.pointerUp(card, { pointerId: 7, isPrimary: true, button: 0, clientX: 200 + dx, clientY: 300 + dy }) + } + const stamps = (card: HTMLElement) => [within(card).getByText('Try it!'), within(card).getByText('Pass')] + + it('makes the top card follow the pointer, captured, with the stamps showing how far it has gone', async () => { + stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp, passStamp] = stamps(card) + expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('none') + + press(card) + expect(capture).toHaveBeenCalledExactlyOnceWith(7) + expect(capture.mock.contexts[0]).toBe(card) + + drag(card, 54, 20) + expect(card.style.transform).toBe('translate(54px,6px) rotate(3deg)') + expect(tryStamp!.style.opacity).toBe('0.54') + expect(passStamp!.style.opacity).toBe('0') + + drag(card, -36, -10) + expect(card.style.transform).toBe('translate(-36px,-3px) rotate(-2deg)') + expect(tryStamp!.style.opacity).toBe('0') + expect(passStamp!.style.opacity).toBe('0.36') + + drag(card, -180, 0) + expect(tryStamp!.style.opacity).toBe('0') + expect(passStamp!.style.opacity).toBe('1') + expect(animations).toEqual([]) + }) + + it('Try it on a release past 110 px right: throws the card from where it was let go, in 340 ms with no wiggle, then records it', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [TIKKA, ORZO] + return accepted(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const pill = await screen.findByText('Quest log · 6') + const tryButton = screen.getByRole('button', { name: 'Try it' }) + + press(card) + drag(card, 126, 30) + release(card) + // No button bump, no wiggle: the stamp pops and the card flies. + expect(animations.map((a) => a.target)).toEqual([within(card).getByText('Try it!')]) + await waitFor(() => expect(tryButton).toBeDisabled()) + expect(animations).toHaveLength(2) + const fly = animations[1]! + expect(fly.target).toBe(card) + expect(fly.keyframes).toEqual([ + { transform: 'translate(126px,9px) rotate(7deg)' }, + { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 }, + ]) + expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }) + expect(sent(server.fetchMock)).toEqual([]) + + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + expect(on(animations, pill)).toHaveLength(1) + const next = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(on(animations, next).map((a) => a.options)).toEqual([{ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }]) + await waitFor(() => expect(tryButton).toBeEnabled()) + expect(next.style.transform).toBe('') + }) + + it('Pass on a release past 110 px left: folds the card from where it was let go, then records it', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/decline': () => { + server.deck = [TIKKA] + return declined(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const pill = await screen.findByText('Quest log · 6') + const shade = card.querySelector('[data-part="shade"]')! + + press(card) + drag(card, -144, 20) + release(card) + expect(animations.map((a) => a.target)).toEqual([shade, card]) + expect(animations[1]!.keyframes).toEqual([ + { transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' }, + { transformOrigin: '0% 50%', transform: 'translate(-144px,6px) rotate(-8deg) rotateY(-80deg)', offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' }, + { transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }, + ]) + expect(animations[1]!.options).toEqual({ duration: 720, fill: 'forwards' }) + await waitFor(() => expect(screen.getByRole('button', { name: 'Pass' })).toBeDisabled()) + expect(sent(server.fetchMock)).toEqual([]) + + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(on(animations, pill)).toEqual([]) + }) + + it.each([ + ['right', 110], + ['left', -110], + ])('springs the card back from a release of 110 px %s, resetting the stamps and deciding nothing', async (_, dx) => { + const server = stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp, passStamp] = stamps(card) + + press(card) + drag(card, dx, 0) + expect([tryStamp!.style.opacity, passStamp!.style.opacity]).toContain('1') + release(card) + expect(animations).toHaveLength(1) + expect(animations[0]!.target).toBe(card) + expect(animations[0]!.keyframes).toEqual([{ transform: `translate(${dx}px,0px) rotate(${dx / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }]) + expect(animations[0]!.options).toEqual({ duration: 420, easing: SPRING }) + expect(card.style.transform).toBe('') + expect(tryStamp!.style.opacity).toBe('') + expect(passStamp!.style.opacity).toBe('') + expect(getComputedStyle(tryStamp!).opacity).toBe('0') + expect(getComputedStyle(passStamp!).opacity).toBe('0') + + // A later move with the pointer up does nothing. + drag(card, 200, 0) + expect(card.style.transform).toBe('') + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + expect(sent(server.fetchMock)).toEqual([]) + }) + + it.each([ + ['Try it', 60, 130, 'accept'], + ['Pass', -60, -130, 'decline'], + ] as const)('%s from where a release past 110 px lets go, though the last move was short of it', async (_, moved, at, action) => { + const server = stubServer([BOWLS, TIKKA], { + [`POST /api/v1/suggestions/s1/${action}`]: () => { + server.deck = [TIKKA] + return action === 'accept' ? accepted(BOWLS) : declined(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, moved, 0) + release(card, at, 20) + const from = `translate(${at}px,6px) rotate(${at / 18}deg)` + await waitFor(() => expect(on(animations, card)).toHaveLength(1)) + expect(on(animations, card).map((a) => a.keyframes[0]!.transform)).toEqual([action === 'accept' ? from : `${from} rotateY(0deg)`]) + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`]) + }) + + it.each([ + ['right', 150, 40], + ['left', -150, -40], + ])('springs the card back from where a release short of 110 px %s lets go, though the last move was past it', async (_, moved, at) => { + const server = stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, moved, 0) + release(card, at, 0) + expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([ + [card, [{ transform: `translate(${at}px,0px) rotate(${at / 18}deg)` }, { transform: 'translate(0px,0px) rotate(0deg)' }], { duration: 420, easing: SPRING }], + ]) + expect(card.style.transform).toBe('') + await act(async () => { + await Promise.resolve() + }) + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('stops a snap-back when the card is grabbed again, so it follows the new drag', async () => { + stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, 80, 0) + release(card) + const [spring] = animations + expect(spring!.playState).toBe('running') + + press(card) + expect(spring!.playState).toBe('idle') + drag(card, 36, 0) + expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)') + expect(animations).toHaveLength(1) + }) + + it('stops a snap-back when a decision starts', async () => { + stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => declined(BOWLS) }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, -80, 0) + release(card) + const [spring] = animations + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + expect(spring!.playState).toBe('idle') + expect(on(animations, card)).toHaveLength(2) + }) + + it('springs the card back when it loses the pointer capture before the release, deciding nothing', async () => { + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/accept': () => accepted(BOWLS) }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp, passStamp] = stamps(card) + + press(card) + drag(card, 150, 0) + fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true }) + expect(animations.map((a) => [a.target, a.options])).toEqual([[card, { duration: 420, easing: SPRING }]]) + expect(card.style.transform).toBe('') + expect(tryStamp!.style.opacity).toBe('') + expect(passStamp!.style.opacity).toBe('') + + // The drag is over: later moves and the release do nothing. + drag(card, 180, 0) + release(card) + expect(card.style.transform).toBe('') + expect(animations).toHaveLength(1) + await act(async () => { + await Promise.resolve() + }) + expect(sent(server.fetchMock)).toEqual([]) + + // The capture going after a release leaves its decision alone. + press(card) + drag(card, 140, 0) + release(card) + await waitFor(() => expect(on(animations, card)).toHaveLength(2)) + fireEvent.lostPointerCapture(card, { pointerId: 7, isPrimary: true }) + expect(animations).toHaveLength(3) + expect(card.style.transform).toBe('translate(140px,0px) rotate(7.777777777777778deg)') + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + }) + + it('springs the card back when the pointer is canceled, however far it went', async () => { + const server = stubServer([BOWLS]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, 180, 0) + fireEvent.pointerCancel(card, { pointerId: 7, isPrimary: true }) + expect(animations.map((a) => a.options)).toEqual([{ duration: 420, easing: SPRING }]) + expect(card.style.transform).toBe('') + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('ignores a drag while busy', async () => { + const server = stubServer([BOWLS, TIKKA], { 'POST /api/v1/suggestions/s1/decline': () => declined(BOWLS) }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Pass' })) + const started = animations.length + press(card) + drag(card, 160, 0) + release(card) + expect(capture).not.toHaveBeenCalled() + expect(card.style.transform).toBe('') + expect(animations).toHaveLength(started) + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/decline']) + }) + + it('puts a drag held while a Retry goes out back at once on release, deciding nothing', async () => { + const retry = deferred() + let calls = 0 + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/accept': () => (++calls === 1 ? errorResponse(500, 'internal') : retry.promise), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + const alert = await screen.findByRole('alert') + // A read of the deck puts the refused Side quest back in the server's + // order, still pending, behind the card dragged during its Retry. + server.deck = [TIKKA, BOWLS] + await act(async () => await queryClient.invalidateQueries({ queryKey: DECK_KEY })) + const card = await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + + press(card) + drag(card, 60, 0) + fireEvent.click(within(alert).getByRole('button', { name: 'Retry' })) + expect(screen.getByRole('button', { name: 'Try it' })).toBeDisabled() + drag(card, -160, 0) + expect(card.style.transform).toBe('translate(60px,0px) rotate(3.3333333333333335deg)') + const started = vi.mocked(Element.prototype.animate).mock.calls.length + release(card) + expect(card.style.transform).toBe('') + expect(within(card).getByText('Try it!').style.opacity).toBe('') + expect(vi.mocked(Element.prototype.animate).mock.calls.length).toBe(started) + await waitFor(() => + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept', 'POST /api/v1/suggestions/s1/accept']), + ) + // The retried Side quest leaves the deck while the Retry is out. + await waitFor(() => expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()) + server.deck = [TIKKA] + retry.resolve(accepted(BOWLS)) + await waitFor(() => expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled()) + }) + + it('drags only the top card', async () => { + const server = stubServer([BOWLS, TIKKA, ORZO]) + const animations = stubAnimate({ autoFinish: false }) + const { container } = await renderPage() + await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [, second, third] = container.querySelectorAll('article') + + for (const card of [second!, third!]) { + expect(declaredStyle(card).getPropertyValue('touch-action')).toBe('') + press(card) + drag(card, 160, 0) + release(card) + expect(card.style.transform).toBe('') + } + expect(capture).not.toHaveBeenCalled() + expect(animations).toEqual([]) + expect(sent(server.fetchMock)).toEqual([]) + }) + + it('ignores a press that is not the primary button', async () => { + stubServer([BOWLS]) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.pointerDown(card, { pointerId: 7, isPrimary: true, button: 2, clientX: 200, clientY: 300 }) + drag(card, 160, 0) + expect(capture).not.toHaveBeenCalled() + expect(card.style.transform).toBe('') + }) + + it.each([ + ['without moving', null], + ['after moving', 160], + ])('ignores a second primary pointer pressing and releasing %s mid-drag, then springs the card back clean', async (_, moves) => { + const server = stubServer([BOWLS, TIKKA]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp, passStamp] = stamps(card) + // A touch (id 7) drags; a mouse (id 1), also primary, comes and goes. + const mouse = (x: number) => ({ pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, clientX: 400 + x, clientY: 300 }) + + press(card) + drag(card, 72, 0) + fireEvent.pointerDown(card, mouse(0)) + if (moves !== null) fireEvent.pointerMove(card, mouse(moves)) + fireEvent.pointerUp(card, mouse(moves ?? 0)) + fireEvent.lostPointerCapture(card, mouse(moves ?? 0)) + expect(capture).toHaveBeenCalledExactlyOnceWith(7) + expect(card.style.transform).toBe('translate(72px,0px) rotate(4deg)') + expect(animations).toEqual([]) + + release(card) + expect(animations.map((a) => [a.target, a.options])).toEqual([[card, { duration: 420, easing: SPRING }]]) + expect(card.style.transform).toBe('') + expect(tryStamp!.style.opacity).toBe('') + expect(passStamp!.style.opacity).toBe('') + await act(async () => { + await Promise.resolve() + }) + expect(screen.getByRole('button', { name: 'Try it' })).toBeEnabled() + expect(sent(server.fetchMock)).toEqual([]) + + // The card can be dragged again. + press(card) + drag(card, 36, 0) + expect(card.style.transform).toBe('translate(36px,0px) rotate(2deg)') + }) + + it('a key decision mid-drag ends the drag and throws the card from rest', async () => { + const server = stubServer([BOWLS, TIKKA], { + 'POST /api/v1/suggestions/s1/accept': () => { + server.deck = [TIKKA] + return accepted(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp] = stamps(card) + + press(card) + drag(card, -60, 0) + fireEvent.keyDown(window, { key: 'ArrowRight' }) + expect(card.style.transform).toBe('') + expect(tryStamp!.style.opacity).toBe('') + // The button bump, stamp pop and wiggle of a Try it from rest. + expect(animations.map((a) => a.target)).toEqual([screen.getByRole('button', { name: 'Try it' }), tryStamp, card]) + release(card) + expect(animations).toHaveLength(3) + await finishRunning(animations) + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept']) + }) + + it('puts a swiped card back at rest when its decision is refused', async () => { + stubServer([BOWLS], { 'POST /api/v1/suggestions/s1/decline': () => errorResponse(500, 'internal') }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + const [tryStamp, passStamp] = stamps(card) + + press(card) + drag(card, -150, 0) + release(card) + await finishRunning(animations) + await screen.findByRole('alert') + expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle']) + expect(card.style.transform).toBe('') + expect(tryStamp!.style.opacity).toBe('') + expect(passStamp!.style.opacity).toBe('') + expect(screen.getByRole('button', { name: 'Pass' })).toBeEnabled() + }) + + it.each([ + ['Try it', 140, 'accept'], + ['Pass', -140, 'decline'], + ] as const)('%s by swipe with reduced motion fades the card where it was let go', async (_, dx, action) => { + mockViewport(390, { reducedMotion: true }) + const server = stubServer([BOWLS, TIKKA], { + [`POST /api/v1/suggestions/s1/${action}`]: () => { + server.deck = [TIKKA] + return action === 'accept' ? accepted(BOWLS) : declined(BOWLS) + }, + }) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, dx, 0) + release(card) + expect(animations.map((a) => [a.target, a.keyframes, a.options])).toEqual([[card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }]]) + expect(card.style.transform).toBe(`translate(${dx}px,0px) rotate(${dx / 18}deg)`) + await finishRunning(animations) + expect(sent(server.fetchMock)).toEqual([`POST /api/v1/suggestions/s1/${action}`]) + await screen.findByRole('article', { name: 'Crock pot chicken tikka masala' }) + expect(animations).toHaveLength(1) + }) + + it('puts the card back at once, without the spring, from a short swipe with reduced motion', async () => { + mockViewport(390, { reducedMotion: true }) + stubServer([BOWLS]) + const animations = stubAnimate({ autoFinish: false }) + await renderPage() + const card = await screen.findByRole('article', { name: 'Korean beef bowls' }) + + press(card) + drag(card, 80, 0) + release(card) + expect(animations).toEqual([]) + expect(card.style.transform).toBe('') + expect(within(card).getByText('Try it!').style.opacity).toBe('') + }) + }) +}) diff --git a/web/src/discover/DiscoverPage.tsx b/web/src/discover/DiscoverPage.tsx new file mode 100644 index 0000000..fd77c50 --- /dev/null +++ b/web/src/discover/DiscoverPage.tsx @@ -0,0 +1,434 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Heart, X } from 'lucide-react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent, type Ref } from 'react' +import { useJob } from '../api/index.ts' +import { EmptyState } from '../ui/EmptyState.tsx' +import { ErrorBanner } from '../ui/ErrorBanner.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { Skeleton } from '../ui/Skeleton.tsx' +import { + BATCH_JOB_KIND, + DECK_KEY, + parseBatchResult, + stillPending, + useDeciding, + useDeck, + useDecide, + useQuestLog, + useStartBatch, + type Choice, + type QuestLogDinner, + type SideQuest, +} from './api.ts' +import { DeckCard, DECK_DEPTH, type SwipeHandlers } from './DeckCard.tsx' +import { + bump, + fadeOut, + follow, + isAbort, + pass, + prefersReducedMotion, + promote, + snapBack, + tryIt, + unfollow, + type Exit, +} from './motion.ts' +import { QuestLogSheet } from './QuestLogSheet.tsx' +import styles from './DiscoverPage.module.css' + +// SWIPE_THRESHOLD is how far, in px, the top card must be dragged left or +// right for its release to Pass or Try it. +const SWIPE_THRESHOLD = 110 + +// A Drag is the pointer dragging the top card: where it went down, and the +// card's drag transform so far. +type Drag = { pointerId: number; x: number; y: number; dx: number; transform: string | null } + +// isEditable says whether a key press belongs to a field being typed in, +// which the deck's ← / → keys leave alone. +function isEditable(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + (target.isContentEditable || target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) + ) +} + +// QuestLogPill is the "Quest log · N" pill, which opens the Quest log +// sheet. ref is the visible pill, which Try it bumps. +function QuestLogPill({ dinners, ref }: { dinners: QuestLogDinner[]; ref: Ref }) { + const [open, setOpen] = useState(false) + return ( + <> + + setOpen(false)} /> + + ) +} + +// DiscoverPage is mock 1a: the deck of Side quests to Try it or Pass, one +// at a time from the top card. A decision from the buttons, the ← / → keys +// or a swipe of the top card throws it out (motion.ts) and sends the +// decision once it is gone, taking the card off the deck at once (see +// useDecide); Retry sends a failed one again while the deck's newest read +// still has its Side quest pending. Input is locked from the press +// until the decision settles, which is once the deck and the Quest log have +// been read again after it, so no decision starts under those reads. The lock is a +// ref, set before anything starts, so a second press before the next render +// does nothing, and busy mirrors it to disable the buttons. +export function DiscoverPage() { + const queryClient = useQueryClient() + const deck = useDeck() + // The deck shown: the deck's newest read, less the Side quests being + // decided, which a read landing mid-decision may still have pending. + const inFlight = useDeciding() + const cards = deck.data?.suggestions.filter((quest) => !inFlight.has(quest.id)) + const questLog = useQuestLog() + const decision = useDecide() + const startBatch = useStartBatch() + // The job of the batch last known to be finding Side quests, or to have + // failed to: the one Get more now started or joined, the replenish a + // decision started or joined, or the deck's active or failed batch, which + // may have been started elsewhere or before the page opened. + const [batchJobId, setBatchJobId] = useState(null) + const job = useJob(BATCH_JOB_KIND, batchJobId) + // A read of the deck reporting a new active or failed batch follows its + // job from that render on. One reporting neither leaves the last job + // followed, so its end stays shown — unless that job is known to have + // failed, by its poll or the deck's last read: the deck reports the newest + // batch's failure, so a later batch has superseded it, and it goes. + const failedJobId = deck.data?.failedBatch?.job_id ?? null + const deckJobId = deck.data?.activeBatch?.job_id ?? failedJobId + const [seenRead, setSeenRead] = useState({ at: 0, jobId: null as string | null, failedJobId: null as string | null }) + if (deck.dataUpdatedAt !== seenRead.at) { + setSeenRead({ at: deck.dataUpdatedAt, jobId: deckJobId, failedJobId }) + if (deckJobId !== null && deckJobId !== seenRead.jobId) setBatchJobId(deckJobId) + else if (deckJobId === null && batchJobId !== null && (job.status === 'failed' || batchJobId === seenRead.failedJobId)) setBatchJobId(null) + } + + const top = cards?.[0] + const [busy, setBusy] = useState(false) + // The deck's failed batch is not finding anything, even before its job's + // first poll says why it failed. + const jobRunning = batchJobId !== null && batchJobId !== failedJobId && job.status !== 'succeeded' && job.status !== 'failed' + const finding = startBatch.isPending || jobRunning + const { mutate: mutateStartBatch } = startBatch + const getMore = useCallback(() => mutateStartBatch(undefined, { onSuccess: (b) => setBatchJobId(b.job_id) }), [mutateStartBatch]) + + // The batch's result as far as it has gone, or why it cannot be read. + const batch = useMemo(() => { + if (job.result === null) return { stored: 0, error: null } + try { + return { stored: parseBatchResult(`GET /api/v1/jobs/${batchJobId}`, job.result).suggestion_ids.length, error: null } + } catch (error) { + return { stored: 0, error: error as Error } + } + }, [job.result, batchJobId]) + // The job's failure, the latest poll's failure, an answer that is not the + // batch's job, or an unreadable result. + const batchError = job.error ?? batch.error + + const deckRef = useRef(null) + const passRef = useRef(null) + const tryRef = useRef(null) + const questLogRef = useRef(null) + // The top card's exit until its decision settles, and the Side quest the + // last decision sent was for, until the deck moves past it. + const exit = useRef(null) + const leaving = useRef(null) + const drag = useRef(null) + // The top card's snap-back while it may still run; a new drag or a + // decision stops it so it does not fight the card's new motion. + const springing = useRef(null) + const stopSpring = useCallback(() => { + springing.current?.cancel() + springing.current = null + }, []) + + const { mutate: mutateDecision } = decision + const deciding = useRef(false) + const lock = useCallback(() => { + if (deciding.current) return false + deciding.current = true + setBusy(true) + return true + }, []) + // send records a decision. A failed one stops the card's exit, so a card + // useDecide put back on the deck is at rest; a saved one that started or + // joined a replenish batch follows its job. + const send = useCallback( + (variables: { quest: SideQuest; choice: Choice }) => { + leaving.current = variables.quest.id + mutateDecision(variables, { + onSuccess: (replenishJobId) => { + if (replenishJobId !== null) setBatchJobId(replenishJobId) + }, + onError: () => { + exit.current?.cancel() + leaving.current = null + }, + onSettled: () => { + exit.current = null + deciding.current = false + setBusy(false) + }, + }) + }, + [mutateDecision], + ) + const submit = useCallback( + (variables: { quest: SideQuest; choice: Choice }) => { + if (lock()) send(variables) + }, + [lock, send], + ) + // decide bumps the button, throws the top card out — or, motion reduced, + // fades it — then sends the decision; Try it bumps the Quest log pill too. + // A swipe's decision comes from the card's drag transform: it is thrown + // from there without a bump or wiggle. A button or key decision ends a + // drag in progress, putting the card back first. + const decide = useCallback( + (choice: Choice, from?: string) => { + if (top === undefined || !lock()) return + stopSpring() + const card = deckRef.current!.querySelector('article')! + if (from === undefined && drag.current !== null) { + drag.current = null + unfollow(card) + } + const reduced = prefersReducedMotion() + let run: Exit + if (reduced) { + run = fadeOut(card) + } else if (from === undefined) { + bump((choice === 'try' ? tryRef : passRef).current!) + run = choice === 'try' ? tryIt(card) : pass(card) + } else { + run = choice === 'try' ? tryIt(card, from) : pass(card, from) + } + if (from !== undefined) { + // Putting a swiped card back puts it back at rest. + const thrown = run + run = { + finished: thrown.finished, + cancel: () => { + thrown.cancel() + unfollow(card) + }, + } + } + exit.current = run + run.finished.then( + () => { + // The Quest log pill is missing while the Quest log fails to load. + if (choice === 'try' && !reduced && questLogRef.current !== null) bump(questLogRef.current) + send({ quest: top, choice }) + }, + (error: unknown) => { + if (!isAbort(error)) throw error + }, + ) + }, + [top, lock, send, stopSpring], + ) + + // swipe drags the top card: it follows the pointer, captured from the + // press, until the release decides past the threshold or springs it back + // short of it. The release decides from where the pointer let go, which + // may differ from its last move. A cancel, or losing the capture before + // the release, springs it back from its last move. One pointer drags at + // a time: a press while a drag is in progress is ignored, as is input + // while busy, and a drag held into a busy spell (a Retry) is put back on + // release. + const swipe = useMemo(() => { + function track(card: HTMLElement, current: Drag, event: PointerEvent) { + current.dx = event.clientX - current.x + current.transform = follow(card, current.dx, event.clientY - current.y) + } + function release(event: PointerEvent, decides: boolean) { + const card = event.currentTarget + const current = drag.current + if (current === null || current.pointerId !== event.pointerId) return + drag.current = null + const moved = current.transform !== null || event.clientX !== current.x || event.clientY !== current.y + if (decides && moved && !deciding.current) track(card, current, event) + if (current.transform === null) return + if (deciding.current) unfollow(card) + else if (decides && current.dx > SWIPE_THRESHOLD) decide('try', current.transform) + else if (decides && current.dx < -SWIPE_THRESHOLD) decide('pass', current.transform) + else if (prefersReducedMotion()) unfollow(card) + else springing.current = snapBack(card, current.transform) + } + return { + onPointerDown: (event) => { + if (drag.current !== null || deciding.current || !event.isPrimary || event.button !== 0) return + stopSpring() + event.currentTarget.setPointerCapture(event.pointerId) + drag.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY, dx: 0, transform: null } + }, + onPointerMove: (event) => { + const current = drag.current + if (current === null || current.pointerId !== event.pointerId || deciding.current) return + track(event.currentTarget, current, event) + }, + onPointerUp: (event) => release(event, true), + onPointerCancel: (event) => release(event, false), + // After a release the drag is already over, so this only ends a drag + // whose capture was lost before it. + onLostPointerCapture: (event) => release(event, false), + } + }, [decide, stopSpring]) + + // Leaving the page stops an exit before its decision is sent. + useEffect(() => () => exit.current?.cancel(), []) + + const topId = top?.id + // A drag belongs to the card it started on, which a new top card ends. + useLayoutEffect(() => { + drag.current = null + }, [topId]) + + // Once the deck moves past the card a decision was sent for, the cards + // behind it move up a depth. + useLayoutEffect(() => { + if (leaving.current === null || leaving.current === topId) return + leaving.current = null + if (topId === undefined || prefersReducedMotion()) return + promote([...deckRef.current!.querySelectorAll(':scope > article')]) + }, [topId]) + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + if (isEditable(event.target)) return + // A modal over the page (the Quest log sheet or its confirmation) makes + // the deck inert: its keys are the modal's, not the deck's. + if (deckRef.current === null || deckRef.current.closest('[inert]') !== null) return + if (event.key === 'ArrowLeft') decide('pass') + else if (event.key === 'ArrowRight') decide('try') + else return + event.preventDefault() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [decide]) + + // The batch stores its Side quests as it goes, so the deck is read again + // whenever it reports more, and once it ends. A read already out may be a + // decision's, which must not be cut short: the batch's read waits for it, + // then goes out after it, as it may predate what the batch reported. + const { stored } = batch + useEffect(() => { + if (job.status === undefined) return + const after = queryClient.isFetching({ queryKey: DECK_KEY }) > 0 + void queryClient + .invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) + .then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined)) + }, [queryClient, job.status, stored]) + + // A failed decision can be retried only while the deck's newest read + // still has its Side quest pending: anything else decided it already. + const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined + const retryable = pending !== undefined && pending !== null + + // What went wrong asking for or finding more Side quests, shown whatever + // the deck has: a replenish can fail while cards remain. While Get more now + // is asking again, the last batch's failure is not the news. + const batchFailure = startBatch.isPending ? null : startBatch.isError ? ( + Couldn't ask for more side quests. {startBatch.error.message} + ) : batchError === null ? null : jobRunning ? ( + Couldn't check on new side quests. {batchError.message} + ) : ( + Couldn't find new side quests. {batchError.message} + ) + + let body + if (deck.isError) { + body = void deck.refetch()}>Couldn't load your side quests. {deck.error.message} + } else if (deck.isPending || (cards!.length === 0 && (finding || decision.isPending))) { + // An empty deck is not caught up while a batch finds more Side quests, + // or while the last decision may yet start one or come back refused. + body = ( +
+
+ {DECK_DEPTH.map((depth) => ( + + ))} +
+ {finding &&

Finding new side quests…

} +
+ ) + } else if (top === undefined) { + body = ( + + New side quests arrive Sunday. + + ) + } else { + const shown = cards!.slice(0, DECK_DEPTH.length) + body = ( + <> + {/* The deck keeps all three depths: a short deck fills those it has + no card for with skeleton cards while a batch finds more Side + quests, and with blank cards, the stack's edge, while none is + coming. */} +
+ {shown.map((quest, depth) => ( + + ))} + {DECK_DEPTH.slice(shown.length).map((depth) => + finding ? ( + + ) : ( + +
+ decide('pass')}> + + + decide('try')}> + + +
+ {finding &&

Finding new side quests…

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

Discover

+

Side quests

+
+ {questLog.isSuccess && } +
+ {questLog.isError && ( + void questLog.refetch()}>Couldn't load the Quest log. {questLog.error.message} + )} + {body} + {batchFailure} + {/* A failed decision is shown whatever the deck has come to since. */} + {decision.isError && + (retryable ? ( + submit(decision.variables)}>Couldn't save that choice. {decision.error.message} + ) : pending === undefined ? ( + Couldn't save that choice. {decision.error.message} + ) : ( + That side quest has been decided already. {decision.error.message} + ))} +
+ ) +} diff --git a/web/src/discover/QuestLogSheet.module.css b/web/src/discover/QuestLogSheet.module.css new file mode 100644 index 0000000..75fce9c --- /dev/null +++ b/web/src/discover/QuestLogSheet.module.css @@ -0,0 +1,38 @@ +/* The Quest log sheet (mock 1a's "Quest log · N" pill opens it). */ +.sheet { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 560px; + margin: 0 auto; +} + +.title { + margin: 0; + font-family: var(--font-heading); + font-size: 22px; + font-weight: 400; +} + +.rows { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +/* The 48 px washed photo; Photo applies the washed filter. */ +.photo { + width: 48px; + height: 48px; +} + +.empty { + margin: 0; + font-family: var(--font-body); + font-size: 14px; + line-height: 1.45; + color: var(--color-neutral-700); +} diff --git a/web/src/discover/QuestLogSheet.tsx b/web/src/discover/QuestLogSheet.tsx new file mode 100644 index 0000000..cafdfa7 --- /dev/null +++ b/web/src/discover/QuestLogSheet.tsx @@ -0,0 +1,147 @@ +import { Trash2 } from 'lucide-react' +import { useEffect, useId, useRef, useState } from 'react' +import { useNavigate } from 'react-router' +import { Confirm } from '../settings/LeaveGuard.tsx' +import { Icon } from '../ui/Icon.tsx' +import { IconButton } from '../ui/IconButton.tsx' +import { ListRow } from '../ui/ListRow.tsx' +import { Photo } from '../ui/Photo.tsx' +import { Sheet } from '../ui/Sheet.tsx' +import { useRemoveFromQuestLog, type QuestLogDinner } from './api.ts' +import { DIFFICULTY_LABEL } from './DeckCard.tsx' +import styles from './QuestLogSheet.module.css' + +export type QuestLogSheetProps = { + open: boolean + // The Quest log, as useQuestLog reads it. + dinners: QuestLogDinner[] + onClose: () => void +} + +// details is a Quest-log dinner's "difficulty · minutes" line, or null when +// neither is known. +function details(dinner: QuestLogDinner): string | null { + const parts = [ + dinner.difficulty !== null && DIFFICULTY_LABEL[dinner.difficulty], + dinner.minutes_total !== null && `${dinner.minutes_total} min`, + ].filter((part): part is string => typeof part === 'string') + return parts.length === 0 ? null : parts.join(' · ') +} + +// QuestLogSheet is the Quest log as a bottom sheet: each dinner with its +// washed photo, name, difficulty and minutes. A row opens the dinner; its +// remove button, once confirmed, archives the dinner. +export function QuestLogSheet({ open, dinners, onClose }: QuestLogSheetProps) { + const titleId = useId() + const navigate = useNavigate() + const remove = useRemoveFromQuestLog() + // The dinner whose removal is being confirmed. + const [removing, setRemoving] = useState(null) + // The dinner just removed and its place in the list, until focus has + // moved on from it. + const removed = useRef<{ id: string; index: number } | null>(null) + const removeButtons = useRef(new Map()) + const sheetRef = useRef(null) + // Whether a removal has been sent and not yet settled. The ref is set as + // Remove is activated, so a dismissal or a second Remove that arrives before + // the next render is already refused; the state renders it. + const sending = useRef(false) + const [sendingShown, setSendingShown] = useState(false) + + // Once a removal's confirmation has closed and the dinner has left the list, + // focus goes to the remove button of the dinner now in its place, or of the + // one before it when it was the last, or to the Quest log sheet itself when + // none is left. Otherwise it would be left on the removed dinner's button, + // which the confirmation hands it back to, and which is then gone. + useEffect(() => { + const done = removed.current + if (removing !== null || done === null || dinners.some((d) => d.id === done.id)) return + removed.current = null + const next = dinners[Math.min(done.index, dinners.length - 1)] + if (next) removeButtons.current.get(next.id)!.focus() + else sheetRef.current!.closest('[role="dialog"]')!.focus() + }, [removing, dinners]) + + function setSending(value: boolean) { + sending.current = value + setSendingShown(value) + } + + function cancelRemove() { + if (sending.current) return + remove.reset() + setRemoving(null) + } + + function confirmRemove() { + if (sending.current) return + setSending(true) + remove.mutate(removing!, { + onSuccess: (_data, dinner) => { + removed.current = { id: dinner.id, index: dinners.findIndex((d) => d.id === dinner.id) } + setRemoving(null) + }, + onSettled: () => setSending(false), + }) + } + + return ( + <> + +
+

+ Quest log +

+ {dinners.length === 0 ? ( +

Your Quest log is empty — swipe right on a side quest to add one.

+ ) : ( +
    + {dinners.map((dinner) => ( +
  • +
  • + ))} +
+ )} +
+
+ + It leaves your Quest log for good. + + + ) +} diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts new file mode 100644 index 0000000..ea8d070 --- /dev/null +++ b/web/src/discover/api.ts @@ -0,0 +1,279 @@ +import { useMutation, useMutationState, useQuery, useQueryClient } from '@tanstack/react-query' +import { useApi } from '../api/index.ts' +import { isRecord } from '../api/errors.ts' +import { DIFFICULTIES, DINNERS_PATH, dinnerPath, oneOf, parseDinner, photoId, type Difficulty, type Regular } from '../dinners/api.ts' + +export const SUGGESTIONS_PATH = '/api/v1/suggestions' +export const DECK_PATH = `${SUGGESTIONS_PATH}/deck` +export const BATCHES_PATH = `${SUGGESTIONS_PATH}/batches` +export const QUEST_LOG_PATH = `${DINNERS_PATH}?status=quest_log` + +// DECK_KEY is the deck's query; QUEST_LOG_KEY the Quest log's; +// DECIDE_KEY the decisions'. +export const DECK_KEY = ['suggestions', 'deck'] as const +export const QUEST_LOG_KEY = ['dinners', 'quest_log'] as const +const DECIDE_KEY = ['suggestions', 'decide'] as const + +// SideQuest is a pending suggestion as the deck shows it. dinner_id is the +// dinner Try it moves to the Quest log; photo_id is the stored photo's id, +// from photo_url; reason is the "Why this?". +export type SideQuest = { + id: string + dinner_id: string + name: string + photo_id: string | undefined + difficulty: Difficulty | null + reason: string +} + +// Choice is the household's decision on a Side quest: Try it or Pass. +export type Choice = 'try' | 'pass' + +// Batch is a POST /api/v1/suggestions/batches answer, or the deck's active +// or failed batch: the batch and the job running it, which GET +// /api/v1/jobs/{id} reports on. +export type Batch = { batch_id: string; job_id: string } + +// Deck is the deck as GET /api/v1/suggestions/deck reads it: the pending +// Side quests, oldest first; the batch pending or running, if any; and the +// newest batch when it failed, which a later batch supersedes. At most one +// of the two batches is set. +export type Deck = { suggestions: SideQuest[]; activeBatch: Batch | null; failedBatch: Batch | null } + +function isBatch(body: unknown): body is Batch { + return isRecord(body) && typeof body.batch_id === 'string' && typeof body.job_id === 'string' +} + +// BATCH_JOB_KIND is the kind of the job running a suggestion batch. +export const BATCH_JOB_KIND = 'suggestion_batch' + +// BatchResult is the suggestion batch job's result, published as it goes. +export type BatchResult = { batch_id: string; stage: string; suggestion_ids: string[]; dropped: number } + +// QuestLogDinner is a dinner in the Quest log, as the Quest log sheet lists +// it: photo, name, difficulty and minutes. +export type QuestLogDinner = Pick + +// DECISION_STATUS is the status a suggestion has once each choice is saved. +const DECISION_STATUS: Record = { try: 'accepted', pass: 'declined' } + +// parseBatchResult checks a suggestion batch job's result, which useJob +// passes on unread. +export function parseBatchResult(where: string, body: unknown): BatchResult { + if ( + !isRecord(body) || + typeof body.batch_id !== 'string' || + typeof body.stage !== 'string' || + !Array.isArray(body.suggestion_ids) || + !body.suggestion_ids.every((id) => typeof id === 'string') || + typeof body.dropped !== 'number' + ) { + throw new Error(`${where}: result is not a suggestion batch`) + } + return { batch_id: body.batch_id, stage: body.stage, suggestion_ids: body.suggestion_ids as string[], dropped: body.dropped } +} + +function parseSideQuest(where: string, body: unknown): SideQuest { + if ( + !isRecord(body) || + typeof body.id !== 'string' || + typeof body.dinner_id !== 'string' || + typeof body.name !== 'string' || + typeof body.reason !== 'string' + ) { + throw new Error(`${where}: response is not a suggestion`) + } + return { + id: body.id, + dinner_id: body.dinner_id, + name: body.name, + photo_id: photoId(where, body.photo_url), + difficulty: body.difficulty === null ? null : oneOf(where, 'difficulty', DIFFICULTIES, body.difficulty), + reason: body.reason, + } +} + +// useDeck is the pending Side quests, oldest first, the batch pending or +// running and the newest batch when it failed (GET +// /api/v1/suggestions/deck). +export function useDeck() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: DECK_KEY, + queryFn: async (): Promise => { + const where = `GET ${DECK_PATH}` + const body = await apiFetch(DECK_PATH) + if (!isRecord(body) || !Array.isArray(body.suggestions)) throw new Error(`${where}: response has no suggestions list`) + if (body.active_batch !== null && !isBatch(body.active_batch)) throw new Error(`${where}: response has no active_batch`) + if (body.failed_batch !== null && !isBatch(body.failed_batch)) throw new Error(`${where}: response has no failed_batch`) + return { + suggestions: body.suggestions.map((suggestion) => parseSideQuest(where, suggestion)), + activeBatch: body.active_batch === null ? null : { batch_id: body.active_batch.batch_id, job_id: body.active_batch.job_id }, + failedBatch: body.failed_batch === null ? null : { batch_id: body.failed_batch.batch_id, job_id: body.failed_batch.job_id }, + } + }, + }) +} + +// useQuestLog is the dinners in the Quest log, in the server's order (GET +// /api/v1/dinners?status=quest_log). The server leaves out a dinner already +// on the plan for the current week or a later one: it is scheduled. +export function useQuestLog() { + const { apiFetch } = useApi() + return useQuery({ + queryKey: QUEST_LOG_KEY, + queryFn: async (): Promise => { + const where = `GET ${QUEST_LOG_PATH}` + const body = await apiFetch(QUEST_LOG_PATH) + if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: response has no dinners list`) + return body.dinners.map((dinner) => { + const { id, name, photo_id, difficulty, minutes_total } = parseDinner(where, dinner) + return { id, name, photo_id, difficulty, minutes_total } + }) + }, + }) +} + +// useRemoveFromQuestLog archives a Quest-log dinner (DELETE +// /api/v1/dinners/{id}). Once the server has archived it, it leaves the +// Quest log, and the Quest log is read again. +export function useRemoveFromQuestLog() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (dinner: QuestLogDinner) => { + await apiFetch(dinnerPath(dinner.id), { method: 'DELETE' }) + }, + onSuccess: async (_data, dinner) => { + queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.id !== dinner.id)) + await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + }, + }) +} + +// useStartBatch asks for more Side quests now (POST +// /api/v1/suggestions/batches). The server answers with the batch already +// running when there is one. +export function useStartBatch() { + const { apiFetch } = useApi() + return useMutation({ + mutationFn: async (): Promise => { + const body = await apiFetch(BATCHES_PATH, { method: 'POST' }) + if (!isBatch(body)) throw new Error(`POST ${BATCHES_PATH}: response is not a batch`) + return { batch_id: body.batch_id, job_id: body.job_id } + }, + }) +} + +// stillPending says whether the deck's newest read has the Side quest id +// pending, after a failed decision on it: the Side quest when it does, null +// when it does not, and undefined when that read failed, which says neither. +// deck is the deck query's state, from the cache or useDeck. +export function stillPending(deck: { status: string; data: Deck | undefined } | undefined, id: string): SideQuest | null | undefined { + if (deck?.status !== 'success') return undefined + return deck.data!.suggestions.find((q) => q.id === id) ?? null +} + +// useDeciding is the ids of the Side quests whose decisions are pending: +// sent and not yet settled. The deck shown leaves them out, whatever a read +// of the deck has said meanwhile. +export function useDeciding(): Set { + const ids = useMutationState({ + filters: { mutationKey: DECIDE_KEY, status: 'pending' }, + select: (mutation) => (mutation.state.variables as { quest: SideQuest }).quest.id, + }) + return new Set(ids) +} + +// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass +// (POST /api/v1/suggestions/{id}/decline) on the Side quest the household +// decided on, whether or not a read of the deck has since taken it away. +// The answer must be that suggestion, decided that way; the mutation's data +// is the id of the replenish job the decision started or joined, or null. +// +// The decision is applied at once: the Side quest leaves the deck shown +// while it is pending (see useDeciding) and, on Try it, its dinner joins the +// Quest log unless it is there already. Nothing else is worked out here: +// the server is the record of both. However the decision ends, saved or +// failed, the deck and the Quest log are read again, and the decision is +// pending until those reads land, so the next one (which DiscoverPage locks +// out until then) cannot start under them. A read under way when the +// decision ends is replaced by the read made after it, and DiscoverPage's +// batch reads wait for that one rather than replace it. +// A failed decision waits for the deck read it makes itself. Whether its +// Side quest is still pending is then the deck query's to say, from its +// newest successful read: once the reads have landed, a Side quest the deck +// still has goes back on top of it. +export function useDecide() { + const { apiFetch } = useApi() + const queryClient = useQueryClient() + + // send POSTs the decision and checks the answer. + async function send(id: string, choice: Choice): Promise { + const action = choice === 'try' ? 'accept' : 'decline' + const path = `${SUGGESTIONS_PATH}/${encodeURIComponent(id)}/${action}` + const body = await apiFetch(path, { method: 'POST' }) + if ( + !isRecord(body) || + !isRecord(body.suggestion) || + body.suggestion.id !== id || + body.suggestion.status !== DECISION_STATUS[choice] + ) { + throw new Error(`POST ${path}: response is not the ${DECISION_STATUS[choice]} suggestion`) + } + if (body.replenish_job_id !== null && typeof body.replenish_job_id !== 'string') { + throw new Error(`POST ${path}: response has no replenish_job_id`) + } + return body.replenish_job_id + } + + return useMutation({ + mutationKey: DECIDE_KEY, + mutationFn: async ({ quest, choice }) => { + try { + return await send(quest.id, choice) + } catch (error) { + // The deck is read again as the decision's own fetch, replacing any + // read already out. A failed read is the deck query's error, shown + // with the deck; the decision's own error is the one thrown. + await queryClient + .getQueryCache() + .build(queryClient, { queryKey: DECK_KEY }) + .fetch(undefined, { cancelRefetch: true }) + .catch(() => undefined) + throw error + } + }, + onMutate: ({ quest, choice }) => { + if (choice !== 'try') return + // A stale deck can offer a Side quest whose dinner the Quest log has. + queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => + dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) + ? dinners + : // The deck does not say the minutes; the Quest log read after the + // decision does. + dinners.concat({ + id: quest.dinner_id, + name: quest.name, + photo_id: quest.photo_id, + difficulty: quest.difficulty, + minutes_total: null, + }), + ) + }, + onSettled: async (_replenishJobId, error, { quest }) => { + if (error === null) { + await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })]) + return + } + // A failed decision has read the deck already; any read since is newer. + await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }) + const pending = stillPending(queryClient.getQueryState(DECK_KEY), quest.id) + if (pending === null || pending === undefined) return + queryClient.setQueryData(DECK_KEY, (deck) => ({ + ...deck!, + suggestions: [pending, ...deck!.suggestions.filter((q) => q.id !== pending.id)], + })) + }, + }) +} diff --git a/web/src/discover/motion.test.ts b/web/src/discover/motion.test.ts new file mode 100644 index 0000000..2352f08 --- /dev/null +++ b/web/src/discover/motion.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { stubAnimate, type FakeAnimation } from '../test/animate.ts' +import { bump, dragTransform, fadeOut, follow, isAbort, pass, promote, snapBack, tryIt, unfollow } from './motion.ts' + +let animations: FakeAnimation[] + +beforeEach(() => { + animations = stubAnimate({ autoFinish: false }) +}) + +afterEach(() => { + Reflect.deleteProperty(Element.prototype, 'animate') +}) + +// card builds a deck card with the parts DeckCard marks. +function card() { + const article = document.createElement('article') + article.innerHTML = '' + return { + article, + stamp: article.querySelector('[data-part="try-stamp"]')!, + passStamp: article.querySelector('[data-part="pass-stamp"]')!, + shade: article.querySelector('[data-part="shade"]')!, + } +} + +// settled says whether a promise has settled after pending callbacks run. +async function settled(promise: Promise) { + let done = false + void promise.then( + () => (done = true), + () => (done = true), + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + return done +} + +const FLY_OFF = { transform: 'translate(150%,-60px) rotate(28deg)', opacity: 0 } + +describe('motion', () => { + it('bumps: scale 1 → .88 → 1.06 → 1, 300 ms ease-out', () => { + const button = document.createElement('button') + bump(button) + expect(animations).toHaveLength(1) + expect(animations[0]!.target).toBe(button) + expect(animations[0]!.keyframes).toEqual([ + { transform: 'scale(1)' }, + { transform: 'scale(.88)' }, + { transform: 'scale(1.06)' }, + { transform: 'scale(1)' }, + ]) + expect(animations[0]!.options).toEqual({ duration: 300, easing: 'ease-out' }) + }) + + it('Try it pops the stamp and wiggles, then throws the card right', async () => { + const { article, stamp } = card() + const exit = tryIt(article) + + const [pop, wiggle] = animations + expect(pop!.target).toBe(stamp) + expect(pop!.keyframes).toEqual([ + { opacity: 0, transform: 'rotate(-12deg) scale(1.5)' }, + { opacity: 1, transform: 'rotate(-12deg) scale(1)' }, + ]) + expect(pop!.options).toEqual({ duration: 220, easing: 'cubic-bezier(.34,1.4,.64,1)', fill: 'forwards' }) + expect(wiggle!.target).toBe(article) + expect(wiggle!.keyframes).toEqual(['0deg', '-4deg', '4deg', '-3deg', '2deg', '0deg'].map((a) => ({ transform: `rotate(${a})` }))) + expect(wiggle!.options).toEqual({ duration: 420, easing: 'ease-in-out' }) + expect(animations).toHaveLength(2) + + // The throw waits for the wiggle. + pop!.finish() + wiggle!.finish() + await expect.poll(() => animations.length).toBe(3) + const fly = animations[2]! + expect(fly.target).toBe(article) + expect(fly.keyframes).toEqual([ + { transform: 'translate(0px,0px) rotate(0deg)' }, + { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 }, + FLY_OFF, + ]) + expect(fly.options).toEqual({ duration: 480, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }) + + expect(await settled(exit.finished)).toBe(false) + fly.finish() + await expect(exit.finished).resolves.toBeUndefined() + }) + + it('Try it from a dragged transform skips the wiggle and throws in 340 ms', async () => { + const { article } = card() + const exit = tryIt(article, 'translate(120px,9px) rotate(6.7deg)') + await expect.poll(() => animations.length).toBe(2) + const fly = animations[1]! + expect(fly.target).toBe(article) + expect(fly.keyframes).toEqual([{ transform: 'translate(120px,9px) rotate(6.7deg)' }, FLY_OFF]) + expect(fly.options).toEqual({ duration: 340, easing: 'cubic-bezier(.55,0,.8,.4)', fill: 'forwards' }) + fly.finish() + await expect(exit.finished).resolves.toBeUndefined() + }) + + it('Pass folds the card over its left edge under a darkening shade', async () => { + const { article, shade } = card() + const exit = pass(article) + + const [darken, fold] = animations + expect(animations).toHaveLength(2) + expect(darken!.target).toBe(shade) + expect(darken!.keyframes).toEqual([{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }]) + expect(darken!.options).toEqual({ duration: 720, fill: 'forwards' }) + expect(fold!.target).toBe(article) + expect(fold!.keyframes).toEqual([ + { transformOrigin: '0% 50%', transform: 'translate(0px,0px) rotate(0deg) rotateY(0deg)', easing: 'cubic-bezier(.45,0,.85,.55)' }, + { + transformOrigin: '0% 50%', + transform: 'translate(0px,0px) rotate(0deg) rotateY(-80deg)', + offset: 0.45, + easing: 'cubic-bezier(.4,0,.9,.6)', + }, + { transformOrigin: '0% 50%', transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }, + ]) + expect(fold!.options).toEqual({ duration: 720, fill: 'forwards' }) + + darken!.finish() + expect(await settled(exit.finished)).toBe(false) + fold!.finish() + await expect(exit.finished).resolves.toBeUndefined() + }) + + it('Pass from a dragged transform folds from there', () => { + const { article } = card() + pass(article, 'translate(-130px,6px) rotate(-7deg)') + const fold = animations[1]! + expect(fold.keyframes.map((k) => k.transform)).toEqual([ + 'translate(-130px,6px) rotate(-7deg) rotateY(0deg)', + 'translate(-130px,6px) rotate(-7deg) rotateY(-80deg)', + 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', + ]) + }) + + it('fades out in 200 ms with reduced motion', async () => { + const { article } = card() + const exit = fadeOut(article) + expect(animations).toHaveLength(1) + expect(animations[0]!.target).toBe(article) + expect(animations[0]!.keyframes).toEqual([{ opacity: 1 }, { opacity: 0 }]) + expect(animations[0]!.options).toEqual({ duration: 200, fill: 'forwards' }) + animations[0]!.finish() + await expect(exit.finished).resolves.toBeUndefined() + }) + + it('promotes the top two cards from the depth behind, 300 ms', () => { + const cards = [card().article, card().article, card().article] + promote(cards) + expect(animations.map((a) => a.target)).toEqual([cards[0], cards[1]]) + expect(animations.map((a) => a.keyframes)).toEqual([ + [{ transform: 'translateY(8px) scale(0.95)' }, { transform: 'translateY(0px) scale(1)' }], + [{ transform: 'translateY(16px) scale(0.9)' }, { transform: 'translateY(8px) scale(0.95)' }], + ]) + for (const a of animations) expect(a.options).toEqual({ duration: 300, easing: 'cubic-bezier(.2,.8,.2,1)' }) + }) + + it('cancel stops an exit and never starts its next step', async () => { + const { article } = card() + const exit = tryIt(article) + animations[1]!.finish() + exit.cancel() + const error: unknown = await exit.finished.catch((e: unknown) => e) + expect(isAbort(error)).toBe(true) + expect(animations).toHaveLength(2) + expect(animations.map((a) => a.playState)).toEqual(['idle', 'idle']) + }) + + it('drags: translate(dx, dy·0.3) rotate(dx/18 deg)', () => { + expect(dragTransform(90, 20)).toBe('translate(90px,6px) rotate(5deg)') + expect(dragTransform(-36, -10)).toBe('translate(-36px,-3px) rotate(-2deg)') + }) + + it.each([ + [50, '0.5', '0'], + [-50, '0', '0.5'], + [160, '1', '0'], + [-160, '0', '1'], + [0, '0', '0'], + ])('follows a drag of %i px with the stamps at Try it! %s, Pass %s', (dx, tryOpacity, passOpacity) => { + const { article, stamp, passStamp } = card() + expect(follow(article, dx, 0)).toBe(dragTransform(dx, 0)) + expect(article.style.transform).toBe(dragTransform(dx, 0)) + expect(stamp.style.opacity).toBe(tryOpacity) + expect(passStamp.style.opacity).toBe(passOpacity) + expect(animations).toEqual([]) + }) + + it('unfollows at once, back to the stylesheet', () => { + const { article, stamp, passStamp } = card() + follow(article, -80, 12) + unfollow(article) + expect(article.getAttribute('style')).toBe('') + expect(stamp.getAttribute('style')).toBe('') + expect(passStamp.getAttribute('style')).toBe('') + expect(animations).toEqual([]) + }) + + it('snaps back from the drag transform to rest, 420 ms spring, and hides the stamps', () => { + const { article, stamp, passStamp } = card() + const from = follow(article, 70, 30) + snapBack(article, from) + expect(article.style.transform).toBe('') + expect(stamp.style.opacity).toBe('') + expect(passStamp.style.opacity).toBe('') + expect(animations).toHaveLength(1) + expect(animations[0]!.target).toBe(article) + expect(animations[0]!.keyframes).toEqual([{ transform: 'translate(70px,9px) rotate(3.888888888888889deg)' }, { transform: 'translate(0px,0px) rotate(0deg)' }]) + expect(animations[0]!.options).toEqual({ duration: 420, easing: 'cubic-bezier(.34,1.4,.64,1)' }) + }) + + it('fails fast on a card without its parts', () => { + expect(() => tryIt(document.createElement('article'))).toThrow('deck card has no try-stamp') + expect(() => pass(document.createElement('article'))).toThrow('deck card has no shade') + expect(() => follow(document.createElement('article'), 10, 0)).toThrow('deck card has no try-stamp') + }) +}) diff --git a/web/src/discover/motion.ts b/web/src/discover/motion.ts new file mode 100644 index 0000000..b620edb --- /dev/null +++ b/web/src/discover/motion.ts @@ -0,0 +1,178 @@ +// The deck's motion, from the handoff's Motion §2 (Try it), §3 (Pass), +// §4 (swipe), §5 (deck advance) and §6 (reduced motion), with the Web +// Animations API. The exits take the card's starting transform so the swipe +// gesture can throw a dragged card from where it was let go. + +const SPRING = 'cubic-bezier(.34,1.4,.64,1)' +const THROW = 'cubic-bezier(.55,0,.8,.4)' +const SOFT = 'cubic-bezier(.2,.8,.2,1)' +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)' + +// REST is the top card's transform when nothing moves it, written as the +// translate + rotate a drag gives it so the exits interpolate function by +// function. +export const REST = 'translate(0px,0px) rotate(0deg)' + +// DEPTH_TRANSFORM is each deck depth's transform, as the stylesheet places it. +export const DEPTH_TRANSFORM = ['translateY(0px) scale(1)', 'translateY(8px) scale(0.95)', 'translateY(16px) scale(0.9)'] as const + +const FLY_OFF = 'translate(150%,-60px) rotate(28deg)' +const PASS_ORIGIN = '0% 50%' + +// An Exit is a card leaving the deck: finished resolves once it is off +// screen and rejects with an AbortError if cancel stops it first. cancel +// also undoes a finished exit, putting the card back. +export type Exit = { + finished: Promise + cancel: () => void +} + +export function prefersReducedMotion(): boolean { + return window.matchMedia(REDUCED_MOTION_QUERY).matches +} + +// isAbort says whether an exit's finished rejected because it was canceled. +export function isAbort(error: unknown): boolean { + return error instanceof DOMException && error.name === 'AbortError' +} + +// bump is the press bump of the Try it and Pass buttons and the Quest log pill. +export function bump(element: Element): Animation { + return element.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(.88)' }, { transform: 'scale(1.06)' }, { transform: 'scale(1)' }], + { duration: 300, easing: 'ease-out' }, + ) +} + +// tryIt pops the "Try it!" stamp and throws the card off to the right. From +// rest it wiggles first and winds up before the throw; from a dragged +// transform it skips both and throws faster. +export function tryIt(card: HTMLElement, from?: string): Exit { + const exit = recorder() + exit.run(part(card, 'try-stamp'), [ + { opacity: 0, transform: 'rotate(-12deg) scale(1.5)' }, + { opacity: 1, transform: 'rotate(-12deg) scale(1)' }, + ], { duration: 220, easing: SPRING, fill: 'forwards' }) + const wiggled = + from === undefined + ? exit.run(card, [ + { transform: 'rotate(0deg)' }, + { transform: 'rotate(-4deg)' }, + { transform: 'rotate(4deg)' }, + { transform: 'rotate(-3deg)' }, + { transform: 'rotate(2deg)' }, + { transform: 'rotate(0deg)' }, + ], { duration: 420, easing: 'ease-in-out' }).finished + : Promise.resolve() + const finished = wiggled.then(() => + from === undefined + ? exit.run(card, [ + { transform: REST }, + { transform: 'translate(-14px,4px) rotate(-3deg)', offset: 0.18 }, + { transform: FLY_OFF, opacity: 0 }, + ], { duration: 480, easing: THROW, fill: 'forwards' }).finished + : exit.run(card, [{ transform: from }, { transform: FLY_OFF, opacity: 0 }], { duration: 340, easing: THROW, fill: 'forwards' }) + .finished, + ) + return exit.done(finished) +} + +// pass folds the card over its left edge like a page, darkening it, then +// drops it off to the left. A dragged card folds from where it was let go. +export function pass(card: HTMLElement, from: string = REST): Exit { + const exit = recorder() + exit.run(part(card, 'shade'), [{ opacity: 0 }, { opacity: 0.4, offset: 0.45 }, { opacity: 0.55 }], { + duration: 720, + fill: 'forwards', + }) + const finished = exit.run(card, [ + { transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(0deg)`, easing: 'cubic-bezier(.45,0,.85,.55)' }, + { transformOrigin: PASS_ORIGIN, transform: `${from} rotateY(-80deg)`, offset: 0.45, easing: 'cubic-bezier(.4,0,.9,.6)' }, + { transformOrigin: PASS_ORIGIN, transform: 'translate(-45%,560px) rotate(-35deg) rotateY(-90deg)', opacity: 0 }, + ], { duration: 720, fill: 'forwards' }).finished + return exit.done(finished) +} + +// dragTransform is the top card's transform dragged dx, dy from where the +// pointer went down. +export function dragTransform(dx: number, dy: number): string { + return `translate(${dx}px,${dy * 0.3}px) rotate(${dx / 18}deg)` +} + +// follow moves a dragged card to its drag transform and shows each stamp as +// far as the drag has gone toward its side, fully at 100 px. It returns the +// transform, for the exit or snap-back the release starts. +export function follow(card: HTMLElement, dx: number, dy: number): string { + const transform = dragTransform(dx, dy) + card.style.transform = transform + part(card, 'try-stamp').style.opacity = String(clamp(dx / 100)) + part(card, 'pass-stamp').style.opacity = String(clamp(-dx / 100)) + return transform +} + +// unfollow puts a dragged card and its stamps back where the stylesheet +// places them, at once. +export function unfollow(card: HTMLElement) { + card.style.removeProperty('transform') + part(card, 'try-stamp').style.removeProperty('opacity') + part(card, 'pass-stamp').style.removeProperty('opacity') +} + +// snapBack springs a card let go short of a decision back to rest from its +// drag transform, and hides its stamps. +export function snapBack(card: HTMLElement, from: string): Animation { + unfollow(card) + return card.animate([{ transform: from }, { transform: REST }], { duration: 420, easing: SPRING }) +} + +// fadeOut is both exits under prefers-reduced-motion: reduce. +export function fadeOut(card: HTMLElement): Exit { + const exit = recorder() + return exit.done(exit.run(card, [{ opacity: 1 }, { opacity: 0 }], { duration: 200, fill: 'forwards' }).finished) +} + +// promote moves the cards left after an exit, top first, from the depth +// behind to their own. +export function promote(cards: HTMLElement[]): Animation[] { + return cards.slice(0, DEPTH_TRANSFORM.length - 1).map((card, depth) => + card.animate([{ transform: DEPTH_TRANSFORM[depth + 1] }, { transform: DEPTH_TRANSFORM[depth] }], { + duration: 300, + easing: SOFT, + }), + ) +} + +function clamp(value: number): number { + return Math.min(1, Math.max(0, value)) +} + +// part is the card's element marked data-part="name", which DeckCard renders. +function part(card: HTMLElement, name: string): HTMLElement { + const element = card.querySelector(`[data-part="${name}"]`) + if (element === null) throw new Error(`deck card has no ${name}`) + return element +} + +// recorder keeps every animation an exit starts, so cancel can stop them all. +// A step that would start after cancel aborts instead. +function recorder() { + const animations: Animation[] = [] + let canceled = false + return { + run(element: Element, keyframes: Keyframe[], options: KeyframeAnimationOptions): Animation { + if (canceled) throw new DOMException('The exit was canceled.', 'AbortError') + const animation = element.animate(keyframes, options) + animations.push(animation) + return animation + }, + done(finished: Promise): Exit { + return { + finished: finished.then(() => undefined), + cancel: () => { + canceled = true + animations.forEach((animation) => animation.cancel()) + }, + } + }, + } +} diff --git a/web/src/settings/LeaveGuard.tsx b/web/src/settings/LeaveGuard.tsx index 35ae711..26ded91 100644 --- a/web/src/settings/LeaveGuard.tsx +++ b/web/src/settings/LeaveGuard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, type ReactNode } from 'react' +import { useEffect, useId, useLayoutEffect, useRef, type ReactNode } from 'react' import { useBlocker } from 'react-router' import { Button } from '../ui/Button.tsx' import { Sheet } from '../ui/Sheet.tsx' @@ -36,6 +36,9 @@ type ConfirmProps = { confirmLabel: string cancelLabel?: string busy?: boolean + // When false, the cancel button, Escape and the scrim do nothing, so the + // sheet stays until the action settles. + dismissible?: boolean error?: string | null onCancel: () => void onConfirm: () => void @@ -49,14 +52,28 @@ export function Confirm({ confirmLabel, cancelLabel = 'Cancel', busy = false, + dismissible = true, error = null, onCancel, onConfirm, }: ConfirmProps) { const titleId = useId() + const sheetRef = useRef(null) + // A focused button that becomes disabled either keeps focus while disabled or + // loses it to the page, depending on the browser; the dialog takes it instead. + useLayoutEffect(() => { + const dialog = sheetRef.current?.closest('[role="dialog"]') + if (!dialog) return + const active = document.activeElement + const lost = active === null || active === document.body || (dialog.contains(active) && active.matches(':disabled')) + if (lost) dialog.focus() + }, [busy, dismissible]) + function dismiss() { + if (dismissible) onCancel() + } return ( - -
+ +

{title}

@@ -67,7 +84,7 @@ export function Confirm({

)}
- + {onRetry !== undefined && } ) } diff --git a/web/src/ui/IconButton.module.css b/web/src/ui/IconButton.module.css index 7dd0c66..2995110 100644 --- a/web/src/ui/IconButton.module.css +++ b/web/src/ui/IconButton.module.css @@ -29,6 +29,20 @@ height: 62px; } +/* A shown label sits 6 px under the circle, and the button grows to hold it. */ +.labelled { + flex-direction: column; + gap: 6px; + width: auto; + min-width: 62px; + height: auto; + min-height: 62px; + color: var(--color-neutral-700); + font-family: var(--font-body); + font-size: 11px; + font-weight: 700; +} + .face { display: inline-flex; align-items: center; @@ -57,6 +71,11 @@ color: var(--color-text); } +.neutral { + background: var(--color-neutral-100); + color: var(--color-text); +} + .accent { background: var(--color-accent); color: var(--on-accent); diff --git a/web/src/ui/IconButton.test.tsx b/web/src/ui/IconButton.test.tsx index 0341c1a..e909cdd 100644 --- a/web/src/ui/IconButton.test.tsx +++ b/web/src/ui/IconButton.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' +import { declaredStyle } from '../test/cssRules.ts' import { hitArea, expectMinHitArea } from '../test/hitArea.ts' import { IconButton } from './IconButton.tsx' @@ -31,4 +32,21 @@ describe('IconButton', () => { expectMinHitArea(button) expect(hitArea(button)).toEqual({ width: 62, height: 62 }) }) + + it('shows its label as an 11 px caption under the circle, which names it', () => { + render( + + + , + ) + const button = screen.getByRole('button', { name: 'Pass' }) + expect(button).not.toHaveAttribute('aria-label') + expect(button).toHaveTextContent('Pass') + expect(getComputedStyle(button).fontSize).toBe('11px') + expect(getComputedStyle(button).flexDirection).toBe('column') + expectMinHitArea(button) + const face = button.firstElementChild! + expect(hitArea(face)).toEqual({ width: 62, height: 62 }) + expect(declaredStyle(face).background).toBe('var(--color-neutral-100)') + }) }) diff --git a/web/src/ui/IconButton.tsx b/web/src/ui/IconButton.tsx index 1a5ab73..6da1b22 100644 --- a/web/src/ui/IconButton.tsx +++ b/web/src/ui/IconButton.tsx @@ -2,32 +2,38 @@ import type { ComponentPropsWithRef } from 'react' import styles from './IconButton.module.css' export type IconButtonSize = 40 | 62 -export type IconButtonTone = 'surface' | 'accent' | 'teal' +export type IconButtonTone = 'surface' | 'neutral' | 'accent' | 'teal' export type IconButtonProps = Omit, 'aria-label'> & { // Accessible name; an icon alone never names a button. label: string size?: IconButtonSize tone?: IconButtonTone + // Shows the label as an 11 px caption under the circle, which then names + // the button. + showLabel?: boolean } -// Round icon-only button. The 40 px size keeps a 44 px hit box (see the CSS); -// the 62 px size is the round action button with --shadow-md. +// Round icon button, named by its label and showing it only with showLabel. +// The 40 px size keeps a 44 px hit box (see the CSS); the 62 px size is the +// round action button with --shadow-md. export function IconButton({ label, size = 40, tone = 'surface', + showLabel = false, type = 'button', className, children, ...rest }: IconButtonProps) { - const classes = [styles.button, styles[`size${size}`], className].filter(Boolean).join(' ') + const classes = [styles.button, styles[`size${size}`], showLabel && styles.labelled, className].filter(Boolean).join(' ') return ( - ) }