Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d19fb9545 | ||
|
|
fdfd0123fb | ||
|
|
c4ea7a855e | ||
|
|
7f7a91db0d |
@@ -11,14 +11,61 @@ single Go service with the React PWA embedded in the binary.
|
||||
> `/health`, embedded PWA skeleton, Makefile, Dockerfile, CI). The planned
|
||||
> application is described in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
||||
|
||||
## Running it locally
|
||||
## Quick start
|
||||
|
||||
The developer setup (prerequisites, Authentik and Google clients, the
|
||||
`MEALQUEST_*` environment, `make dev` and the optional SearXNG sidecar) is
|
||||
runbook information and lives in CTXD: project `mealquest`, file
|
||||
`RUNBOOKS.MD`, section "Local development". Configuration is read only from
|
||||
the environment, and the service refuses to start, naming every missing or
|
||||
invalid variable.
|
||||
Requirements: Go 1.25 and Node.js `^22.22.2 || ^24.15.0 || >=26.0.0` — that
|
||||
is, 22.22.2 or later on the 22 line, 24.15.0 or later on the 24 line, or 26.0.0
|
||||
or later. Node.js 23, 24.0–24.14 and 25 are not supported: this is the
|
||||
`engines` range of jsdom in the committed lockfile, the strictest of all the
|
||||
web dependencies.
|
||||
|
||||
```sh
|
||||
(cd web && npm ci)
|
||||
mkdir -p ~/.mealquest-dev # outside the repository: it holds a secret
|
||||
chmod 700 ~/.mealquest-dev
|
||||
head -c 32 /dev/urandom | base64 > ~/.mealquest-dev/secret_key
|
||||
chmod 600 ~/.mealquest-dev/secret_key
|
||||
export MEALQUEST_HTTP_ADDR=:8080
|
||||
export MEALQUEST_PUBLIC_URL=http://localhost:5173
|
||||
export MEALQUEST_DATA_DIR="$HOME/.mealquest-dev"
|
||||
export MEALQUEST_SHUTDOWN_TIMEOUT=10s
|
||||
export MEALQUEST_SECRET_KEY_FILE="$HOME/.mealquest-dev/secret_key"
|
||||
# Authentik OAuth2/OpenID provider for MealQuest (redirect URI:
|
||||
# $MEALQUEST_PUBLIC_URL/auth/callback). Save its client secret to the file.
|
||||
export MEALQUEST_OIDC_ISSUER=https://auth.example.com/application/o/mealquest/
|
||||
export MEALQUEST_OIDC_CLIENT_ID=mealquest
|
||||
export MEALQUEST_OIDC_CLIENT_SECRET_FILE="$HOME/.mealquest-dev/oidc_client_secret"
|
||||
export MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
# Optional: sessions end after MEALQUEST_SESSION_IDLE unused (default 720h)
|
||||
# and always MEALQUEST_SESSION_MAX after sign-in (default 4320h).
|
||||
# export MEALQUEST_SESSION_IDLE=720h
|
||||
# export MEALQUEST_SESSION_MAX=4320h
|
||||
# Optional: comma-separated IPs/CIDRs of reverse proxies whose
|
||||
# X-Forwarded-For/-Proto headers are honoured (production: Traefik). Unset
|
||||
# trusts no peer, which is right for local development.
|
||||
# export MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
# Optional: a SearXNG instance the Settings screen suggests as the recipe
|
||||
# search URL until one is saved.
|
||||
# export MEALQUEST_SEARXNG_URL=http://localhost:8888
|
||||
export MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
make dev
|
||||
```
|
||||
|
||||
`make dev` runs the Go server on `:8080` and the Vite dev server on `:5173`
|
||||
(which proxies `/api`, `/auth` and `/media` to `:8080`). Open
|
||||
http://localhost:5173. Configuration is environment-only and fails fast:
|
||||
every variable above is required except the commented-out session
|
||||
lifetimes (when set they must be positive, with MAX at least IDLE),
|
||||
trusted proxy list (every entry must be an IP or CIDR), the SearXNG URL
|
||||
(an absolute `http://` or `https://` URL without credentials, a query or a
|
||||
fragment) and `MEALQUEST_DEFAULT_TZ`, and
|
||||
secrets are read only from files named by `*_FILE` variables. `MEALQUEST_PUBLIC_URL`, `MEALQUEST_OIDC_ISSUER` and the
|
||||
endpoints the issuer advertises must be `https://`; plain `http://` is
|
||||
accepted only for `localhost`, `127.0.0.1` and `[::1]`.
|
||||
`MEALQUEST_DEFAULT_TZ` is the IANA zone the household settings are created
|
||||
with on first boot: until those settings exist, boot fails unless it is a
|
||||
valid IANA zone. Later boots ignore it, set or not (change the zone in
|
||||
household settings instead).
|
||||
|
||||
## Common commands
|
||||
|
||||
@@ -31,8 +78,7 @@ invalid variable.
|
||||
|
||||
## Where to read next
|
||||
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, the quality
|
||||
floor every change must pass, repository layout, conventions.
|
||||
- [AGENTS.md](AGENTS.md) — house rules, approved dependencies, conventions.
|
||||
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — architecture of record.
|
||||
- [design/MealQuest Requirements.md](design/MealQuest%20Requirements.md) — product requirements.
|
||||
- [design/MealQuest UI Designs/design_handoff_mealquest/README.md](design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md) — UI handoff.
|
||||
|
||||
+2
-33
@@ -23,7 +23,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -139,11 +138,6 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ideasSvc, err := ideas.NewService(db, grounder, ideaFinder(ai, guard), quickIdeaFinder(ai, guard), runner, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sessions, err := auth.NewSessionManager(db, cfg.SessionIdle, cfg.SessionMax)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -160,7 +154,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, ideasSvc, runner, photos, frontend.Assets())
|
||||
router, err := server.NewRouter(logger, version, cfg.TrustedProxy, authFlow, sessions, settings, search, ai, dinnerSvc, suggestSvc, planSvc, shopSvc, googleSvc, runner, photos, frontend.Assets())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -286,8 +280,7 @@ func newJobRunner(db *store.Store, logger *slog.Logger) (*jobs.Runner, *jobKinds
|
||||
// service registers its own kinds when it is built, before the runner
|
||||
// starts (dinners.NewService registers dinners.KindGroundDinner and
|
||||
// suggest.NewService suggest.KindSuggestionBatch, plan.NewService
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun,
|
||||
// ideas.NewService ideas.KindFromIngredients and ideas.KindQuick).
|
||||
// plan.KindDraftWeek, shopping.NewService shopping.KindTitleSupplyRun).
|
||||
func registerJobKinds(runner *jobs.Runner) (*jobKinds, error) {
|
||||
builtins, err := jobs.RegisterBuiltins(runner)
|
||||
if err != nil {
|
||||
@@ -344,30 +337,6 @@ func archiveTitler(ai *aisettings.Service, guard *harness.Guard) shopping.Titler
|
||||
}
|
||||
}
|
||||
|
||||
// ideaFinder runs ideas_from_ingredients with the AI settings current at
|
||||
// each run, sharing guard.
|
||||
func ideaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.Generator {
|
||||
return func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.Ideas{}, err
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// quickIdeaFinder runs quick_ideas with the AI settings current at each
|
||||
// run, sharing guard.
|
||||
func quickIdeaFinder(ai *aisettings.Service, guard *harness.Guard) ideas.QuickGenerator {
|
||||
return func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
cfg, err := ai.HarnessConfig(ctx, guard)
|
||||
if err != nil {
|
||||
return skills.QuickIdeaList{}, err
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
}
|
||||
|
||||
// logListening logs the startup line. Only the public URL's origin is logged
|
||||
// so no credential-bearing URL component can reach the log.
|
||||
func logListening(logger *slog.Logger, cfg config.Config) {
|
||||
|
||||
+23
-18
@@ -1,15 +1,20 @@
|
||||
# MealQuest production environment: the template for deploy/.env (never
|
||||
# committed). The service fails fast: a missing or invalid required variable
|
||||
# stops it at boot with the variable named. Creating deploy/.env: CTXD
|
||||
# project mealquest, RUNBOOKS.MD "Stack and first deploy" > "3. Environment
|
||||
# file".
|
||||
# MealQuest production environment. Copy to deploy/.env (never committed)
|
||||
# and replace the placeholders. The service fails fast: a missing or invalid
|
||||
# required variable stops it at boot with the variable named.
|
||||
#
|
||||
# Secrets are never set here. They live in deploy/secrets/ (never committed),
|
||||
# one value per file, owned by the container user 10001:10001 with mode
|
||||
# 0600: secret_key, oidc_client_secret and google_client_secret. SearXNG's
|
||||
# searxng.env is the exception: compose reads it itself, so it is owned by
|
||||
# the host account that runs compose, mode 0600. Creating them: CTXD project
|
||||
# mealquest, RUNBOOKS.MD "Stack and first deploy" > "4. Secrets".
|
||||
# one value per file, owned by the container user and readable only by it:
|
||||
#
|
||||
# mkdir -p deploy/secrets && chmod 700 deploy/secrets
|
||||
# head -c 32 /dev/urandom | base64 > deploy/secrets/secret_key
|
||||
# printf '%s\n' '<Authentik client secret>' > deploy/secrets/oidc_client_secret
|
||||
# printf '%s\n' '<Google OAuth client secret>' > deploy/secrets/google_client_secret
|
||||
# sudo chown 10001:10001 deploy/secrets/* && sudo chmod 600 deploy/secrets/*
|
||||
#
|
||||
# SearXNG's secret key is the exception: compose reads it itself, so it
|
||||
# stays owned by you (mode 0600). Create it after the chown above:
|
||||
#
|
||||
# (umask 077; printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" > deploy/secrets/searxng.env)
|
||||
#
|
||||
# deploy/compose.yaml mounts them at /run/secrets/<name> and points the
|
||||
# *_FILE variables at them.
|
||||
@@ -37,7 +42,8 @@ MEALQUEST_TRUSTED_PROXY=10.60.1.33
|
||||
|
||||
# IANA zone the household settings are created with on first boot. Required
|
||||
# until those settings exist; ignored afterwards (change the zone in
|
||||
# household settings instead).
|
||||
# household settings instead). Confirm the household's zone before the first
|
||||
# deploy.
|
||||
MEALQUEST_DEFAULT_TZ=America/New_York
|
||||
|
||||
# --- Authentik OIDC ----------------------------------------------------------
|
||||
@@ -51,10 +57,10 @@ MEALQUEST_OIDC_ALLOWED_GROUPS=mealquest-admin
|
||||
|
||||
# --- Google Calendar (Push to Calendar) ------------------------------------
|
||||
|
||||
# Client ID of the Google OAuth web client (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Google Calendar OAuth client"). Required: the service will
|
||||
# not start without it and the client secret file. Its authorized redirect
|
||||
# URI is <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
# Client ID of the Google OAuth web client (deploy/RUNBOOK.md "Google
|
||||
# Calendar OAuth client"). Required: the service will not start without it
|
||||
# and the client secret file. Its authorized redirect URI is
|
||||
# <MEALQUEST_PUBLIC_URL origin>/api/v1/integrations/google/callback.
|
||||
MEALQUEST_GOOGLE_CLIENT_ID=<client id>.apps.googleusercontent.com
|
||||
|
||||
# Optional session lifetimes. A session ends after IDLE unused and always MAX
|
||||
@@ -76,14 +82,13 @@ MEALQUEST_SESSION_MAX=4320h
|
||||
# The SearXNG recipe-search sidecar on the compose network. The Settings
|
||||
# screen pre-fills it as the SearXNG URL until one is saved; the saved
|
||||
# provider and base URL come from PATCH /api/v1/settings/recipe-search.
|
||||
# See CTXD project mealquest, RUNBOOKS.MD "Recipe search (SearXNG)".
|
||||
# See deploy/RUNBOOK.md "Recipe search (SearXNG)".
|
||||
|
||||
# --- Planned; not read by the service yet -------------------------------------
|
||||
#
|
||||
# Added to deploy/compose.yaml and this file by the features that use them.
|
||||
#
|
||||
# Web Push (Feature 19). Key pair: CTXD project mealquest, RUNBOOKS.MD
|
||||
# "VAPID keys (Web Push)".
|
||||
# Web Push (Feature 19). Generate the key pair with cmd/vapidgen.
|
||||
# MEALQUEST_VAPID_PUBLIC_KEY=<base64url VAPID public key>
|
||||
# MEALQUEST_VAPID_PRIVATE_KEY_FILE=/run/secrets/vapid_private_key
|
||||
# MEALQUEST_VAPID_SUBJECT=mailto:<owner address>
|
||||
|
||||
+1417
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,8 @@
|
||||
# Development override layered on deploy/compose.yaml: publishes SearXNG
|
||||
# on the dev machine's loopback (127.0.0.1:8888) so it can be queried
|
||||
# directly. Using it: CTXD project mealquest, RUNBOOKS.MD "Local
|
||||
# development".
|
||||
# Development override: publishes SearXNG on the dev machine's loopback so
|
||||
# it can be queried directly, e.g.
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml -f deploy/compose.dev.yaml up -d searxng
|
||||
# curl 'http://127.0.0.1:8888/search?q=chicken+tikka+masala+recipe&format=json'
|
||||
#
|
||||
# Loopback only, never a LAN address. Never used in production: the
|
||||
# systemd unit starts deploy/compose.yaml alone.
|
||||
|
||||
+12
-10
@@ -1,16 +1,18 @@
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Building, deploying
|
||||
# and operating it: CTXD project mealquest, RUNBOOKS.MD "Stack unit" and
|
||||
# "Routine deploy and rollback".
|
||||
# Production stack for MealQuest on 10.60.1.14 (D-018). Build on the host,
|
||||
# then start through systemd:
|
||||
#
|
||||
# docker compose -f deploy/compose.yaml build
|
||||
# sudo systemctl restart mealquest
|
||||
#
|
||||
# The services use restart: "no" on purpose: Docker must not start them
|
||||
# before the DOCKER-USER firewall is applied. deploy/systemd/mealquest.service
|
||||
# owns start and stop and requires mealquest-firewall.service first.
|
||||
#
|
||||
# Configuration comes from deploy/.env (template: deploy/.env.example) and
|
||||
# secrets from deploy/secrets/; neither is committed. Compose bind-mounts
|
||||
# file secrets with their host owner and mode, so each file in
|
||||
# deploy/secrets/ must be owned by the container user 10001:10001 with mode
|
||||
# 0600 (except searxng.env, read by compose itself; see the searxng service).
|
||||
# Configuration comes from deploy/.env (copy deploy/.env.example) and secrets
|
||||
# from deploy/secrets/; neither is committed. Compose bind-mounts file secrets
|
||||
# with their host owner and mode, so each file in deploy/secrets/ must be
|
||||
# owned by the container user 10001:10001 with mode 0600 (except
|
||||
# searxng.env, read by compose itself; see the searxng service).
|
||||
name: mealquest
|
||||
|
||||
services:
|
||||
@@ -63,8 +65,8 @@ services:
|
||||
# the app's own user (owner of the volume) with a read-only root
|
||||
# filesystem, no privilege escalation and no Linux capabilities.
|
||||
#
|
||||
# Restoring the database: CTXD project mealquest, RUNBOOKS.MD "Restore
|
||||
# the database".
|
||||
# Restore: follow "Restore the database" in deploy/RUNBOOK.md (stop the
|
||||
# stack, move the database with its -wal and -shm aside, restore, check).
|
||||
litestream:
|
||||
image: litestream/litestream:0.5.17
|
||||
user: "10001:10001"
|
||||
|
||||
@@ -5,9 +5,8 @@
|
||||
# are one-way: a failure here fails this unit visibly and never fails
|
||||
# docker.service.
|
||||
#
|
||||
# mealquest.service requires this unit and starts the stack only after it
|
||||
# has succeeded. Installing and operating both: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Host firewall" and "Stack unit".
|
||||
# Start MealQuest only after this unit has succeeded:
|
||||
# systemctl start mealquest-firewall && docker compose up -d
|
||||
[Unit]
|
||||
Description=Restrict MealQuest port 8090 to Traefik (DOCKER-USER)
|
||||
After=docker.service
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Runs the nightly MealQuest media backup (/usr/local/sbin/mealquest-media-backup).
|
||||
# Triggered by mealquest-media-backup.timer; output goes to the journal.
|
||||
# Installing it and running it by hand: CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync".
|
||||
# Triggered by mealquest-media-backup.timer; run by hand with
|
||||
# systemctl start mealquest-media-backup
|
||||
# and read the result with
|
||||
# journalctl -u mealquest-media-backup
|
||||
#
|
||||
# The script runs as the app's user (10001, host user "mealquest") with no
|
||||
# capabilities. systemd, as root, only bind-mounts the data volume's root
|
||||
|
||||
@@ -15,9 +15,8 @@
|
||||
# swapping the path afterwards cannot redirect it. Symlinks inside the
|
||||
# directory are copied as links (-a), never followed.
|
||||
#
|
||||
# The media directory is created at deploy time (CTXD project mealquest,
|
||||
# RUNBOOKS.MD "Media — nightly rsync"). If it is missing the job fails; it
|
||||
# is never treated as "nothing to back up".
|
||||
# The media directory is created at deploy time (deploy/RUNBOOK.md). If it
|
||||
# is missing the job fails; it is never treated as "nothing to back up".
|
||||
#
|
||||
# rsync mirrors into .../mealquest-backups/media/ only: the target ends in
|
||||
# a slash, so --delete removes files from that directory and nothing
|
||||
|
||||
+1
-150
@@ -308,89 +308,6 @@ automatic** (D-021).
|
||||
still `pending` or `running` with no job queued or running (the process
|
||||
stopped, or recording its end failed) is set `failed`, so it does not
|
||||
block the next.
|
||||
- Cook from what I have (`ideas.Service`, Feature 18). `POST
|
||||
/api/v1/ideas/from-ingredients {ingredients[], quick_only}` (both
|
||||
required) checks the list with `skills.CleanIngredients` — 1 to 20
|
||||
entries, each non-blank, at most 80 characters, with a non-empty
|
||||
`harness.Normalize` form that no earlier entry shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues an `ideas_from_ingredients`
|
||||
job (service-only: `POST /jobs` refuses the kind) and answers 202
|
||||
`{jobId}` without waiting. The job runs `ideas_from_ingredients` (scope:
|
||||
`skills.IdeasScope`, the flag and the normalized list in order), then
|
||||
grounds its ideas in the model's order with `Grounder.GroundEach`, as
|
||||
many as are still wanted at a time, until 5 are kept or none are left.
|
||||
An idea no recipe grounds is dropped; with `quick_only`, so is one whose
|
||||
grounded recipe is not a Quick dinner (`ideas.IsQuick`: method
|
||||
`crockpot`, or `minutes_total` ≤ 30 — the recipe's, never the model's
|
||||
`est_minutes`). Ideas are not stored as dinners. The job publishes
|
||||
`{stage, ingredients, quick_only, ideas: [{name, uses, missing, summary,
|
||||
method, minutes_total, difficulty, quick, recipe_id, photo_url,
|
||||
source_url, site_name}], dropped}` at each stage (`suggesting`,
|
||||
`grounding`, `done`) and again as each idea is kept, the moment its
|
||||
grounding finishes, so the result fills idea by idea; the ideas shown
|
||||
are always in the model's order, an earlier idea that grounds later
|
||||
taking its place before those shown. It fails with `no_ideas_found`
|
||||
(every idea was dropped), `ai_failed` (the skill or `normalize_recipe`
|
||||
failed; no idea is made up instead, D-017), `search_not_configured` or
|
||||
`search_failed`. A failed job shows no ideas, even those kept before the
|
||||
failure: the kind registers a `FailedResult` view, so `GET
|
||||
/api/v1/jobs/{id}` empties the `ideas` of any failed job's result on
|
||||
read, however it failed (including `interrupted by restart` and an
|
||||
`internal_error` whose clearing write failed). Before it fails it also
|
||||
publishes its result again with no ideas, to tidy what is stored.
|
||||
- Quick dinners (`ideas.Service`, Feature 18). `POST /api/v1/ideas/quick
|
||||
{kind, exclude[]}` (both required; `exclude` is `[]` on a first page)
|
||||
checks `kind` with `skills.CheckQuickKind` — `crockpot` or `under30`,
|
||||
no default — and `exclude` with `skills.CleanExclude` — at most 60
|
||||
names, each non-blank, at most 120 characters, with a non-empty
|
||||
`harness.Normalize` form no earlier name shares — answering 400
|
||||
`invalid_request` otherwise, then enqueues a `quick_ideas` job
|
||||
(service-only) and answers 202 `{jobId}` without waiting. The job reads
|
||||
the household context (`harness.BuildContext`, budget 16000), runs
|
||||
`quick_ideas` (scope: `skills.QuickIdeasScope`, the kind and the
|
||||
normalized excluded names in order), then grounds its ideas exactly as
|
||||
the ingredients job does (the same `Grounder.GroundEach` rounds, model
|
||||
order and failure codes) until 6 are kept or none are left. The kind is
|
||||
enforced on the grounded recipe, never the model's estimate
|
||||
(`ideas.OfQuickKind`): `crockpot` keeps method `crockpot`
|
||||
(`normalize_recipe` writes a slow cooker as `crockpot`), `under30`
|
||||
keeps a known `minutes_total` ≤ 30; every other idea is dropped. The
|
||||
model is asked for 12, more than a page, so dropped ideas can be
|
||||
replaced; when fewer than 6 survive the page shows only those, never
|
||||
padded with an idea not of the kind. The job publishes `{stage, kind,
|
||||
exclude, ideas: [{name, method, minutes_total, difficulty, recipe_id,
|
||||
photo_url, source_url, site_name}], dropped}` at each stage and as each
|
||||
idea is kept, fails with the same reasons, and a failed job shows no
|
||||
ideas through the same `FailedResult` view. "↻ Show me more" posts
|
||||
again with every name the tab has shown so far in `exclude`.
|
||||
- Idea actions (Feature 18). Both act on idea `{i}` (0, 1, …; anything
|
||||
else is 400 `invalid_request`) of a **succeeded** `ideas_from_ingredients`
|
||||
or `quick_ideas` job, read from its stored result
|
||||
(`ideas.Service.Idea`): its name and `recipe_id`, never grounded again.
|
||||
A job still queued or running is 409 `ideas_not_ready` (its ideas may
|
||||
still move); an unknown job, another kind, a failed job or an index past
|
||||
its ideas is 404 `not_found`. `POST
|
||||
/api/v1/ideas/{jobId}/items/{i}/quest-log` (no body) runs
|
||||
`dinners.Service.AddToQuestLog`: in one transaction, an unarchived
|
||||
dinner the idea repeats — the same rule that refuses a duplicate Regular
|
||||
(`harness.Similar` name, or the recipe's link) — is answered 200 as it
|
||||
is (a Regular stays a Regular); one awaiting the household's choice
|
||||
(`suggested`, `tried`) is 409 `duplicate_dinner` and changes nothing;
|
||||
otherwise it creates a dinner with status `quest_log`, origin
|
||||
`ingredients` or `quick`, grounded in the idea's recipe (its method,
|
||||
minutes, difficulty and photo), answered 201. Repeating the call
|
||||
answers the same dinner. `POST /api/v1/ideas/{jobId}/items/{i}/cook-tonight`
|
||||
(no body) takes today as the household-local date in the household
|
||||
timezone and its Monday (`plan.Service.Tonight`); when that week has no
|
||||
plan it answers 409 `no_plan` and changes nothing — no dinner, no plan
|
||||
(a plan holds all seven days; the household plans the week first).
|
||||
Otherwise it adds or finds the dinner as `quest-log` does and puts it
|
||||
on today through `plan.Service.Swap`, exactly like a Draft board swap
|
||||
(plan marked edited, revision raised, a synced plan becomes `changed`,
|
||||
the day's calendar event id cleared, `is_new` for a Quest log dinner),
|
||||
answering 200 `{dinner, plan}`. The week is always today's own, so no
|
||||
other day is written; a dinner already on another day of the week is
|
||||
409 `dinner_already_planned`.
|
||||
`GET /api/v1/suggestions/deck` returns `{suggestions: [{id, dinner_id,
|
||||
name, photo_url, difficulty, method, minutes_total, cuisine, reason,
|
||||
cited_regular_ids, status, dinner_status, decided_at, created_at}],
|
||||
@@ -553,32 +470,6 @@ automatic** (D-021).
|
||||
not a Regular; and one whose reason is over 180 characters or names none
|
||||
of its cited Regulars. Cited names are rewritten as the household writes
|
||||
them. Grounding each survivor in a recipe is the caller's next step.
|
||||
- **Ideas from ingredients** (`skills.IdeasFromIngredients`, skill
|
||||
`ideas_from_ingredients` v1): from the household's ingredient list (the
|
||||
"Ingredients on hand" context section) and the Quick only flag, asks for
|
||||
8 ideas `{name, uses[], missing[], est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 8 ideas) get the one
|
||||
repair turn. The invariant is enforced in Go: a `uses` entry is kept
|
||||
only when its `harness.Normalize` form is a listed ingredient's, and is
|
||||
then written as the household wrote it, once, in the household's order;
|
||||
`missing` loses blanks, repeats and anything listed. An idea left using
|
||||
none of the listed ingredients, or repeating an earlier idea
|
||||
(`harness.Similar`), is dropped into `Dropped`. Each kept idea's summary
|
||||
line (`skills.IdeaSummary`) is computed from the corrected lists: "Uses
|
||||
all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of 5 ·
|
||||
missing: …" otherwise, with "nothing missing" in place of the list when
|
||||
nothing is.
|
||||
- **Quick ideas** (`skills.QuickIdeas`, skill `quick_ideas` v1): from the
|
||||
household context, a kind (`crockpot` or `under30`, which picks the
|
||||
prompt's rule) and the names already shown (the "Already shown" context
|
||||
section), asks for 12 ideas `{name, est_minutes, method, difficulty,
|
||||
search_query}` at the household temperature. Malformed replies (a
|
||||
missing field, a bad enum, an empty name, more than 12 ideas) get the
|
||||
one repair turn. An idea repeating an excluded name or an earlier idea
|
||||
(`harness.Similar`, on the normalized names) is dropped into `Dropped`,
|
||||
so an excluded name is never returned. Whether an idea is of the kind
|
||||
is left to the grounded recipe (see Quick dinners above).
|
||||
- **Supply run titles** (`skills.TitleArchive`, skill `archive_title`
|
||||
v1): from the names of the week's planned dinners in day order, writes
|
||||
`{title}`, the Supply run's post title in the Supply archive, at the
|
||||
@@ -599,31 +490,6 @@ automatic** (D-021).
|
||||
generation: the list stays untitled (the UI shows the week label), the
|
||||
failure is logged as a warning and recorded in `ai_runs`, the job fails
|
||||
`title_failed`, and the week's next generation tries again.
|
||||
`shopping.Freeze` titles a list still untitled once more before
|
||||
freezing it (see Supply archive), through the same skill, scope and
|
||||
conditional write.
|
||||
|
||||
## Supply archive (Feature 17)
|
||||
|
||||
`shopping.Freeze(weekStart)` moves a week's Supply run into the Supply
|
||||
archive: `status` becomes `archived` and `frozen_at` is set, after which
|
||||
generating the list or changing an item is 409 `archived`. It freezes only
|
||||
a week that has ended in the household's time zone (its Monday is before
|
||||
the Monday of the household's current week), so a week not yet ended is
|
||||
`ErrWeekNotEnded` and a week with no list `ErrListNotFound`; freezing a
|
||||
frozen list does nothing. A list still untitled is titled first with
|
||||
`archive_title` from the week's plan, as `Generate` titles it; a title
|
||||
that cannot be written is logged and the list is frozen untitled, when its
|
||||
week label stands in for good. Titling and freezing are ordered by a
|
||||
per-service `RWMutex`: a `title_supply_run` job holds it shared across
|
||||
reading the list, the `archive_title` run and the title write, and `Freeze`
|
||||
holds it exclusively from reading the list to freezing it. So a freeze
|
||||
waits for a job in flight to store its title or fail for good; when the
|
||||
job failed, the freeze finds the list still untitled and makes its own
|
||||
`archive_title` attempt before freezing. A job that starts during a freeze
|
||||
waits for it, then finds the list titled (that title stands) or frozen
|
||||
untitled (the job fails `title_failed`), and stores nothing. The
|
||||
`week_rollover` schedule is to call `Freeze` for last week.
|
||||
|
||||
## Recipe grounding
|
||||
|
||||
@@ -734,8 +600,7 @@ untitled (the job fails `title_failed`), and stores nothing. The
|
||||
candidates `Ground` would try, without grounding any. Search failures
|
||||
wrap `recipes.ErrSearchFailed` and model provider failures
|
||||
`recipes.ErrNormalizeFailed`. `GroundMany` grounds items 3 at a time and returns one result per
|
||||
item, so one failure never affects its siblings; `GroundEach` grounds
|
||||
the same way but reports each item, one at a time, as it finishes. A recipe is usable only
|
||||
item, so one failure never affects its siblings. A recipe is usable only
|
||||
with ingredients, an image and, if given, a positive total time.
|
||||
|
||||
## Scheduler
|
||||
@@ -925,20 +790,6 @@ Runs are recorded in `schedule_runs` and executed through `jobs`.
|
||||
needed, needed_count, total_count}` with the list's counts after the
|
||||
change. 404 for an unknown item, 409 `archived` when the list is in the
|
||||
Supply archive, 400 `invalid_request` for a bad body.
|
||||
- `GET /api/v1/shopping/archive?by=week|month|year` returns the Supply
|
||||
archive `{by, periods: [{key, heading, posts: [{weekStart, label,
|
||||
title, dinnerCount, itemCount, onHandCount, dinners, current}]}]}`: the
|
||||
Supply run of every week up to the household's current week, newest
|
||||
first, grouped by the Monday's ISO week (`key` "2026-W41", `heading` the
|
||||
week label), month ("2026-10", "October 2026") or year ("2026"). The
|
||||
Monday is a household-local date, so the week of Monday 28 December
|
||||
is in December. `label` is the week label ("Week 41 · Oct 5 – 11"; the
|
||||
UI sets it in capitals), `title` null while untitled, `dinners` the
|
||||
list's dinners in day order and `dinnerCount` their number,
|
||||
`itemCount` all items, `onHandCount` the items marked "have it", and
|
||||
`current` true for the household's current week. 400 `invalid_request`
|
||||
for any other `by`. A frozen list is read through `GET
|
||||
/api/v1/shopping/{weekStart}` with `status` `archived`.
|
||||
|
||||
## Frontend
|
||||
|
||||
|
||||
@@ -1,356 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
const (
|
||||
// IdeasRequested is how many ideas ideas_from_ingredients asks the
|
||||
// model for; validation and grounding drop some.
|
||||
IdeasRequested = 8
|
||||
// IdeasKept is the most ideas the household is shown.
|
||||
IdeasKept = 5
|
||||
// MaxIngredients is the most ingredients the household may list.
|
||||
MaxIngredients = 20
|
||||
// IngredientLimit is the most characters one listed ingredient may
|
||||
// have.
|
||||
IngredientLimit = 80
|
||||
)
|
||||
|
||||
// IdeasRequest is what ideas_from_ingredients reads.
|
||||
type IdeasRequest struct {
|
||||
// Ingredients are the ingredients the household has on hand, in its
|
||||
// order, as CleanIngredients returns them.
|
||||
Ingredients []string
|
||||
// QuickOnly asks the model for Quick dinners only. It is a hint: the
|
||||
// caller enforces it after grounding, from the grounded recipe.
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// Idea is one dinner idea. Method, EstMinutes and Difficulty are the
|
||||
// model's estimates until the idea is grounded in a recipe.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order. PostProcess drops every entry that is
|
||||
// not one of them.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have, in the model's order.
|
||||
Missing []string `json:"missing"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
// Summary is the line the household reads under the idea, computed
|
||||
// by IdeaSummary from Uses and Missing. It is never part of the
|
||||
// model's reply.
|
||||
Summary string `json:"-"`
|
||||
}
|
||||
|
||||
// Ideas is ideas_from_ingredients' output.
|
||||
type Ideas struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []Idea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// DroppedIdea is an idea dropped by PostProcess.
|
||||
type DroppedIdea struct {
|
||||
Name string
|
||||
// Why says which rule it broke.
|
||||
Why string
|
||||
}
|
||||
|
||||
// IdeasFromIngredients runs ideas_from_ingredients v1 on in and returns
|
||||
// ideas whose uses hold only the household's own ingredients: an entry
|
||||
// naming anything else is removed, and an idea left using none of them is
|
||||
// dropped, as is one repeating an earlier idea. Each kept idea carries its
|
||||
// summary line, computed from the corrected uses and missing lists. scope
|
||||
// names the request (see harness.Run; IdeasScope). An ingredient list
|
||||
// CleanIngredients refuses fails before any LLM call.
|
||||
func IdeasFromIngredients(ctx context.Context, cfg harness.Config, scope string, in IdeasRequest) (Ideas, error) {
|
||||
ings, err := CleanIngredients(in.Ingredients)
|
||||
if err != nil {
|
||||
return Ideas{}, fmt.Errorf("skills: ideas_from_ingredients: %w", err)
|
||||
}
|
||||
in.Ingredients = ings
|
||||
return harness.Run(ctx, cfg, ideasFromIngredients{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// IdeasScope is the harness scope of a request: its normalized
|
||||
// ingredients, in order, and whether it is for Quick dinners only, so two
|
||||
// requests share a run only when the model would be asked the same.
|
||||
func IdeasScope(in IdeasRequest) string {
|
||||
parts := make([]string, len(in.Ingredients))
|
||||
for i, ing := range in.Ingredients {
|
||||
parts[i] = harness.Normalize(ing)
|
||||
}
|
||||
return "quick_only=" + strconv.FormatBool(in.QuickOnly) + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CleanIngredients returns the household's ingredient list with each entry
|
||||
// on one line, or why it cannot be used: it is empty or longer than
|
||||
// MaxIngredients, or an entry is blank, longer than IngredientLimit
|
||||
// characters, has nothing left once normalized (harness.Normalize), or
|
||||
// normalizes the same as an earlier one.
|
||||
func CleanIngredients(list []string) ([]string, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, errors.New("list at least one ingredient")
|
||||
}
|
||||
if len(list) > MaxIngredients {
|
||||
return nil, fmt.Errorf("%d ingredients listed; list at most %d", len(list), MaxIngredients)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, ing := range list {
|
||||
ing = oneLine(ing)
|
||||
switch {
|
||||
case ing == "":
|
||||
return nil, fmt.Errorf("ingredient %d is blank", i+1)
|
||||
case utf8.RuneCountInString(ing) > IngredientLimit:
|
||||
return nil, fmt.Errorf("ingredient %d is over %d characters", i+1, IngredientLimit)
|
||||
}
|
||||
n := harness.Normalize(ing)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, names no ingredient", i+1, ing)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("ingredient %d, %q, repeats %q", i+1, ing, first)
|
||||
}
|
||||
seen[n] = ing
|
||||
out[i] = ing
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// IdeaSummary is the line shown under an idea: how many of the household's
|
||||
// total ingredients it uses, then what is missing, in the order given.
|
||||
// "Uses all 5 · missing: 1 lime" when every ingredient is used, "Uses 3 of
|
||||
// 5 · missing: 1 lime, cumin" when some are. With nothing missing the
|
||||
// second part is "nothing missing", so an idea using all 5 and needing
|
||||
// nothing else prints "Uses all 5 · nothing missing".
|
||||
func IdeaSummary(uses, missing []string, total int) string {
|
||||
used := fmt.Sprintf("Uses %d of %d", len(uses), total)
|
||||
if len(uses) == total {
|
||||
used = fmt.Sprintf("Uses all %d", total)
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return used + " · nothing missing"
|
||||
}
|
||||
return used + " · missing: " + strings.Join(missing, ", ")
|
||||
}
|
||||
|
||||
// ideasFromIngredients is the skill. It holds its input because
|
||||
// post-processing compares the output with it.
|
||||
type ideasFromIngredients struct {
|
||||
in IdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[IdeasRequest, Ideas] = ideasFromIngredients{}
|
||||
|
||||
func (ideasFromIngredients) Name() string { return "ideas_from_ingredients" }
|
||||
|
||||
func (ideasFromIngredients) Version() int { return 1 }
|
||||
|
||||
func (ideasFromIngredients) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// ideasPrompt is the template data of ideas_from_ingredients.v1.tmpl.
|
||||
type ideasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
QuickOnly bool
|
||||
}
|
||||
|
||||
// PromptInput puts the ingredients, already cleaned by
|
||||
// IdeasFromIngredients, in the household context block.
|
||||
func (ideasFromIngredients) PromptInput(in IdeasRequest) (harness.PromptInput, error) {
|
||||
return harness.PromptInput{
|
||||
Data: ideasPrompt{Count: IdeasRequested, QuickOnly: in.QuickOnly},
|
||||
Context: []harness.ContextSection{{Title: "Ingredients on hand", Lines: in.Ingredients}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ideasFromIngredients) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "ideas_from_ingredients", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"uses": {"type": "array", "items": {"type": "string"}},
|
||||
"missing": {"type": "array", "items": {"type": "string"}},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *Idea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "uses", "missing", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain Idea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *Ideas) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain Ideas
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (ideasFromIngredients) Validate(out Ideas) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", IdeasRequested))
|
||||
case n > IdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, IdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess corrects every idea against the household's ingredients and
|
||||
// moves each that breaks a rule to Dropped. Uses keeps only entries whose
|
||||
// normalized form (harness.Normalize) is that of a listed ingredient,
|
||||
// written as the household wrote it, once each and in the household's
|
||||
// order; anything else the model claimed is removed, never trusted.
|
||||
// Missing loses blank entries, repeats and anything the household listed.
|
||||
// An idea is dropped when it repeats an earlier idea or uses none of the
|
||||
// household's ingredients. The summary line is computed from the
|
||||
// corrected lists.
|
||||
func (s ideasFromIngredients) PostProcess(out Ideas) (Ideas, error) {
|
||||
listed := map[string]int{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
listed[harness.Normalize(ing)] = i
|
||||
}
|
||||
result := Ideas{Ideas: []Idea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = Idea{
|
||||
Name: oneLine(idea.Name),
|
||||
Uses: s.uses(idea.Uses, listed),
|
||||
Missing: missing(idea.Missing, listed),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := dropIdea(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
idea.Summary = IdeaSummary(idea.Uses, idea.Missing, len(s.in.Ingredients))
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// uses returns the household's ingredients that claimed names, as the
|
||||
// household wrote them and in its order. A claimed entry that names none
|
||||
// of them is left out.
|
||||
func (s ideasFromIngredients) uses(claimed []string, listed map[string]int) []string {
|
||||
used := make([]bool, len(s.in.Ingredients))
|
||||
for _, c := range claimed {
|
||||
if i, ok := listed[harness.Normalize(c)]; ok {
|
||||
used[i] = true
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for i, ing := range s.in.Ingredients {
|
||||
if used[i] {
|
||||
out = append(out, ing)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missing returns claimed on one line each, in order, without blank
|
||||
// entries, repeats or any the household listed.
|
||||
func missing(claimed []string, listed map[string]int) []string {
|
||||
out := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range claimed {
|
||||
c = oneLine(c)
|
||||
n := harness.Normalize(c)
|
||||
if n == "" || seen[n] {
|
||||
continue
|
||||
}
|
||||
if _, ok := listed[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// dropIdea returns why idea must be dropped, or "" when it may be kept.
|
||||
func dropIdea(idea Idea, kept []Idea) string {
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
if len(idea.Uses) == 0 {
|
||||
return "uses none of the household's ingredients"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list in its order.
|
||||
var pantry = IdeasRequest{Ingredients: []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply.
|
||||
func idea(name, method string, minutes int, uses, missing []string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
m, _ := json.Marshal(append([]string{}, missing...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":%s,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, m, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// ideasReply renders a reply holding the ideas given.
|
||||
func ideasReply(ideas ...string) string {
|
||||
return `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
}
|
||||
|
||||
// ideas runs IdeasFromIngredients on in with f and checks f was called
|
||||
// exactly calls times, each at the household temperature.
|
||||
func ideas(t *testing.T, f *llm.Fake, in IdeasRequest, calls int) (Ideas, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := IdeasFromIngredients(context.Background(), testConfig(t, f), IdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "ideas_from_ingredients" {
|
||||
t.Fatalf("request %d schema = %v, want ideas_from_ingredients", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"Chicken thighs", "rice"}, nil))))
|
||||
_, reqs, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, "ideas_from_ingredients_any.system.golden", system)
|
||||
golden(t, "ideas_from_ingredients_any.user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Ingredients on hand\n- Chicken thighs\n- rice\n- cilantro\n- black beans\n- red onion",
|
||||
"## Output contract",
|
||||
`"uses"`,
|
||||
`"missing"`,
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if strings.Contains(system, "Quick dinners only") {
|
||||
t.Error("an Any time request asks for Quick dinners only")
|
||||
}
|
||||
if user != "Suggest 8 dinner ideas from the ingredients on hand." {
|
||||
t.Errorf("user message = %q", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsQuickOnlyPrompt(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Fried Rice", "stovetop", 25, []string{"Chicken thighs", "rice"}, nil))))
|
||||
in := pantry
|
||||
in.QuickOnly = true
|
||||
_, reqs, err := ideas(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(reqs[0].System, "The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.") {
|
||||
t.Errorf("Quick only request does not ask for Quick dinners:\n%s", reqs[0].System)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsSummaryLine(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// Every ingredient, named in another order and spelling: uses is
|
||||
// in the household's order, as the household wrote it.
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"red onion", "Black Beans", "chicken thighs", "Rice", "cilantro!"}, []string{"1 lime"}),
|
||||
// Some of them.
|
||||
idea("Cilantro Chicken Rice", "stovetop", 30, []string{"rice", "cilantro", "Chicken thighs"}, []string{"1 lime", "cumin"}),
|
||||
// Every ingredient, nothing missing.
|
||||
idea("Black Bean Chicken Skillet", "stovetop", 30, []string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, nil),
|
||||
// Some, nothing missing.
|
||||
idea("Rice and Beans", "stovetop", 25, []string{"rice", "black beans"}, []string{}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Dropped) != 0 {
|
||||
t.Fatalf("dropped %+v", out.Dropped)
|
||||
}
|
||||
got := map[string][2]any{}
|
||||
for _, i := range out.Ideas {
|
||||
got[i.Name] = [2]any{i.Uses, i.Summary}
|
||||
}
|
||||
want := map[string][2]any{
|
||||
"Chicken Burrito Bowls": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · missing: 1 lime"},
|
||||
"Cilantro Chicken Rice": {[]string{"Chicken thighs", "rice", "cilantro"}, "Uses 3 of 5 · missing: 1 lime, cumin"},
|
||||
"Black Bean Chicken Skillet": {[]string{"Chicken thighs", "rice", "cilantro", "black beans", "red onion"}, "Uses all 5 · nothing missing"},
|
||||
"Rice and Beans": {[]string{"rice", "black beans"}, "Uses 2 of 5 · nothing missing"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("uses and summaries = %v\nwant %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsHallucinatedUses(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The model claims parmesan and lime, which the household never
|
||||
// listed, and repeats rice.
|
||||
idea("Cheesy Chicken Rice", "oven", 40, []string{"Chicken thighs", "parmesan", "rice", "lime", "rice"}, []string{"1 cup broth"}),
|
||||
// Uses only ingredients the household never listed.
|
||||
idea("Lemon Pasta", "stovetop", 20, []string{"pasta", "lemon"}, []string{"garlic"}),
|
||||
// Lists an ingredient on hand as missing.
|
||||
idea("Chicken Tacos", "stovetop", 25, []string{"Chicken thighs"}, []string{"tortillas", "Cilantro", "tortillas"}),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := Ideas{
|
||||
Ideas: []Idea{
|
||||
{
|
||||
Name: "Cheesy Chicken Rice", Uses: []string{"Chicken thighs", "rice"}, Missing: []string{"1 cup broth"},
|
||||
EstMinutes: 40, Method: "oven", Difficulty: "easy", SearchQuery: "cheesy chicken rice recipe",
|
||||
Summary: "Uses 2 of 5 · missing: 1 cup broth",
|
||||
},
|
||||
{
|
||||
Name: "Chicken Tacos", Uses: []string{"Chicken thighs"}, Missing: []string{"tortillas"},
|
||||
EstMinutes: 25, Method: "stovetop", Difficulty: "easy", SearchQuery: "chicken tacos recipe",
|
||||
Summary: "Uses 1 of 5 · missing: tortillas",
|
||||
},
|
||||
},
|
||||
Dropped: []DroppedIdea{{Name: "Lemon Pasta", Why: "uses none of the household's ingredients"}},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Fatalf("output = %+v\nwant %+v", out, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsDropsRepeatedIdea(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil),
|
||||
idea("Easy Chicken Burrito Bowls", "stovetop", 30, []string{"rice"}, nil),
|
||||
)))
|
||||
out, _, err := ideas(t, &f, pantry, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 || out.Ideas[0].Name != "Chicken Burrito Bowls" {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
if want := []DroppedIdea{{Name: "Easy Chicken Burrito Bowls", Why: `repeats the idea "Chicken Burrito Bowls"`}}; !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v, want %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "microwave", 0, []string{"rice"}, nil))))
|
||||
f.QueueGenerate(reply(ideasReply(idea("Chicken Burrito Bowls", "stovetop", 35, []string{"rice"}, nil))))
|
||||
out, reqs, err := ideas(t, &f, pantry, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var nine []string
|
||||
for i := range 9 {
|
||||
nine = append(nine, idea(fmt.Sprintf("Dish %d", i+1), "stovetop", 30, []string{"rice"}, nil))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
f.QueueGenerate(reply(ideasReply(nine...)))
|
||||
if _, _, err := ideas(t, &f, pantry, 2); err == nil || !strings.Contains(err.Error(), "give at most 8 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanIngredients(t *testing.T) {
|
||||
got, err := CleanIngredients([]string{" 1 lime ", "Chicken\tthighs"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"1 lime", "Chicken thighs"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanIngredients = %q, want %q", got, want)
|
||||
}
|
||||
tooMany := make([]string, MaxIngredients+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("thing %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"empty", nil, "list at least one ingredient"},
|
||||
{"too many", tooMany, "list at most 20"},
|
||||
{"blank", []string{"rice", " "}, "ingredient 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", IngredientLimit+1)}, "over 80 characters"},
|
||||
{"filler only", []string{"the"}, "names no ingredient"},
|
||||
{"repeat", []string{"Red onion", "red-onion"}, `repeats "Red onion"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanIngredients(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasFromIngredientsRefusesBadListBeforeCalling(t *testing.T) {
|
||||
var f llm.Fake
|
||||
if _, _, err := ideas(t, &f, IdeasRequest{Ingredients: []string{"rice", "Rice"}}, 0); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := IdeasScope(IdeasRequest{Ingredients: []string{"Rice", "black beans"}})
|
||||
if b := IdeasScope(IdeasRequest{Ingredients: []string{"rice", "Black Beans"}}); a != b {
|
||||
t.Errorf("scopes of the same list differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []IdeasRequest{
|
||||
{Ingredients: []string{"Rice", "black beans"}, QuickOnly: true},
|
||||
{Ingredients: []string{"black beans", "Rice"}},
|
||||
{Ingredients: []string{"Rice"}},
|
||||
} {
|
||||
if IdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,3 @@ normalize_recipe v1 7174e5479fc020f3b2b39953d85411381aea26defb9943145e7864c5b752
|
||||
suggest_side_quests v1 bd54d7bf9aea0980059a346e5f40ff4c866b80ef9bb9f74e7cbd413be0911c8a
|
||||
draft_week v1 e637419690d9532bab53cb8ca01b73a079ecfe8e3022522c05188d18e9740f32
|
||||
archive_title v1 b4f30562a8614dac09a69d512dc361883b917ee4228a0ce3b4583c7fd3f6101a
|
||||
ideas_from_ingredients v1 49df3e134f07fe2c0dbbda7bd3d7d1364731e5851bf6f6a526ec04b703480924
|
||||
quick_ideas v1 c78d1b9917a150bcf8df1351e0b4c2e76eaa10097819782c2c74695b0ae3cf05
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{{/* ideas_from_ingredients drafts dinner ideas the household can cook from the ingredients it has on hand. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
{{- if .QuickOnly}}
|
||||
|
||||
The household wants Quick dinners only: each idea must be cooked in a crock pot, or take 30 minutes or less from prep to table.
|
||||
{{- end}}
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
Suggest {{.Count}} dinner ideas from the ingredients on hand.
|
||||
{{- end}}
|
||||
@@ -1,34 +0,0 @@
|
||||
{{/* quick_ideas drafts Quick dinner ideas of one kind, crock pot or 30 minutes or less, picked from the household's tastes. */}}
|
||||
{{define "instructions" -}}
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
{{- if .Crockpot}}
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
{{- else}}
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
{{- end}}
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{{- if .Crockpot}}
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
{{- else}}
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{define "user" -}}
|
||||
{{- if .Crockpot -}}
|
||||
Suggest {{.Count}} crock pot dinner ideas.
|
||||
{{- else -}}
|
||||
Suggest {{.Count}} dinner ideas ready in 30 minutes or less.
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
@@ -1,297 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// Kinds of Quick dinner quick_ideas suggests: the two tabs of the Quick
|
||||
// dinners screen.
|
||||
const (
|
||||
// QuickCrockpot is a dinner cooked in a crock pot (slow cooker).
|
||||
QuickCrockpot = "crockpot"
|
||||
// QuickUnder30 is a dinner 30 minutes or less from prep to table.
|
||||
QuickUnder30 = "under30"
|
||||
)
|
||||
|
||||
// QuickKinds are every kind of Quick dinner, in the screen's tab order.
|
||||
var QuickKinds = []string{QuickCrockpot, QuickUnder30}
|
||||
|
||||
const (
|
||||
// QuickIdeasRequested is how many ideas quick_ideas asks the model
|
||||
// for: more than a page holds, since validation, the exclude list,
|
||||
// grounding and the kind's check drop some.
|
||||
QuickIdeasRequested = 12
|
||||
// QuickIdeasKept is the most ideas one page of Quick dinners shows.
|
||||
QuickIdeasKept = 6
|
||||
// MaxExclude is the most names already shown a request may exclude.
|
||||
MaxExclude = 60
|
||||
// ExcludeLimit is the most characters one excluded name may have.
|
||||
ExcludeLimit = 120
|
||||
)
|
||||
|
||||
// QuickIdeasRequest is what quick_ideas reads.
|
||||
type QuickIdeasRequest struct {
|
||||
// Context is the household context (see harness.BuildContext): the
|
||||
// ideas are picked from the household's tastes.
|
||||
Context harness.HouseholdContext
|
||||
// Kind is QuickCrockpot or QuickUnder30. It is a hint to the model:
|
||||
// the caller enforces it after grounding, from the grounded recipe.
|
||||
Kind string
|
||||
// Exclude are the names already shown to the household, as
|
||||
// CleanExclude returns them. No idea repeating one is returned.
|
||||
Exclude []string
|
||||
}
|
||||
|
||||
// QuickIdea is one Quick dinner idea. Method, EstMinutes and Difficulty
|
||||
// are the model's estimates until the idea is grounded in a recipe.
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
EstMinutes int `json:"est_minutes"`
|
||||
// Method is one of stovetop, oven, crockpot, grill and other.
|
||||
Method string `json:"method"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// SearchQuery is a web search query that finds a recipe for the dinner.
|
||||
SearchQuery string `json:"search_query"`
|
||||
}
|
||||
|
||||
// QuickIdeaList is quick_ideas' output.
|
||||
type QuickIdeaList struct {
|
||||
// Ideas are the ideas that passed every rule, in the model's order.
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
// Dropped are the ideas the model gave that broke a rule, in the
|
||||
// model's order. It is never part of the model's reply.
|
||||
Dropped []DroppedIdea `json:"-"`
|
||||
}
|
||||
|
||||
// QuickIdeas runs quick_ideas v1 on in and returns ideas that repeat no
|
||||
// excluded name nor each other (harness.Similar, on the normalized
|
||||
// names); the rest are in Dropped. Whether an idea is of in.Kind is not
|
||||
// decided here: the model's estimates never decide it, the grounded
|
||||
// recipe does. scope names the request (see harness.Run; QuickIdeasScope).
|
||||
// A kind CheckQuickKind refuses, or an exclude list CleanExclude refuses,
|
||||
// fails before any LLM call.
|
||||
func QuickIdeas(ctx context.Context, cfg harness.Config, scope string, in QuickIdeasRequest) (QuickIdeaList, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
exclude, err := CleanExclude(in.Exclude)
|
||||
if err != nil {
|
||||
return QuickIdeaList{}, fmt.Errorf("skills: quick_ideas: %w", err)
|
||||
}
|
||||
in.Exclude = exclude
|
||||
return harness.Run(ctx, cfg, quickIdeas{in: in}, scope, in)
|
||||
}
|
||||
|
||||
// QuickIdeasScope is the harness scope of a request: its kind and its
|
||||
// normalized excluded names, in order, so two requests share a run only
|
||||
// when they ask for the same page.
|
||||
func QuickIdeasScope(in QuickIdeasRequest) string {
|
||||
parts := make([]string, len(in.Exclude))
|
||||
for i, name := range in.Exclude {
|
||||
parts[i] = harness.Normalize(name)
|
||||
}
|
||||
return "kind=" + in.Kind + ";" + strings.Join(parts, "|")
|
||||
}
|
||||
|
||||
// CheckQuickKind returns why kind is not one of QuickKinds, or nil. There
|
||||
// is no default kind.
|
||||
func CheckQuickKind(kind string) error {
|
||||
if !slices.Contains(QuickKinds, kind) {
|
||||
return fmt.Errorf("kind %q is not one of %s", kind, strings.Join(QuickKinds, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanExclude returns the excluded names with each on one line, never
|
||||
// nil, or why they cannot be used: there are more than MaxExclude, or a
|
||||
// name is blank, longer than ExcludeLimit characters, has nothing left
|
||||
// once normalized (harness.Normalize), or normalizes the same as an
|
||||
// earlier one. An empty list, the first page's, is valid.
|
||||
func CleanExclude(list []string) ([]string, error) {
|
||||
if len(list) > MaxExclude {
|
||||
return nil, fmt.Errorf("%d names excluded; exclude at most %d", len(list), MaxExclude)
|
||||
}
|
||||
out := make([]string, len(list))
|
||||
seen := map[string]string{}
|
||||
for i, name := range list {
|
||||
name = oneLine(name)
|
||||
switch {
|
||||
case name == "":
|
||||
return nil, fmt.Errorf("excluded name %d is blank", i+1)
|
||||
case utf8.RuneCountInString(name) > ExcludeLimit:
|
||||
return nil, fmt.Errorf("excluded name %d is over %d characters", i+1, ExcludeLimit)
|
||||
}
|
||||
n := harness.Normalize(name)
|
||||
if n == "" {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, names no dinner", i+1, name)
|
||||
}
|
||||
if first, ok := seen[n]; ok {
|
||||
return nil, fmt.Errorf("excluded name %d, %q, repeats %q", i+1, name, first)
|
||||
}
|
||||
seen[n] = name
|
||||
out[i] = name
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// quickIdeas is the skill. It holds its input because post-processing
|
||||
// compares the output with it.
|
||||
type quickIdeas struct {
|
||||
in QuickIdeasRequest
|
||||
}
|
||||
|
||||
var _ harness.Skill[QuickIdeasRequest, QuickIdeaList] = quickIdeas{}
|
||||
|
||||
func (quickIdeas) Name() string { return "quick_ideas" }
|
||||
|
||||
func (quickIdeas) Version() int { return 1 }
|
||||
|
||||
func (quickIdeas) Timeout() time.Duration { return harness.DefaultTimeout }
|
||||
|
||||
// quickIdeasPrompt is the template data of quick_ideas.v1.tmpl.
|
||||
type quickIdeasPrompt struct {
|
||||
// Count is how many ideas to ask for.
|
||||
Count int
|
||||
// Crockpot is set for QuickCrockpot, unset for QuickUnder30.
|
||||
Crockpot bool
|
||||
}
|
||||
|
||||
// PromptInput puts the household context, then the names already shown,
|
||||
// in the household context block.
|
||||
func (quickIdeas) PromptInput(in QuickIdeasRequest) (harness.PromptInput, error) {
|
||||
if err := CheckQuickKind(in.Kind); err != nil {
|
||||
return harness.PromptInput{}, err
|
||||
}
|
||||
sections := append(in.Context.Sections(), harness.ContextSection{Title: "Already shown", Lines: in.Exclude})
|
||||
return harness.PromptInput{
|
||||
Data: quickIdeasPrompt{Count: QuickIdeasRequested, Crockpot: in.Kind == QuickCrockpot},
|
||||
Context: sections,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (quickIdeas) Schema() llm.JSONSchema {
|
||||
return llm.JSONSchema{Name: "quick_ideas", Schema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"est_minutes": {"type": "integer"},
|
||||
"method": {"type": "string", "enum": ["stovetop", "oven", "crockpot", "grill", "other"]},
|
||||
"difficulty": {"type": "string", "enum": ["easy", "medium", "hard"]},
|
||||
"search_query": {"type": "string"}
|
||||
},
|
||||
"required": ["name", "est_minutes", "method", "difficulty", "search_query"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ideas"],
|
||||
"additionalProperties": false
|
||||
}`)}
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes an idea, requiring each of its fields as the
|
||||
// schema does: encoding/json would otherwise read a missing or null one as
|
||||
// empty.
|
||||
func (i *QuickIdea) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "name", "est_minutes", "method", "difficulty", "search_query"); err != nil {
|
||||
return fmt.Errorf("idea %s", err)
|
||||
}
|
||||
type plain QuickIdea
|
||||
return json.Unmarshal(data, (*plain)(i))
|
||||
}
|
||||
|
||||
// UnmarshalJSON decodes the output, requiring its one field as the schema
|
||||
// does.
|
||||
func (out *QuickIdeaList) UnmarshalJSON(data []byte) error {
|
||||
if err := requireFields(data, nil, "ideas"); err != nil {
|
||||
return fmt.Errorf("output %s", err)
|
||||
}
|
||||
type plain QuickIdeaList
|
||||
return json.Unmarshal(data, (*plain)(out))
|
||||
}
|
||||
|
||||
// Validate finds what makes the reply unusable as a whole: no ideas, too
|
||||
// many, or one that is malformed. A well-formed idea that breaks a rule is
|
||||
// dropped by PostProcess instead.
|
||||
func (quickIdeas) Validate(out QuickIdeaList) []string {
|
||||
var problems []string
|
||||
switch n := len(out.Ideas); {
|
||||
case n == 0 && len(out.Dropped) == 0:
|
||||
problems = append(problems, fmt.Sprintf("ideas is empty; give %d ideas", QuickIdeasRequested))
|
||||
case n > QuickIdeasRequested:
|
||||
problems = append(problems, fmt.Sprintf("ideas has %d entries; give at most %d ideas", n, QuickIdeasRequested))
|
||||
}
|
||||
for i, idea := range out.Ideas {
|
||||
for _, f := range []struct{ field, value string }{{"name", idea.Name}, {"search_query", idea.SearchQuery}} {
|
||||
if strings.TrimSpace(f.value) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d has an empty %s; give one", i+1, f.field))
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(idea.Name) != "" && harness.Normalize(idea.Name) == "" {
|
||||
problems = append(problems, fmt.Sprintf("idea %d name %q has only filler words; name the dish", i+1, idea.Name))
|
||||
}
|
||||
if !slices.Contains(methods, idea.Method) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d method %q is not one of %s", i+1, idea.Method, strings.Join(methods, ", ")))
|
||||
}
|
||||
if idea.EstMinutes <= 0 {
|
||||
problems = append(problems, fmt.Sprintf("idea %d est_minutes %d is not positive; give the minutes from prep to table", i+1, idea.EstMinutes))
|
||||
}
|
||||
if !slices.Contains(difficulties, idea.Difficulty) {
|
||||
problems = append(problems, fmt.Sprintf("idea %d difficulty %q is not one of %s", i+1, idea.Difficulty, strings.Join(difficulties, ", ")))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// PostProcess puts every idea's text on one line and moves each that
|
||||
// repeats an excluded name or an earlier idea (harness.Similar, which
|
||||
// compares normalized names) to Dropped. The model's method and minutes
|
||||
// are kept as estimates and never decide the kind.
|
||||
func (s quickIdeas) PostProcess(out QuickIdeaList) (QuickIdeaList, error) {
|
||||
result := QuickIdeaList{Ideas: []QuickIdea{}}
|
||||
for _, idea := range out.Ideas {
|
||||
idea = QuickIdea{
|
||||
Name: oneLine(idea.Name),
|
||||
EstMinutes: idea.EstMinutes,
|
||||
Method: idea.Method,
|
||||
Difficulty: idea.Difficulty,
|
||||
SearchQuery: oneLine(idea.SearchQuery),
|
||||
}
|
||||
if why := s.drop(idea, result.Ideas); why != "" {
|
||||
result.Dropped = append(result.Dropped, DroppedIdea{Name: idea.Name, Why: why})
|
||||
continue
|
||||
}
|
||||
result.Ideas = append(result.Ideas, idea)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// drop returns why idea must be dropped, or "" when it may be kept.
|
||||
func (s quickIdeas) drop(idea QuickIdea, kept []QuickIdea) string {
|
||||
for _, name := range s.in.Exclude {
|
||||
if harness.Similar(idea.Name, name) {
|
||||
return fmt.Sprintf("repeats %q, already shown", name)
|
||||
}
|
||||
}
|
||||
for _, k := range kept {
|
||||
if harness.Similar(idea.Name, k.Name) {
|
||||
return fmt.Sprintf("repeats the idea %q", k.Name)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
package skills
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
)
|
||||
|
||||
// quickTastes is the household context of the quick_ideas tests.
|
||||
var quickTastes = harness.HouseholdContext{
|
||||
Today: date(2026, 10, 8),
|
||||
Timezone: "America/New_York",
|
||||
Regulars: []harness.RegularDinner{
|
||||
{Name: "Beef Chili", Cuisine: "Tex-Mex", Method: "crockpot", Minutes: 240, Favorite: true},
|
||||
{Name: "Chicken Fajitas", Cuisine: "Tex-Mex", Method: "stovetop", Minutes: 30},
|
||||
},
|
||||
QuestLog: []string{"Pad Thai"},
|
||||
}
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, strings.ToLower(name)+" recipe")
|
||||
}
|
||||
|
||||
// quick runs QuickIdeas on in with f and checks f was called exactly
|
||||
// calls times, each at the household temperature.
|
||||
func quick(t *testing.T, f *llm.Fake, in QuickIdeasRequest, calls int) (QuickIdeaList, []llm.Request, error) {
|
||||
t.Helper()
|
||||
out, err := QuickIdeas(context.Background(), testConfig(t, f), QuickIdeasScope(in), in)
|
||||
reqs := f.Requests()
|
||||
if len(reqs) != calls {
|
||||
t.Fatalf("Generate called %d times, want %d", len(reqs), calls)
|
||||
}
|
||||
if g, _, _ := f.Remaining(); g != 0 {
|
||||
t.Fatalf("%d scripted Generate results unused", g)
|
||||
}
|
||||
for i, req := range reqs {
|
||||
if req.Temperature == nil || *req.Temperature != 0.7 {
|
||||
t.Fatalf("request %d temperature = %v, want 0.7", i+1, req.Temperature)
|
||||
}
|
||||
if req.Schema == nil || req.Schema.Name != "quick_ideas" {
|
||||
t.Fatalf("request %d schema = %v, want quick_ideas", i+1, req.Schema)
|
||||
}
|
||||
}
|
||||
return out, reqs, err
|
||||
}
|
||||
|
||||
func TestQuickIdeasPrompt(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
kind, golden, user, rule string
|
||||
}{
|
||||
{QuickCrockpot, "quick_ideas_crockpot", "Suggest 12 crock pot dinner ideas.",
|
||||
`Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".`},
|
||||
{QuickUnder30, "quick_ideas_under30", "Suggest 12 dinner ideas ready in 30 minutes or less.",
|
||||
"Every idea must take 30 minutes or less from starting prep to the table."},
|
||||
} {
|
||||
t.Run(tc.kind, func(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Chicken Fried Rice", "stovetop", 25))))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: tc.kind, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
_, reqs, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
system, user := reqs[0].System, reqs[0].Messages[0].Content
|
||||
golden(t, tc.golden+".system.golden", system)
|
||||
golden(t, tc.golden+".user.golden", user)
|
||||
for _, part := range []string{
|
||||
"## Household context",
|
||||
"### Regulars\n- Beef Chili",
|
||||
"### Quest log\n- Pad Thai",
|
||||
"### Already shown\n- Beef Stew\n- Shrimp Tacos",
|
||||
tc.rule,
|
||||
"## Output contract",
|
||||
} {
|
||||
if !strings.Contains(system, part) {
|
||||
t.Errorf("system prompt lacks %q", part)
|
||||
}
|
||||
}
|
||||
if user != tc.user {
|
||||
t.Errorf("user message = %q, want %q", user, tc.user)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasNeverReturnExcludedNames(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(
|
||||
// The excluded names, spelled another way: the normalized name
|
||||
// decides.
|
||||
quickIdea("Easy Beef Stew!", "crockpot", 300),
|
||||
quickIdea("shrimp-tacos", "stovetop", 20),
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
// A repeat of an idea already kept.
|
||||
quickIdea("The Pulled Pork", "crockpot", 420),
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 25),
|
||||
)))
|
||||
in := QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot, Exclude: []string{"Beef Stew", "Shrimp Tacos"}}
|
||||
out, _, err := quick(t, &f, in, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, i := range out.Ideas {
|
||||
kept = append(kept, i.Name)
|
||||
}
|
||||
// Chicken Fried Rice is not a crock pot dinner, but the grounded
|
||||
// recipe, not the model's estimate, decides that later.
|
||||
if want := []string{"Pulled Pork", "Chicken Fried Rice"}; !reflect.DeepEqual(kept, want) {
|
||||
t.Fatalf("ideas = %q, want %q", kept, want)
|
||||
}
|
||||
want := []DroppedIdea{
|
||||
{Name: "Easy Beef Stew!", Why: `repeats "Beef Stew", already shown`},
|
||||
{Name: "shrimp-tacos", Why: `repeats "Shrimp Tacos", already shown`},
|
||||
{Name: "The Pulled Pork", Why: `repeats the idea "Pulled Pork"`},
|
||||
}
|
||||
if !reflect.DeepEqual(out.Dropped, want) {
|
||||
t.Fatalf("dropped = %+v\nwant %+v", out.Dropped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRepairsMalformedReply(t *testing.T) {
|
||||
var f llm.Fake
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "microwave", 0))))
|
||||
f.QueueGenerate(reply(ideasReply(quickIdea("Pulled Pork", "crockpot", 480))))
|
||||
out, reqs, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickCrockpot}, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out.Ideas) != 1 {
|
||||
t.Fatalf("ideas = %+v", out.Ideas)
|
||||
}
|
||||
repair := reqs[1].Messages[len(reqs[1].Messages)-1].Content
|
||||
for _, part := range []string{`idea 1 method "microwave" is not one of`, "idea 1 est_minutes 0 is not positive"} {
|
||||
if !strings.Contains(repair, part) {
|
||||
t.Errorf("repair turn lacks %q:\n%s", part, repair)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefusesTooManyIdeas(t *testing.T) {
|
||||
var f llm.Fake
|
||||
var many []string
|
||||
for i := range QuickIdeasRequested + 1 {
|
||||
many = append(many, quickIdea(fmt.Sprintf("Dish %d", i+1), "stovetop", 20))
|
||||
}
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
f.QueueGenerate(reply(ideasReply(many...)))
|
||||
if _, _, err := quick(t, &f, QuickIdeasRequest{Context: quickTastes, Kind: QuickUnder30}, 2); err == nil || !strings.Contains(err.Error(), "give at most 12 ideas") {
|
||||
t.Fatalf("err = %v, want a bad output naming the limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasRefuseBadRequestBeforeCalling(t *testing.T) {
|
||||
for _, in := range []QuickIdeasRequest{
|
||||
{Context: quickTastes, Kind: ""},
|
||||
{Context: quickTastes, Kind: "quick"},
|
||||
{Context: quickTastes, Kind: QuickUnder30, Exclude: []string{"Beef Stew", "beef-stew"}},
|
||||
} {
|
||||
var f llm.Fake
|
||||
if _, _, err := quick(t, &f, in, 0); err == nil {
|
||||
t.Fatalf("%+v was accepted", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanExclude(t *testing.T) {
|
||||
got, err := CleanExclude([]string{" Beef Stew ", "Shrimp\tTacos"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []string{"Beef Stew", "Shrimp Tacos"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("CleanExclude = %q, want %q", got, want)
|
||||
}
|
||||
if got, err := CleanExclude(nil); err != nil || got == nil || len(got) != 0 {
|
||||
t.Fatalf("CleanExclude(nil) = %q, %v, want an empty list", got, err)
|
||||
}
|
||||
tooMany := make([]string, MaxExclude+1)
|
||||
for i := range tooMany {
|
||||
tooMany[i] = fmt.Sprintf("dish %d", i)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []string
|
||||
want string
|
||||
}{
|
||||
{"too many", tooMany, "exclude at most 60"},
|
||||
{"blank", []string{"Beef Stew", " "}, "excluded name 2 is blank"},
|
||||
{"too long", []string{strings.Repeat("a", ExcludeLimit+1)}, "over 120 characters"},
|
||||
{"filler only", []string{"Easy Recipe"}, "names no dinner"},
|
||||
{"repeat", []string{"Beef Stew", "easy beef-stew"}, `repeats "Beef Stew"`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if _, err := CleanExclude(tc.in); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckQuickKind(t *testing.T) {
|
||||
for _, kind := range QuickKinds {
|
||||
if err := CheckQuickKind(kind); err != nil {
|
||||
t.Errorf("CheckQuickKind(%q) = %v", kind, err)
|
||||
}
|
||||
}
|
||||
for _, kind := range []string{"", "Crockpot", "under_30", "quick"} {
|
||||
if err := CheckQuickKind(kind); err == nil || !strings.Contains(err.Error(), "is not one of crockpot, under30") {
|
||||
t.Errorf("CheckQuickKind(%q) = %v, want a refusal naming the kinds", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickIdeasScopeIdentifiesTheRequest(t *testing.T) {
|
||||
a := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"Beef Stew"}})
|
||||
if b := QuickIdeasScope(QuickIdeasRequest{Kind: QuickUnder30, Exclude: []string{"beef-stew"}}); a != b {
|
||||
t.Errorf("scopes of the same request differ: %q, %q", a, b)
|
||||
}
|
||||
for _, other := range []QuickIdeasRequest{
|
||||
{Kind: QuickCrockpot, Exclude: []string{"Beef Stew"}},
|
||||
{Kind: QuickUnder30},
|
||||
{Kind: QuickUnder30, Exclude: []string{"Beef Stew", "Shrimp Tacos"}},
|
||||
} {
|
||||
if QuickIdeasScope(other) == a {
|
||||
t.Errorf("%+v shares the scope %q", other, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
## Instructions
|
||||
You suggest dinner ideas the household can cook from the ingredients it has on hand, listed under "Ingredients on hand" in the household context.
|
||||
|
||||
Prefer ideas that use as many of those ingredients as possible and need as few others as possible. Pantry staples such as salt, pepper, oil and water need not be listed as missing. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- uses: the ingredients on hand the idea uses, each written exactly as it appears in the "Ingredients on hand" list. List only ingredients from that list, never one it does not hold.
|
||||
- missing: the ingredients the idea needs that are not on hand, each with a quantity where it helps, e.g. "1 lime". Leave it empty when nothing is missing.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea, for a household with "chicken thighs", "rice" and "cilantro" on hand (an example only, not about this household):
|
||||
{"name": "Cilantro Lime Chicken and Rice", "uses": ["chicken thighs", "rice", "cilantro"], "missing": ["1 lime"], "est_minutes": 35, "method": "stovetop", "difficulty": "easy", "search_query": "cilantro lime chicken and rice recipe"}
|
||||
|
||||
## Household context
|
||||
### Ingredients on hand
|
||||
- Chicken thighs
|
||||
- rice
|
||||
- cilantro
|
||||
- black beans
|
||||
- red onion
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "ideas_from_ingredients" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"uses": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"missing": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"uses",
|
||||
"missing",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 8 dinner ideas from the ingredients on hand.
|
||||
@@ -1,93 +0,0 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must be a crock pot dinner: cooked mainly in a crock pot (slow cooker), with method "crockpot".
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Slow Cooker Chicken Tortilla Soup", "est_minutes": 375, "method": "crockpot", "difficulty": "easy", "search_query": "slow cooker chicken tortilla soup recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 12 crock pot dinner ideas.
|
||||
@@ -1,93 +0,0 @@
|
||||
## Instructions
|
||||
You suggest Quick dinner ideas the household would like, picked from its tastes: the dinners it already makes, its Regulars in the household context, and the dinners on its Quest log.
|
||||
|
||||
Every idea must take 30 minutes or less from starting prep to the table.
|
||||
|
||||
Never suggest a dinner listed under "Already shown" in the household context, nor one of those under another name. Vary the ideas: no two may be the same dish.
|
||||
|
||||
Give for each idea:
|
||||
- name: the dinner's usual name, as a recipe site would title it.
|
||||
- est_minutes: your estimate of the whole minutes from starting prep to the table.
|
||||
- method: how it is mainly cooked: "stovetop", "oven", "crockpot" (slow cooker or crock pot), "grill" or "other".
|
||||
- difficulty: "easy", "medium" or "hard" for a home cook.
|
||||
- search_query: a web search query that finds a recipe for this dinner.
|
||||
|
||||
One valid idea (an example only, not about this household):
|
||||
{"name": "Garlic Butter Shrimp Pasta", "est_minutes": 25, "method": "stovetop", "difficulty": "easy", "search_query": "garlic butter shrimp pasta recipe"}
|
||||
|
||||
## Household context
|
||||
### Today
|
||||
- Thursday 2026-10-08 (America/New_York)
|
||||
|
||||
### Regulars
|
||||
- Beef Chili (Tex-Mex, crockpot, 240 min, favorite)
|
||||
- Chicken Fajitas (Tex-Mex, stovetop, 30 min)
|
||||
|
||||
### Quest log
|
||||
- Pad Thai
|
||||
|
||||
### Passed, oldest first
|
||||
(none)
|
||||
|
||||
### Recent plans
|
||||
(none)
|
||||
|
||||
### Already shown
|
||||
- Beef Stew
|
||||
- Shrimp Tacos
|
||||
|
||||
## Output contract
|
||||
This contract overrides any earlier instruction about the form of your reply. Reply with one JSON value matching the JSON Schema "quick_ideas" below, and nothing else: no prose and no Markdown code fences.
|
||||
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ideas": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"est_minutes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"stovetop",
|
||||
"oven",
|
||||
"crockpot",
|
||||
"grill",
|
||||
"other"
|
||||
]
|
||||
},
|
||||
"difficulty": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"easy",
|
||||
"medium",
|
||||
"hard"
|
||||
]
|
||||
},
|
||||
"search_query": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"est_minutes",
|
||||
"method",
|
||||
"difficulty",
|
||||
"search_query"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"ideas"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
Suggest 12 dinner ideas ready in 30 minutes or less.
|
||||
@@ -87,9 +87,6 @@ type Querier interface {
|
||||
// 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)
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error)
|
||||
// Returns sql.ErrNoRows until the row exists.
|
||||
GetAISettings(ctx context.Context) (AiSetting, error)
|
||||
// The job of kind queued or running for the week starting week_start,
|
||||
@@ -187,9 +184,6 @@ type Querier interface {
|
||||
// The items of list list_id with their group's dinner, in list order: by
|
||||
// group, then by item.
|
||||
ListShoppingItems(ctx context.Context, listID string) ([]ListShoppingItemsRow, error)
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error)
|
||||
// The ids of the dinners with status quest_log planned on date, on a plan
|
||||
// of any status, that have no Rotation check for date yet.
|
||||
ListTriedCandidates(ctx context.Context, date string) ([]string, error)
|
||||
|
||||
@@ -112,28 +112,6 @@ func (q *Queries) DeleteShoppingItem(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
const freezeShoppingList = `-- name: FreezeShoppingList :execrows
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL
|
||||
`
|
||||
|
||||
type FreezeShoppingListParams struct {
|
||||
FrozenAt sql.NullString
|
||||
UpdatedAt string
|
||||
ID string
|
||||
}
|
||||
|
||||
// Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
// frozen already.
|
||||
func (q *Queries) FreezeShoppingList(ctx context.Context, arg FreezeShoppingListParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, freezeShoppingList, arg.FrozenAt, arg.UpdatedAt, arg.ID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const getShoppingItemList = `-- name: GetShoppingItemList :one
|
||||
SELECT shopping_lists.id, shopping_lists.week_start, shopping_lists.plan_id, shopping_lists.title, shopping_lists.status, shopping_lists.sent_to_chat_at, shopping_lists.frozen_at, shopping_lists.created_at, shopping_lists.updated_at
|
||||
FROM shopping_items
|
||||
@@ -321,47 +299,6 @@ func (q *Queries) ListShoppingItems(ctx context.Context, listID string) ([]ListS
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listShoppingListsThrough = `-- name: ListShoppingListsThrough :many
|
||||
SELECT id, week_start, plan_id, title, status, sent_to_chat_at, frozen_at, created_at, updated_at FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC
|
||||
`
|
||||
|
||||
// The Supply runs of the weeks starting on or before week_start, newest
|
||||
// week first.
|
||||
func (q *Queries) ListShoppingListsThrough(ctx context.Context, weekStart string) ([]ShoppingList, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listShoppingListsThrough, weekStart)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []ShoppingList
|
||||
for rows.Next() {
|
||||
var i ShoppingList
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.WeekStart,
|
||||
&i.PlanID,
|
||||
&i.Title,
|
||||
&i.Status,
|
||||
&i.SentToChatAt,
|
||||
&i.FrozenAt,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
); 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 moveShoppingGroup = `-- name: MoveShoppingGroup :exec
|
||||
UPDATE shopping_groups
|
||||
SET position = ?, updated_at = ?
|
||||
|
||||
+3
-100
@@ -288,89 +288,6 @@ func (s *Service) createRegular(ctx context.Context, r NewRegular, updatedBy str
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// IdeaOrigins are the origins of a dinner added from an idea: ingredients
|
||||
// for Cook from what I have, quick for Quick dinners.
|
||||
var IdeaOrigins = []string{"ingredients", "quick"}
|
||||
|
||||
// NewQuestLog is a dinner to add to the Quest log from an idea: its name,
|
||||
// the recipe the idea was already grounded in, and its origin, one of
|
||||
// IdeaOrigins.
|
||||
type NewQuestLog struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// AddToQuestLog adds n to the Quest log, recording updatedBy: a dinner
|
||||
// with status quest_log and origin n.Origin, grounded in recipe n.RecipeID
|
||||
// (its method, minutes, difficulty and photo), and returns it with
|
||||
// created true. When an unarchived dinner already repeats it, by the same
|
||||
// rule that refuses a duplicate Regular (a harness.Similar name, or the
|
||||
// recipe's link), that dinner is returned as it is, with created false,
|
||||
// so adding the same idea again never makes a second dinner: a Regular
|
||||
// stays a Regular and a Quest log dinner stays in the Quest log. A
|
||||
// repeated dinner still awaiting the household's choice (a suggested Side
|
||||
// quest, or one tried and awaiting its Rotation check) is a
|
||||
// *DuplicateError and changes nothing: adding it would make that choice
|
||||
// for them. An unknown recipe is an error.
|
||||
func (s *Service) AddToQuestLog(ctx context.Context, n NewQuestLog, updatedBy string) (d Dinner, created bool, err error) {
|
||||
if updatedBy == "" {
|
||||
return Dinner{}, false, errors.New("dinners: add to Quest log: no member to record as updated_by")
|
||||
}
|
||||
if !slices.Contains(IdeaOrigins, n.Origin) {
|
||||
return Dinner{}, false, fmt.Errorf("dinners: add to Quest log: origin %q is not one of %s", n.Origin, strings.Join(IdeaOrigins, ", "))
|
||||
}
|
||||
name, err := checkName(n.Name)
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
var row db.Dinner
|
||||
err = s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
recipe, err := q.GetRecipe(ctx, n.RecipeID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: read recipe %s: %w", n.RecipeID, err)
|
||||
}
|
||||
existing, found, err := findRepeat(ctx, q, "", name, sql.NullString{String: recipe.SourceUrl, Valid: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
if existing.Status != StatusRegular && existing.Status != StatusQuestLog {
|
||||
return &DuplicateError{Existing: fromRow(existing)}
|
||||
}
|
||||
row = existing
|
||||
return nil
|
||||
}
|
||||
now := store.Now().String()
|
||||
row, err = q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(),
|
||||
Name: name,
|
||||
NormalizedName: harness.Normalize(name),
|
||||
Status: StatusQuestLog,
|
||||
Origin: n.Origin,
|
||||
Method: recipe.Method,
|
||||
MinutesTotal: recipe.MinutesTotal,
|
||||
Difficulty: recipe.Difficulty,
|
||||
RecipeID: sql.NullString{String: recipe.ID, Valid: true},
|
||||
PhotoID: recipe.PhotoID,
|
||||
GroundingStatus: GroundingGrounded,
|
||||
UpdatedBy: sql.NullString{String: updatedBy, Valid: true},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("dinners: add to Quest log: %w", err)
|
||||
}
|
||||
created = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Dinner{}, false, err
|
||||
}
|
||||
return fromRow(row), created, nil
|
||||
}
|
||||
|
||||
// List returns the dinners with status, which must be regular or
|
||||
// quest_log, ordered by normalized name. The Quest log is the dinners
|
||||
// accepted and not yet scheduled, so a quest_log dinner on the plan for the
|
||||
@@ -634,23 +551,9 @@ func checkRecipeURL(raw string) (*url.URL, error) {
|
||||
// source_url of the recipe it is grounded in, else the recipe_url still
|
||||
// awaiting grounding.
|
||||
func checkUnique(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) error {
|
||||
row, found, err := findRepeat(ctx, q, self, name, recipeURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
return &DuplicateError{Existing: fromRow(row)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findRepeat returns the first unarchived dinner other than self that
|
||||
// name or recipeURL repeats, as checkUnique decides it, and whether there
|
||||
// is one.
|
||||
func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL sql.NullString) (db.Dinner, bool, error) {
|
||||
rows, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return db.Dinner{}, false, fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
return fmt.Errorf("dinners: list for dedupe: %w", err)
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Dinner.ID == self {
|
||||
@@ -661,10 +564,10 @@ func findRepeat(ctx context.Context, q *db.Queries, self, name string, recipeURL
|
||||
link = row.RecipeSourceUrl
|
||||
}
|
||||
if harness.Similar(name, row.Dinner.Name) || (recipeURL.Valid && link == recipeURL) {
|
||||
return row.Dinner, true, nil
|
||||
return &DuplicateError{Existing: fromRow(row.Dinner)}
|
||||
}
|
||||
}
|
||||
return db.Dinner{}, false, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// PhotoURL is where the API serves a list-sized copy of photo id.
|
||||
|
||||
@@ -1,662 +0,0 @@
|
||||
// Package ideas finds dinner ideas for Cook from what I have and Quick
|
||||
// dinners: from a list of ingredients on hand, ideas_from_ingredients
|
||||
// proposes dinners, and for a kind of Quick dinner, quick_ideas does; each
|
||||
// is grounded with the recipe grounding pipeline (dropping those no recipe
|
||||
// grounds, and Quick dinners whose grounded recipe is not of the kind), and
|
||||
// the ideas kept are reported as a job's result, which fills as each idea
|
||||
// grounds. A job that keeps no idea, or fails, shows no idea: nothing is
|
||||
// made up instead (D-017). Ideas are not stored as dinners: the household
|
||||
// acts on one later.
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// Job kinds.
|
||||
const (
|
||||
// KindFromIngredients is the job that finds ideas from ingredients.
|
||||
KindFromIngredients = "ideas_from_ingredients"
|
||||
// KindQuick is the job that finds a page of Quick dinner ideas.
|
||||
KindQuick = "quick_ideas"
|
||||
)
|
||||
|
||||
// ContextBudget is the character budget of the household context
|
||||
// quick_ideas is given (see harness.BuildContext).
|
||||
const ContextBudget = 16000
|
||||
|
||||
// QuickMinutes is the most minutes from prep to table of a Quick dinner
|
||||
// not cooked in a crock pot.
|
||||
const QuickMinutes = 30
|
||||
|
||||
// Job stages, as the job's result reports them.
|
||||
const (
|
||||
StageSuggesting = "suggesting"
|
||||
StageGrounding = "grounding"
|
||||
StageDone = "done"
|
||||
)
|
||||
|
||||
// Reasons the job failed, stored as its failure code. Any other failure,
|
||||
// such as the database's, is the runner's internal_error.
|
||||
const (
|
||||
// ReasonAIFailed: ideas_from_ingredients or quick_ideas, or
|
||||
// normalize_recipe while grounding, failed; no idea is made up instead
|
||||
// (D-017).
|
||||
ReasonAIFailed = "ai_failed"
|
||||
// ReasonSearchNotConfigured: recipe search is not set up.
|
||||
ReasonSearchNotConfigured = "search_not_configured"
|
||||
// ReasonSearchFailed: the search provider failed.
|
||||
ReasonSearchFailed = "search_failed"
|
||||
// ReasonNoIdeasFound: every idea was dropped, by the skill's rules,
|
||||
// because no recipe grounds it, or because its grounded recipe is not
|
||||
// a Quick dinner (a Quick only request) or not of the kind asked for (a
|
||||
// Quick dinners request).
|
||||
ReasonNoIdeasFound = "no_ideas_found"
|
||||
)
|
||||
|
||||
// Reasons are every failure reason of both jobs.
|
||||
var Reasons = []string{ReasonAIFailed, ReasonSearchNotConfigured, ReasonSearchFailed, ReasonNoIdeasFound}
|
||||
|
||||
// InvalidError is a request the household must correct, such as an empty
|
||||
// ingredient list or an unknown kind of Quick dinner.
|
||||
type InvalidError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *InvalidError) Error() string { return e.Message }
|
||||
|
||||
// Generator runs ideas_from_ingredients on in; scope is
|
||||
// skills.IdeasScope(in). In production it is skills.IdeasFromIngredients
|
||||
// with the household's harness.Config.
|
||||
type Generator func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error)
|
||||
|
||||
// QuickGenerator runs quick_ideas on in; scope is skills.QuickIdeasScope(in).
|
||||
// In production it is skills.QuickIdeas with the household's
|
||||
// harness.Config.
|
||||
type QuickGenerator func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error)
|
||||
|
||||
// Service starts and runs idea jobs.
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
grounder *recipes.Grounder
|
||||
runner *jobs.Runner
|
||||
generate Generator
|
||||
quick QuickGenerator
|
||||
logger *slog.Logger
|
||||
job *jobs.Kind[FromIngredientsJob]
|
||||
quickJob *jobs.Kind[QuickJob]
|
||||
}
|
||||
|
||||
// NewService returns a service that reads the household context from st,
|
||||
// finds ideas with generate and Quick dinner ideas with quick, grounds
|
||||
// them with grounder and registers the KindFromIngredients and KindQuick
|
||||
// jobs on runner, which must not have started yet. The jobs are started
|
||||
// only by StartFromIngredients and StartQuick, which check the request
|
||||
// first.
|
||||
func NewService(st *store.Store, grounder *recipes.Grounder, generate Generator, quick QuickGenerator, runner *jobs.Runner, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("ideas: nil store")
|
||||
case grounder == nil:
|
||||
return nil, errors.New("ideas: nil grounder")
|
||||
case generate == nil:
|
||||
return nil, errors.New("ideas: nil generator")
|
||||
case quick == nil:
|
||||
return nil, errors.New("ideas: nil quick generator")
|
||||
case runner == nil:
|
||||
return nil, errors.New("ideas: nil job runner")
|
||||
case logger == nil:
|
||||
return nil, errors.New("ideas: nil logger")
|
||||
}
|
||||
s := &Service{st: st, grounder: grounder, runner: runner, generate: generate, quick: quick, logger: logger}
|
||||
var err error
|
||||
if s.job, err = register(runner, KindFromIngredients, s.run, withoutIdeas[Result]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.quickJob, err = register(runner, KindQuick, s.runQuick, withoutIdeas[QuickResult]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// register registers the service-only job name, run by run, whose failed
|
||||
// jobs show their result through failed.
|
||||
func register[P any, R jobs.Public](runner *jobs.Runner, name string, run jobs.Handler[P, R], failed func(json.RawMessage) (json.RawMessage, error)) (*jobs.Kind[P], error) {
|
||||
kind, err := jobs.Register(runner, name, run, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.ServiceOnly(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := kind.FailedResult(failed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// FromIngredientsJob is the KindFromIngredients payload: the ingredients
|
||||
// on hand, as skills.CleanIngredients returns them, and whether only
|
||||
// Quick dinners are wanted.
|
||||
type FromIngredientsJob struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// Started is the job a request started.
|
||||
type Started struct {
|
||||
JobID string `json:"jobId"`
|
||||
}
|
||||
|
||||
// StartFromIngredients enqueues a KindFromIngredients job for ingredients
|
||||
// and returns its id. An ingredient list skills.CleanIngredients refuses
|
||||
// is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartFromIngredients(ctx context.Context, ingredients []string, quickOnly bool) (Started, error) {
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "ingredients: " + err.Error()}
|
||||
}
|
||||
id, err := s.job.Enqueue(ctx, FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// Idea is a grounded idea as the job's result shows it. Method,
|
||||
// MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates.
|
||||
type Idea struct {
|
||||
Name string `json:"name"`
|
||||
// Uses are the household's ingredients the idea uses, as the household
|
||||
// wrote them and in its order.
|
||||
Uses []string `json:"uses"`
|
||||
// Missing are the ingredients the idea needs that the household does
|
||||
// not have.
|
||||
Missing []string `json:"missing"`
|
||||
// Summary is the line shown under the idea, e.g. "Uses 3 of 5 ·
|
||||
// missing: 1 lime" (see skills.IdeaSummary).
|
||||
Summary string `json:"summary"`
|
||||
// Method is stovetop, oven, crockpot, grill or other.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
// Quick says whether the idea is a Quick dinner (see IsQuick).
|
||||
Quick bool `json:"quick"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// Result is the KindFromIngredients result, published as the job goes:
|
||||
// its stage, the request, the ideas grounded so far in the model's order
|
||||
// (an idea that grounds sooner than one before it is shown first, and the
|
||||
// earlier one is put back in its place when it grounds),
|
||||
// and how many ideas were dropped, by ideas_from_ingredients' rules,
|
||||
// because no recipe grounds them, or, for a Quick only request, because
|
||||
// the grounded recipe is not a Quick dinner.
|
||||
type Result struct {
|
||||
Stage string `json:"stage"`
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly bool `json:"quick_only"`
|
||||
Ideas []Idea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r Result) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
// IsQuick reports whether a recipe cooked by method in minutes (nil when
|
||||
// unknown) is a Quick dinner: cooked in a crock pot, or 30 minutes or less
|
||||
// from prep to table.
|
||||
func IsQuick(method string, minutes *int) bool {
|
||||
return method == "crockpot" || (minutes != nil && *minutes <= QuickMinutes)
|
||||
}
|
||||
|
||||
// run runs a KindFromIngredients job: it asks ideas_from_ingredients for
|
||||
// skills.IdeasRequested ideas, then grounds them in the model's order, at
|
||||
// most the number still wanted at a time, until skills.IdeasKept are kept
|
||||
// or none are left. An idea no recipe grounds is dropped, and so, for a
|
||||
// Quick only request, is one whose grounded recipe is not a Quick dinner;
|
||||
// when every idea is dropped the job fails with ReasonNoIdeasFound. Any
|
||||
// other grounding failure fails the job, as does ideas_from_ingredients
|
||||
// failing, with no idea made up instead. The result is published at each
|
||||
// stage and again as each idea is kept, so it fills as the ideas ground.
|
||||
// Before the job fails, its result is published again with no ideas (see
|
||||
// finish).
|
||||
func (s *Service) run(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res, err := s.fill(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "ideas from ingredients", res, err, publish)
|
||||
}
|
||||
|
||||
// shownResult is a job result that shows ideas.
|
||||
type shownResult[R any] interface {
|
||||
jobs.Public
|
||||
// shown is how many ideas the result shows.
|
||||
shown() int
|
||||
// cleared is the result showing no ideas.
|
||||
cleared() R
|
||||
}
|
||||
|
||||
func (r Result) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r Result) cleared() Result {
|
||||
r.Ideas = []Idea{}
|
||||
return r
|
||||
}
|
||||
|
||||
// finish ends a job whose work returned res and err. A job that failed
|
||||
// has its result published again with no ideas, to tidy the stored
|
||||
// result. That write is not what keeps a failed job from showing ideas,
|
||||
// since it may fail and a job interrupted by restart never makes it:
|
||||
// withoutIdeas, applied whenever a failed job is read, is.
|
||||
func finish[R shownResult[R]](ctx context.Context, logger *slog.Logger, what string, res R, err error, publish func(R) error) (R, error) {
|
||||
var zero R
|
||||
if err == nil {
|
||||
return res, nil
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return zero, err
|
||||
}
|
||||
var failure *jobs.Failure
|
||||
if errors.As(err, &failure) {
|
||||
// Only the code is logged: the error may quote a model's or
|
||||
// search provider's response.
|
||||
logger.InfoContext(ctx, what+" failed", "reason", failure.Code)
|
||||
}
|
||||
if res.shown() > 0 {
|
||||
if perr := publish(res.cleared()); perr != nil {
|
||||
return zero, fmt.Errorf("ideas: clear failed job's ideas: %w", perr)
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// fill does run's work, returning a *jobs.Failure for a failure the
|
||||
// household may see. On failure it returns the result last published with
|
||||
// the error, so run can clear its ideas.
|
||||
func (s *Service) fill(ctx context.Context, p FromIngredientsJob, publish func(Result) error) (Result, error) {
|
||||
res := Result{Stage: StageSuggesting, Ingredients: p.Ingredients, QuickOnly: p.QuickOnly, Ideas: []Idea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
in := skills.IdeasRequest{Ingredients: p.Ingredients, QuickOnly: p.QuickOnly}
|
||||
out, err := s.generate(ctx, skills.IdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.IdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (Idea, bool) {
|
||||
idea := ideaOf(out.Ideas[i], gr)
|
||||
return idea, !p.QuickOnly || idea.Quick
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// keepGrounded grounds reqs with g in their order, at most the number
|
||||
// still wanted at a time, until want ideas are shown or none are left. As
|
||||
// each grounds, keep makes its idea from request i's grounding and says
|
||||
// whether it is kept: a kept idea is put in *shown in its place in reqs'
|
||||
// order (an idea that grounds sooner than one before it is shown first,
|
||||
// and the earlier one is put back in its place when it grounds) and
|
||||
// publish is called. An idea no recipe grounds, or that keep refuses,
|
||||
// counts in *dropped. Any other grounding failure, or publish failing, is
|
||||
// returned (see ground).
|
||||
func keepGrounded[T any](ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, want int, shown *[]T, dropped *int, keep func(i int, gr recipes.Grounded) (T, bool), publish func() error) error {
|
||||
// order[k] is the index in reqs of (*shown)[k], so each idea is put in
|
||||
// its place in reqs' order as it grounds.
|
||||
var order []int
|
||||
for next := 0; len(*shown) < want && next < len(reqs); {
|
||||
first := next
|
||||
round := reqs[first:min(first+want-len(*shown), len(reqs))]
|
||||
next += len(round)
|
||||
err := ground(ctx, g, round, func(i int, gr *recipes.Grounded) error {
|
||||
if gr == nil {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
idea, ok := keep(first+i, *gr)
|
||||
if !ok {
|
||||
*dropped++
|
||||
return nil
|
||||
}
|
||||
at := len(order)
|
||||
for at > 0 && order[at-1] > first+i {
|
||||
at--
|
||||
}
|
||||
order = slices.Insert(order, at, first+i)
|
||||
*shown = slices.Insert(*shown, at, idea)
|
||||
return publish()
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ground grounds reqs with g and calls kept, one call at a time, as each
|
||||
// request grounds, with its index in reqs and its grounding, or nil when
|
||||
// no recipe grounds it. Any other grounding failure, or kept failing,
|
||||
// stops the groundings still running and is returned, as a *jobs.Failure
|
||||
// when the household may see it.
|
||||
func ground(ctx context.Context, g *recipes.Grounder, reqs []recipes.GroundRequest, kept func(i int, gr *recipes.Grounded) error) error {
|
||||
groundCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
var failed error
|
||||
g.GroundEach(groundCtx, reqs, func(i int, r recipes.GroundResult) {
|
||||
if failed != nil {
|
||||
return
|
||||
}
|
||||
var ungroundable *recipes.ErrUngroundable
|
||||
switch {
|
||||
case r.Err == nil:
|
||||
failed = kept(i, &r.Grounded)
|
||||
case errors.As(r.Err, &ungroundable):
|
||||
failed = kept(i, nil)
|
||||
case errors.Is(r.Err, recipes.ErrSearchNotConfigured):
|
||||
failed = &jobs.Failure{Code: ReasonSearchNotConfigured}
|
||||
case errors.Is(r.Err, recipes.ErrSearchFailed):
|
||||
failed = &jobs.Failure{Code: ReasonSearchFailed}
|
||||
case errors.Is(r.Err, recipes.ErrNormalizeFailed):
|
||||
failed = &jobs.Failure{Code: ReasonAIFailed}
|
||||
default:
|
||||
failed = fmt.Errorf("ideas: ground %q: %w", reqs[i].DishName, r.Err)
|
||||
}
|
||||
if failed != nil {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return failed
|
||||
}
|
||||
|
||||
// withoutIdeas is a failed job's stored result as the job shows it: with no
|
||||
// ideas, however the job failed, so a failed job never shows ideas kept
|
||||
// before the failure.
|
||||
func withoutIdeas[R shownResult[R]](stored json.RawMessage) (json.RawMessage, error) {
|
||||
var res R
|
||||
if err := json.Unmarshal(stored, &res); err != nil {
|
||||
return nil, fmt.Errorf("ideas: decode failed job's result: %w", err)
|
||||
}
|
||||
return res.cleared().PublicJSON()
|
||||
}
|
||||
|
||||
// ideaOf is idea as grounded by gr.
|
||||
func ideaOf(idea skills.Idea, gr recipes.Grounded) Idea {
|
||||
return Idea{
|
||||
Name: idea.Name,
|
||||
Uses: idea.Uses,
|
||||
Missing: idea.Missing,
|
||||
Summary: idea.Summary,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
Quick: IsQuick(gr.Method, gr.MinutesTotal),
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
|
||||
// QuickJob is the KindQuick payload: the kind of Quick dinner wanted,
|
||||
// skills.QuickCrockpot or skills.QuickUnder30, and the names already shown
|
||||
// to the household, as skills.CleanExclude returns them.
|
||||
type QuickJob struct {
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// StartQuick enqueues a KindQuick job for a page of Quick dinner ideas of
|
||||
// kind, none repeating a name in exclude, and returns its id. A kind
|
||||
// skills.CheckQuickKind refuses, with no default, or an exclude list
|
||||
// skills.CleanExclude refuses, is an *InvalidError, and starts nothing.
|
||||
func (s *Service) StartQuick(ctx context.Context, kind string, exclude []string) (Started, error) {
|
||||
if err := skills.CheckQuickKind(kind); err != nil {
|
||||
return Started{}, &InvalidError{Message: "kind: " + err.Error()}
|
||||
}
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
return Started{}, &InvalidError{Message: "exclude: " + err.Error()}
|
||||
}
|
||||
id, err := s.quickJob.Enqueue(ctx, QuickJob{Kind: kind, Exclude: names})
|
||||
if err != nil {
|
||||
return Started{}, fmt.Errorf("ideas: enqueue: %w", err)
|
||||
}
|
||||
return Started{JobID: id}, nil
|
||||
}
|
||||
|
||||
// QuickIdea is a grounded Quick dinner idea as the job's result shows it.
|
||||
// Method, MinutesTotal and Difficulty are the grounded recipe's, never the
|
||||
// model's estimates, and always make it of the kind asked for (see
|
||||
// OfQuickKind).
|
||||
type QuickIdea struct {
|
||||
Name string `json:"name"`
|
||||
// Method is stovetop, oven, crockpot, grill or other: crockpot for
|
||||
// skills.QuickCrockpot.
|
||||
Method string `json:"method"`
|
||||
// MinutesTotal is prep to table, null when the recipe does not say:
|
||||
// never null, and at most QuickMinutes, for skills.QuickUnder30.
|
||||
MinutesTotal *int `json:"minutes_total"`
|
||||
// Difficulty is easy, medium or hard.
|
||||
Difficulty string `json:"difficulty"`
|
||||
RecipeID string `json:"recipe_id"`
|
||||
PhotoURL string `json:"photo_url"`
|
||||
SourceURL string `json:"source_url"`
|
||||
SiteName string `json:"site_name"`
|
||||
}
|
||||
|
||||
// QuickResult is the KindQuick result, published as the job goes: its
|
||||
// stage, the request, the ideas grounded so far in the model's order (as
|
||||
// in Result), and how many ideas were dropped, by quick_ideas' rules
|
||||
// (repeating a name already shown or an earlier idea), because no recipe
|
||||
// grounds them, or because the grounded recipe is not of the kind.
|
||||
type QuickResult struct {
|
||||
Stage string `json:"stage"`
|
||||
Kind string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Ideas []QuickIdea `json:"ideas"`
|
||||
Dropped int `json:"dropped"`
|
||||
}
|
||||
|
||||
func (r QuickResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
|
||||
func (r QuickResult) shown() int { return len(r.Ideas) }
|
||||
|
||||
func (r QuickResult) cleared() QuickResult {
|
||||
r.Ideas = []QuickIdea{}
|
||||
return r
|
||||
}
|
||||
|
||||
// OfQuickKind reports whether a recipe cooked by method in minutes (nil
|
||||
// when unknown) is a Quick dinner of kind: for skills.QuickCrockpot,
|
||||
// cooked in a crock pot (normalize_recipe writes a slow cooker as
|
||||
// crockpot); for skills.QuickUnder30, known to be 30 minutes or less from
|
||||
// prep to table. Any other kind is none.
|
||||
func OfQuickKind(kind, method string, minutes *int) bool {
|
||||
switch kind {
|
||||
case skills.QuickCrockpot:
|
||||
return method == "crockpot"
|
||||
case skills.QuickUnder30:
|
||||
return minutes != nil && *minutes <= QuickMinutes
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runQuick runs a KindQuick job: from the household context, it asks
|
||||
// quick_ideas for skills.QuickIdeasRequested ideas of the kind, more than
|
||||
// a page holds, then grounds them in the model's order, at most the number
|
||||
// still wanted at a time, until skills.QuickIdeasKept are kept or none are
|
||||
// left. An idea no recipe grounds is dropped, and so is one whose grounded
|
||||
// recipe is not of the kind (OfQuickKind): the model's estimate never
|
||||
// keeps an idea. When fewer than skills.QuickIdeasKept survive, the page
|
||||
// shows only those: fewer is honest, and it is never padded with an idea
|
||||
// not of the kind. When every idea is dropped the job fails with
|
||||
// ReasonNoIdeasFound. Any other grounding failure fails the job, as does
|
||||
// quick_ideas failing, with no idea made up instead. The result is
|
||||
// published at each stage and again as each idea is kept, and a failed job
|
||||
// shows no ideas, as for KindFromIngredients (see finish).
|
||||
func (s *Service) runQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res, err := s.fillQuick(ctx, p, publish)
|
||||
return finish(ctx, s.logger, "quick ideas", res, err, publish)
|
||||
}
|
||||
|
||||
// fillQuick does runQuick's work as fill does run's.
|
||||
func (s *Service) fillQuick(ctx context.Context, p QuickJob, publish func(QuickResult) error) (QuickResult, error) {
|
||||
res := QuickResult{Stage: StageSuggesting, Kind: p.Kind, Exclude: p.Exclude, Ideas: []QuickIdea{}}
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("ideas: %w", err)
|
||||
}
|
||||
in := skills.QuickIdeasRequest{Context: hctx, Kind: p.Kind, Exclude: p.Exclude}
|
||||
out, err := s.quick(ctx, skills.QuickIdeasScope(in), in)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return res, ctx.Err()
|
||||
}
|
||||
return res, &jobs.Failure{Code: ReasonAIFailed}
|
||||
}
|
||||
res.Stage = StageGrounding
|
||||
res.Dropped = len(out.Dropped)
|
||||
if err := publish(res); err != nil {
|
||||
return res, err
|
||||
}
|
||||
reqs := make([]recipes.GroundRequest, len(out.Ideas))
|
||||
for i, idea := range out.Ideas {
|
||||
reqs[i] = recipes.GroundRequest{DishName: idea.Name, Query: idea.SearchQuery}
|
||||
}
|
||||
err = keepGrounded(ctx, s.grounder, reqs, skills.QuickIdeasKept, &res.Ideas, &res.Dropped, func(i int, gr recipes.Grounded) (QuickIdea, bool) {
|
||||
return quickIdeaOf(out.Ideas[i], gr), OfQuickKind(p.Kind, gr.Method, gr.MinutesTotal)
|
||||
}, func() error { return publish(res) })
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if len(res.Ideas) == 0 {
|
||||
return res, &jobs.Failure{Code: ReasonNoIdeasFound}
|
||||
}
|
||||
res.Stage = StageDone
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// quickIdeaOf is idea as grounded by gr.
|
||||
func quickIdeaOf(idea skills.QuickIdea, gr recipes.Grounded) QuickIdea {
|
||||
return QuickIdea{
|
||||
Name: idea.Name,
|
||||
Method: gr.Method,
|
||||
MinutesTotal: gr.MinutesTotal,
|
||||
Difficulty: gr.Difficulty,
|
||||
RecipeID: gr.RecipeID,
|
||||
PhotoURL: dinners.PhotoURL(gr.PhotoID),
|
||||
SourceURL: gr.SourceURL,
|
||||
SiteName: gr.SiteName,
|
||||
}
|
||||
}
|
||||
|
||||
// Origins of a dinner made from an idea (the dinners.origin column).
|
||||
const (
|
||||
// OriginIngredients is an idea from Cook from what I have.
|
||||
OriginIngredients = "ingredients"
|
||||
// OriginQuick is an idea from Quick dinners.
|
||||
OriginQuick = "quick"
|
||||
)
|
||||
|
||||
// ErrIdeaNotFound is an idea the household cannot act on: an unknown job,
|
||||
// a job that does not find ideas, a failed job (which shows no ideas), or
|
||||
// an index past the job's ideas.
|
||||
var ErrIdeaNotFound = errors.New("idea not found")
|
||||
|
||||
// ErrIdeasNotReady is an idea of a job still queued or running. Its ideas
|
||||
// may still move: an idea that grounds later is put back in its place
|
||||
// before those shown already, so an index names an idea only once the job
|
||||
// has succeeded.
|
||||
var ErrIdeasNotReady = errors.New("the ideas are still being found; act on one when the job has succeeded")
|
||||
|
||||
// Chosen is the idea the household acts on: its name, the recipe it was
|
||||
// grounded in, and the origin of a dinner made from it.
|
||||
type Chosen struct {
|
||||
Name string
|
||||
RecipeID string
|
||||
Origin string
|
||||
}
|
||||
|
||||
// Idea returns idea i, counted from 0, of the succeeded job jobID, as its
|
||||
// stored result shows it: a dinner made from it uses the recipe it was
|
||||
// grounded in, never grounding it again. A job still queued or running is
|
||||
// ErrIdeasNotReady; any idea not shown by a succeeded KindFromIngredients
|
||||
// or KindQuick job is ErrIdeaNotFound.
|
||||
func (s *Service) Idea(ctx context.Context, jobID string, i int) (Chosen, error) {
|
||||
job, err := s.runner.Get(ctx, jobID)
|
||||
if errors.Is(err, jobs.ErrNotFound) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: read job: %w", err)
|
||||
}
|
||||
if job.Kind != KindFromIngredients && job.Kind != KindQuick {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
switch job.Status {
|
||||
case jobs.StatusQueued, jobs.StatusRunning:
|
||||
return Chosen{}, ErrIdeasNotReady
|
||||
case jobs.StatusSucceeded:
|
||||
default:
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
if job.Kind == KindQuick {
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginQuick}, nil
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
return Chosen{}, fmt.Errorf("ideas: decode %s result: %w", job.Kind, err)
|
||||
}
|
||||
if i < 0 || i >= len(res.Ideas) {
|
||||
return Chosen{}, ErrIdeaNotFound
|
||||
}
|
||||
return Chosen{Name: res.Ideas[i].Name, RecipeID: res.Ideas[i].RecipeID, Origin: OriginIngredients}, nil
|
||||
}
|
||||
@@ -1,575 +0,0 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/migrations"
|
||||
)
|
||||
|
||||
// pantry is the household's ingredient list.
|
||||
var pantry = []string{"Chicken thighs", "rice", "black beans", "red onion", "cilantro"}
|
||||
|
||||
type env struct {
|
||||
svc *Service
|
||||
runner *jobs.Runner
|
||||
search *recipestest.Search
|
||||
pages *recipestest.Pages
|
||||
images *recipestest.Images
|
||||
// model answers ideas_from_ingredients and quick_ideas; normalize
|
||||
// answers normalize_recipe while grounding.
|
||||
model *llm.Fake
|
||||
normalize *llm.Fake
|
||||
// fail, when set, fails ideas_from_ingredients and quick_ideas without
|
||||
// a model call.
|
||||
fail error
|
||||
st *store.Store
|
||||
}
|
||||
|
||||
// newEnv returns an ideas service over a migrated store with the
|
||||
// household's settings seeded and an unstarted
|
||||
// job runner. It finds ideas through the real harness and
|
||||
// ideas_from_ingredients or quick_ideas with the scripted e.model, and
|
||||
// grounds with fake
|
||||
// search, pages and images and the scripted e.normalize.
|
||||
func newEnv(t *testing.T) *env {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
dataDir := t.TempDir()
|
||||
st, err := store.Open(ctx, dataDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := st.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
if err := store.Migrate(ctx, st.Writer, migrations.FS); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := household.SeedSettings(ctx, st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e := &env{
|
||||
search: &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
pages: &recipestest.Pages{Bodies: map[string][]byte{}},
|
||||
images: &recipestest.Images{JPEGs: map[string][]byte{}},
|
||||
model: &llm.Fake{},
|
||||
normalize: &llm.Fake{},
|
||||
st: st,
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, dataDir, e.search, e.pages, e.images, e.normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return e.model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
if e.fail != nil {
|
||||
return skills.Ideas{}, e.fail
|
||||
}
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
if e.fail != nil {
|
||||
return skills.QuickIdeaList{}, e.fail
|
||||
}
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = NewService(st, g, generate, quick, e.runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// recipe makes the search for name find one page that grounds it, titled
|
||||
// title, with a total time of minutes, and scripts the normalize_recipe
|
||||
// reply grounding it takes. The page's total time is the grounded
|
||||
// minutes, and "Slow Cooker" in title makes the grounded method crockpot,
|
||||
// whatever the reply says.
|
||||
func (e *env) recipe(t *testing.T, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
e.search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
e.pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
e.images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(e.images.JPEGs)+1))
|
||||
e.normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// idea renders one idea of an ideas_from_ingredients reply whose search
|
||||
// query is its name.
|
||||
func idea(name, method string, minutes int, uses ...string) string {
|
||||
u, _ := json.Marshal(append([]string{}, uses...))
|
||||
return fmt.Sprintf(`{"name":%q,"uses":%s,"missing":["1 lime"],"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, u, minutes, method, name)
|
||||
}
|
||||
|
||||
// reply scripts one ideas_from_ingredients reply holding ideas.
|
||||
func (e *env) reply(ideas ...string) {
|
||||
js := `{"ideas":[` + strings.Join(ideas, ",") + `]}`
|
||||
e.model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
// recorder collects every result a job publishes.
|
||||
type recorder struct {
|
||||
mu sync.Mutex
|
||||
published []Result
|
||||
}
|
||||
|
||||
// latest returns the last result published.
|
||||
func (r *recorder) latest() (Result, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return Result{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *recorder) publish(res Result) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
// The result's slices are copied, as the runner stores each publish
|
||||
// as JSON at once.
|
||||
res.Ideas = append([]Idea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// run runs the job's handler directly on ingredients, recording each
|
||||
// publish.
|
||||
func (e *env) run(t *testing.T, ingredients []string, quickOnly bool) (Result, []Result, error) {
|
||||
t.Helper()
|
||||
ings, err := skills.CleanIngredients(ingredients)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings, QuickOnly: quickOnly}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
// within reports whether every name in sub appears in list, in list's
|
||||
// order.
|
||||
func within(sub, list []string) bool {
|
||||
for _, name := range list {
|
||||
if len(sub) > 0 && sub[0] == name {
|
||||
sub = sub[1:]
|
||||
}
|
||||
}
|
||||
return len(sub) == 0
|
||||
}
|
||||
|
||||
func names(list []Idea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf", "Bean Salad"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// Eight ideas: two no recipe grounds, so the sixth and seventh are
|
||||
// grounded in a second round, and the eighth never is.
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice", "Chicken thighs"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans", "red onion"),
|
||||
idea("Unknown Casserole", "oven", 50, "rice"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs", "cilantro"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
idea("Bean Salad", "other", 15, "black beans"),
|
||||
)
|
||||
res, published, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos", "Rice Pilaf"}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q", got, want)
|
||||
}
|
||||
if res.Stage != StageDone || res.Dropped != 2 {
|
||||
t.Fatalf("result stage %s dropped %d, want done with 2 dropped", res.Stage, res.Dropped)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Ingredients, pantry) || res.QuickOnly {
|
||||
t.Fatalf("result request = %q quick %v", res.Ingredients, res.QuickOnly)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != 7 || strings.Contains(strings.Join(got, ","), "Bean Salad") {
|
||||
t.Errorf("searched %q, want the first seven ideas only", got)
|
||||
}
|
||||
|
||||
// Each publish: the stage, then one more idea at a time, each idea
|
||||
// grounded when it appears.
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
for _, i := range p.Ideas {
|
||||
if i.RecipeID == "" || i.PhotoURL == "" || i.Method == "" {
|
||||
t.Errorf("published idea %q is not grounded: %+v", i.Name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
wantStages := []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding, StageGrounding}
|
||||
if !reflect.DeepEqual(stages, wantStages) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3, 4, 5}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want %q with 0, 0, 1, 2, 3, 4, 5", stages, counts, wantStages)
|
||||
}
|
||||
// Ideas ground concurrently, so one may appear before an earlier one,
|
||||
// but each publish keeps every idea already shown, in the model's
|
||||
// order.
|
||||
for i := 3; i < len(published); i++ {
|
||||
prev, cur := names(published[i-1].Ideas), names(published[i].Ideas)
|
||||
if !within(prev, cur) || !within(cur, want) {
|
||||
t.Errorf("publish %d shows %q after %q, want earlier ideas kept in the model's order %q", i+1, cur, prev, want)
|
||||
}
|
||||
}
|
||||
|
||||
first := res.Ideas[0]
|
||||
if first.Summary != "Uses 2 of 5 · missing: 1 lime" || !reflect.DeepEqual(first.Uses, []string{"rice", "black beans"}) {
|
||||
t.Errorf("first idea = %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyChecksTheGroundedRecipe(t *testing.T) {
|
||||
for _, quickOnly := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("quick_only=%v", quickOnly), func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea quick; the grounded recipes
|
||||
// decide.
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 25)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Bean Enchiladas", "Bean Enchiladas", 55)
|
||||
e.recipe(t, "Rice Bowls", "Rice Bowls", 30)
|
||||
e.recipe(t, "Bean Bake", "Bean Bake", 31)
|
||||
e.reply(
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Chicken Chili", "stovetop", 25, "Chicken thighs", "black beans"),
|
||||
idea("Bean Enchiladas", "oven", 25, "black beans"),
|
||||
idea("Rice Bowls", "stovetop", 15, "rice"),
|
||||
idea("Bean Bake", "oven", 30, "black beans"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, quickOnly)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
quick := map[string]bool{}
|
||||
for _, i := range res.Ideas {
|
||||
quick[i.Name] = i.Quick
|
||||
}
|
||||
want := map[string]bool{"Fried Rice": true, "Chicken Chili": true, "Rice Bowls": true}
|
||||
wantDropped := 2
|
||||
if !quickOnly {
|
||||
want["Bean Enchiladas"], want["Bean Bake"] = false, false
|
||||
wantDropped = 0
|
||||
}
|
||||
if !reflect.DeepEqual(quick, want) || res.Dropped != wantDropped {
|
||||
t.Fatalf("ideas quick = %v dropped %d, want %v dropped %d", quick, res.Dropped, want, wantDropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Name == "Chicken Chili" && (i.Method != "crockpot" || i.MinutesTotal == nil || *i.MinutesTotal != 240) {
|
||||
t.Errorf("Chicken Chili = %s %v, want the grounded crockpot 240", i.Method, i.MinutesTotal)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickOnlyGroundsMoreToReplaceSlowIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
slow := []string{"Roast One", "Roast Two", "Roast Three", "Roast Four", "Roast Five"}
|
||||
var reply []string
|
||||
for _, name := range slow {
|
||||
e.recipe(t, name, name, 90)
|
||||
reply = append(reply, idea(name, "oven", 20, "Chicken thighs"))
|
||||
}
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
reply = append(reply, idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
e.reply(reply...)
|
||||
res, _, err := e.run(t, pantry, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := names(res.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice"}) || res.Dropped != 5 {
|
||||
t.Fatalf("ideas = %q dropped %d, want Fried Rice with 5 dropped", got, res.Dropped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHallucinatedUsesNeverReachTheResult(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Burrito Bowls", "Burrito Bowls", 35)
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "sour cream", "black beans", "cheddar"),
|
||||
idea("Parmesan Pasta", "stovetop", 20, "pasta", "parmesan"),
|
||||
)
|
||||
res, _, err := e.run(t, pantry, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != 1 || !reflect.DeepEqual(res.Ideas[0].Uses, []string{"rice", "black beans"}) {
|
||||
t.Fatalf("ideas = %+v, want Burrito Bowls using rice and black beans", res.Ideas)
|
||||
}
|
||||
if res.Ideas[0].Summary != "Uses 2 of 5 · missing: 1 lime" || res.Dropped != 1 {
|
||||
t.Fatalf("summary %q dropped %d", res.Ideas[0].Summary, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Burrito Bowls"}) {
|
||||
t.Errorf("searched %q, want only the idea kept", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"bad output twice", func(e *env) { e.reply(); e.reply() }, ReasonAIFailed},
|
||||
{"search fails", func(e *env) {
|
||||
e.search.Err = errors.New("boom")
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(e)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 {
|
||||
t.Fatalf("a failed job published ideas: %+v", p.Ideas)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEachIdeaShowsAsSoonAsItGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"} {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Burrito Bowls": gate}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice", "black beans"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
)
|
||||
ings, err := skills.CleanIngredients(pantry)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec recorder
|
||||
type outcome struct {
|
||||
res Result
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.run(t.Context(), FromIngredientsJob{Ingredients: ings}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := names(latest.Ideas); !reflect.DeepEqual(got, []string{"Fried Rice", "Black Bean Soup"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup"}
|
||||
if got := names(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoIdeaKeptFailsTheJob(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
quickOnly bool
|
||||
setup func(t *testing.T, e *env)
|
||||
}{
|
||||
{"no recipe grounds any", false, func(t *testing.T, e *env) {
|
||||
e.reply(idea("Mystery Stew", "stovetop", 30, "rice"), idea("Unknown Casserole", "oven", 50, "rice"))
|
||||
}},
|
||||
{"no grounded recipe is quick", true, func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Roast Chicken", "Roast Chicken", 90)
|
||||
e.reply(idea("Roast Chicken", "oven", 25, "Chicken thighs"))
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.run(t, pantry, tc.quickOnly)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonNoIdeasFound {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonNoIdeasFound)
|
||||
}
|
||||
for _, p := range published {
|
||||
if len(p.Ideas) != 0 || p.Stage == StageDone {
|
||||
t.Fatalf("a job that kept no idea published %+v", p)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaterRoundFailureClearsPublishedIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
kept := []string{"Burrito Bowls", "Fried Rice", "Black Bean Soup", "Chicken Tacos"}
|
||||
for _, name := range kept {
|
||||
e.recipe(t, name, name, 35)
|
||||
}
|
||||
// The first round keeps four ideas; the second round's search fails.
|
||||
e.search.Fail = map[string]error{"Rice Pilaf": errors.New("boom")}
|
||||
e.reply(
|
||||
idea("Burrito Bowls", "stovetop", 35, "rice"),
|
||||
idea("Mystery Stew", "stovetop", 35, "rice"),
|
||||
idea("Fried Rice", "stovetop", 20, "rice"),
|
||||
idea("Black Bean Soup", "stovetop", 40, "black beans"),
|
||||
idea("Chicken Tacos", "stovetop", 25, "Chicken thighs"),
|
||||
idea("Rice Pilaf", "stovetop", 30, "rice"),
|
||||
)
|
||||
_, published, err := e.run(t, pantry, false)
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != ReasonSearchFailed {
|
||||
t.Fatalf("err = %v, want failure %s", err, ReasonSearchFailed)
|
||||
}
|
||||
most := 0
|
||||
for _, p := range published {
|
||||
most = max(most, len(p.Ideas))
|
||||
}
|
||||
if most != len(kept) {
|
||||
t.Fatalf("at most %d ideas published, want the first round's %d before the failure", most, len(kept))
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 {
|
||||
t.Fatalf("last publish before the failure shows %q, want no ideas", names(last.Ideas))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartFromIngredientsRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Fried Rice", "Fried Rice", 20)
|
||||
e.reply(idea("Fried Rice", "stovetop", 20, "rice"))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
if _, err := e.svc.StartFromIngredients(t.Context(), []string{"rice", "Rice"}, false); err == nil {
|
||||
t.Fatal("a repeated ingredient was accepted")
|
||||
} else if invalid := (*InvalidError)(nil); !errors.As(err, &invalid) {
|
||||
t.Fatalf("err = %v, want an *InvalidError", err)
|
||||
}
|
||||
|
||||
started, err := e.svc.StartFromIngredients(t.Context(), []string{" rice ", "Chicken thighs"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindFromIngredients {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res Result
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || !reflect.DeepEqual(res.Ingredients, []string{"rice", "Chicken thighs"}) || !res.QuickOnly || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Summary != "Uses 1 of 2 · missing: 1 lime" || !got.Quick {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuick(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{"crockpot", m(480), true},
|
||||
{"crockpot", nil, true},
|
||||
{"stovetop", m(30), true},
|
||||
{"oven", m(31), false},
|
||||
{"stovetop", nil, false},
|
||||
} {
|
||||
if got := IsQuick(tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("IsQuick(%s, %v) = %v, want %v", tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,400 +0,0 @@
|
||||
package ideas
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
)
|
||||
|
||||
// quickIdea renders one idea of a quick_ideas reply whose search query is
|
||||
// its name.
|
||||
func quickIdea(name, method string, minutes int) string {
|
||||
return fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"easy","search_query":%q}`,
|
||||
name, minutes, method, name)
|
||||
}
|
||||
|
||||
// quickRecorder collects every result a quick job publishes.
|
||||
type quickRecorder struct {
|
||||
mu sync.Mutex
|
||||
published []QuickResult
|
||||
}
|
||||
|
||||
func (r *quickRecorder) latest() (QuickResult, bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.published) == 0 {
|
||||
return QuickResult{}, false
|
||||
}
|
||||
return r.published[len(r.published)-1], true
|
||||
}
|
||||
|
||||
func (r *quickRecorder) publish(res QuickResult) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
res.Ideas = append([]QuickIdea(nil), res.Ideas...)
|
||||
r.published = append(r.published, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runQuick runs the quick job's handler directly, recording each publish.
|
||||
func (e *env) runQuick(t *testing.T, kind string, exclude ...string) (QuickResult, []QuickResult, error) {
|
||||
t.Helper()
|
||||
names, err := skills.CleanExclude(exclude)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rec quickRecorder
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: kind, Exclude: names}, rec.publish)
|
||||
return res, rec.published, err
|
||||
}
|
||||
|
||||
func quickNames(list []QuickIdea) []string {
|
||||
out := []string{}
|
||||
for _, i := range list {
|
||||
out = append(out, i.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestUnder30DropsIdeasOverThirtyMinutes(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model estimates every idea under 30 minutes; the grounded
|
||||
// recipes decide.
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.recipe(t, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
e.recipe(t, "Shrimp Scampi", "Shrimp Scampi", 30)
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Beef Stroganoff", "stovetop", 25),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Chicken Chili", "crockpot", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Chicken Fried Rice", "Shrimp Scampi"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the 45-minute and the crock pot ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 || res.Stage != StageDone || res.Kind != skills.QuickUnder30 {
|
||||
t.Fatalf("result = %+v, want done with 2 dropped", res)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.MinutesTotal == nil || *i.MinutesTotal > QuickMinutes || i.RecipeID == "" || i.PhotoURL == "" {
|
||||
t.Errorf("idea %+v is not a grounded dinner of 30 minutes or less", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrockpotDropsStovetopIdeas(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// The model calls every idea a crock pot dinner; only the recipes
|
||||
// cooked in a slow cooker are.
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.recipe(t, "Chicken Tortilla Soup", "Crock Pot Chicken Tortilla Soup", 375)
|
||||
e.recipe(t, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
e.reply(
|
||||
quickIdea("Pulled Pork", "crockpot", 480),
|
||||
quickIdea("Beef Stew", "crockpot", 360),
|
||||
quickIdea("Chicken Tortilla Soup", "crockpot", 375),
|
||||
quickIdea("Chicken Fried Rice", "crockpot", 240),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickCrockpot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := quickNames(res.Ideas), []string{"Pulled Pork", "Chicken Tortilla Soup"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ideas = %q, want %q: the stovetop ideas dropped", got, want)
|
||||
}
|
||||
if res.Dropped != 2 {
|
||||
t.Fatalf("dropped %d, want 2", res.Dropped)
|
||||
}
|
||||
for _, i := range res.Ideas {
|
||||
if i.Method != "crockpot" {
|
||||
t.Errorf("idea %q method %s, want the grounded crockpot", i.Name, i.Method)
|
||||
}
|
||||
}
|
||||
if m := res.Ideas[0].MinutesTotal; m == nil || *m != 480 {
|
||||
t.Errorf("Pulled Pork minutes_total = %v, want the recipe's 480", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExcludedNamesAreNeverReturned(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
// Every idea has a recipe that would ground it as a Quick dinner.
|
||||
for _, name := range []string{"Easy Chicken Fried Rice", "shrimp-tacos", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 20)
|
||||
}
|
||||
e.reply(
|
||||
quickIdea("Easy Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("shrimp-tacos", "stovetop", 15),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30, "Chicken Fried Rice", "Shrimp Tacos")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := quickNames(res.Ideas); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) || res.Dropped != 2 {
|
||||
t.Fatalf("ideas = %q dropped %d, want only Garlic Shrimp Pasta with 2 dropped", got, res.Dropped)
|
||||
}
|
||||
if got := e.search.Queries(); !reflect.DeepEqual(got, []string{"Garlic Shrimp Pasta"}) {
|
||||
t.Errorf("searched %q, want the excluded names never grounded", got)
|
||||
}
|
||||
if !reflect.DeepEqual(res.Exclude, []string{"Chicken Fried Rice", "Shrimp Tacos"}) {
|
||||
t.Errorf("result exclude = %q", res.Exclude)
|
||||
}
|
||||
// The model was told what was already shown.
|
||||
if system := e.model.Requests()[0].System; !strings.Contains(system, "### Already shown\n- Chicken Fried Rice\n- Shrimp Tacos") {
|
||||
t.Errorf("prompt does not list the names already shown:\n%s", system)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickPageStopsAtSixAndShowsFewerHonestly(t *testing.T) {
|
||||
t.Run("twelve ideas, six shown", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
e.recipe(t, name, name, 20)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Ideas) != skills.QuickIdeasKept || res.Dropped != 0 {
|
||||
t.Fatalf("ideas = %q dropped %d, want %d", quickNames(res.Ideas), res.Dropped, skills.QuickIdeasKept)
|
||||
}
|
||||
if got := e.search.Queries(); len(got) != skills.QuickIdeasKept {
|
||||
t.Errorf("searched %d ideas, want only the %d a page holds", len(got), skills.QuickIdeasKept)
|
||||
}
|
||||
})
|
||||
t.Run("over-generated ideas replace dropped ones", func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
var reply []string
|
||||
// The first six ground too slow, so the next six are grounded to
|
||||
// fill the page; only three of those are quick.
|
||||
for i := range skills.QuickIdeasRequested {
|
||||
name := fmt.Sprintf("Skillet Dish %c", 'A'+i)
|
||||
minutes := 20
|
||||
if i < 6 || i%2 == 1 {
|
||||
minutes = 50
|
||||
}
|
||||
e.recipe(t, name, name, minutes)
|
||||
reply = append(reply, quickIdea(name, "stovetop", 20))
|
||||
}
|
||||
e.reply(reply...)
|
||||
res, _, err := e.runQuick(t, skills.QuickUnder30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Fewer than six survive: the page shows three, never padded.
|
||||
if got, want := quickNames(res.Ideas), []string{"Skillet Dish G", "Skillet Dish I", "Skillet Dish K"}; !reflect.DeepEqual(got, want) || res.Dropped != 9 || res.Stage != StageDone {
|
||||
t.Fatalf("result %s ideas %q dropped %d, want done with %q and 9 dropped", res.Stage, got, res.Dropped, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestQuickResultFillsAsEachIdeaGrounds(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
for _, name := range []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"} {
|
||||
e.recipe(t, name, name, 25)
|
||||
}
|
||||
// The first idea's search waits, so the two after it ground first.
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
e.reply(
|
||||
quickIdea("Chicken Fried Rice", "stovetop", 20),
|
||||
quickIdea("Shrimp Scampi", "stovetop", 20),
|
||||
quickIdea("Garlic Shrimp Pasta", "stovetop", 25),
|
||||
)
|
||||
var rec quickRecorder
|
||||
type outcome struct {
|
||||
res QuickResult
|
||||
err error
|
||||
}
|
||||
done := make(chan outcome, 1)
|
||||
go func() {
|
||||
res, err := e.svc.runQuick(t.Context(), QuickJob{Kind: skills.QuickUnder30, Exclude: []string{}}, rec.publish)
|
||||
done <- outcome{res, err}
|
||||
}()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
if latest, ok := rec.latest(); ok && len(latest.Ideas) == 2 {
|
||||
if got := quickNames(latest.Ideas); !reflect.DeepEqual(got, []string{"Shrimp Scampi", "Garlic Shrimp Pasta"}) {
|
||||
t.Fatalf("while the first idea grounds, ideas = %q, want the two grounded", got)
|
||||
}
|
||||
if latest.Stage != StageGrounding {
|
||||
t.Fatalf("stage = %s, want grounding", latest.Stage)
|
||||
}
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("the ideas that grounded were not shown while the first idea was still grounding")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
out := <-done
|
||||
if out.err != nil {
|
||||
t.Fatal(out.err)
|
||||
}
|
||||
want := []string{"Chicken Fried Rice", "Shrimp Scampi", "Garlic Shrimp Pasta"}
|
||||
if got := quickNames(out.res.Ideas); !reflect.DeepEqual(got, want) || out.res.Stage != StageDone {
|
||||
t.Fatalf("result = %s %q, want done with %q in the model's order", out.res.Stage, got, want)
|
||||
}
|
||||
|
||||
var stages []string
|
||||
var counts []int
|
||||
for _, p := range rec.published {
|
||||
stages = append(stages, p.Stage)
|
||||
counts = append(counts, len(p.Ideas))
|
||||
}
|
||||
if !reflect.DeepEqual(stages, []string{StageSuggesting, StageGrounding, StageGrounding, StageGrounding, StageGrounding}) || !reflect.DeepEqual(counts, []int{0, 0, 1, 2, 3}) {
|
||||
t.Fatalf("published stages %q with %v ideas, want suggesting, then grounding with 0, 1, 2, 3", stages, counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuickFailures(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
setup func(t *testing.T, e *env)
|
||||
code string
|
||||
}{
|
||||
{"ai fails", func(t *testing.T, e *env) { e.fail = errors.New("provider down") }, ReasonAIFailed},
|
||||
{"no idea is of the kind", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.reply(quickIdea("Beef Stew", "crockpot", 360))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"every idea already shown", func(t *testing.T, e *env) {
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
}, ReasonNoIdeasFound},
|
||||
{"a later round fails after ideas were shown", func(t *testing.T, e *env) {
|
||||
e.recipe(t, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
e.recipe(t, "Beef Stew", "Beef Stew", 150)
|
||||
e.search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
reply := []string{quickIdea("Chicken Chili", "crockpot", 240)}
|
||||
for i := range 5 {
|
||||
name := fmt.Sprintf("Mystery Stew %d", i+1)
|
||||
reply = append(reply, quickIdea(name, "crockpot", 300))
|
||||
}
|
||||
e.reply(append(reply, quickIdea("Lentil Soup", "crockpot", 300))...)
|
||||
}, ReasonSearchFailed},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
tc.setup(t, e)
|
||||
_, published, err := e.runQuick(t, skills.QuickCrockpot, "Pulled Pork")
|
||||
var failure *jobs.Failure
|
||||
if !errors.As(err, &failure) || failure.Code != tc.code {
|
||||
t.Fatalf("err = %v, want failure %s", err, tc.code)
|
||||
}
|
||||
if last := published[len(published)-1]; len(last.Ideas) != 0 || last.Stage == StageDone {
|
||||
t.Fatalf("last publish of a failed job = %+v, want no ideas", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartQuickRunsTheJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.recipe(t, "Pulled Pork", "Slow Cooker Pulled Pork", 480)
|
||||
e.reply(quickIdea("Pulled Pork", "crockpot", 480))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := e.runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
e.runner.Wait()
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind string
|
||||
exclude []string
|
||||
want string
|
||||
}{
|
||||
{"", nil, `kind: kind "" is not one of crockpot, under30`},
|
||||
{"quick", nil, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{skills.QuickCrockpot, []string{"Beef Stew", "beef stew"}, `exclude: excluded name 2, "beef stew", repeats "Beef Stew"`},
|
||||
} {
|
||||
_, err := e.svc.StartQuick(t.Context(), tc.kind, tc.exclude)
|
||||
var invalid *InvalidError
|
||||
if !errors.As(err, &invalid) || invalid.Message != tc.want {
|
||||
t.Errorf("StartQuick(%q, %q) = %v, want %q", tc.kind, tc.exclude, err, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
started, err := e.svc.StartQuick(t.Context(), skills.QuickCrockpot, []string{" Beef Stew "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
var job jobs.Job
|
||||
for {
|
||||
job, err = e.runner.Get(t.Context(), started.JobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if job.Status != jobs.StatusSucceeded || job.Kind != KindQuick {
|
||||
t.Fatalf("job = %+v", job)
|
||||
}
|
||||
var res QuickResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Stage != StageDone || res.Kind != skills.QuickCrockpot || !reflect.DeepEqual(res.Exclude, []string{"Beef Stew"}) || len(res.Ideas) != 1 {
|
||||
t.Fatalf("result = %+v", res)
|
||||
}
|
||||
if got := res.Ideas[0]; got.Name != "Pulled Pork" || got.Method != "crockpot" || got.SiteName != "R Kitchen" {
|
||||
t.Errorf("idea = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfQuickKind(t *testing.T) {
|
||||
m := func(n int) *int { return &n }
|
||||
for _, tc := range []struct {
|
||||
kind, method string
|
||||
minutes *int
|
||||
want bool
|
||||
}{
|
||||
{skills.QuickCrockpot, "crockpot", m(480), true},
|
||||
{skills.QuickCrockpot, "crockpot", nil, true},
|
||||
{skills.QuickCrockpot, "stovetop", m(20), false},
|
||||
{skills.QuickUnder30, "stovetop", m(30), true},
|
||||
{skills.QuickUnder30, "oven", m(31), false},
|
||||
{skills.QuickUnder30, "stovetop", nil, false},
|
||||
{skills.QuickUnder30, "crockpot", m(240), false},
|
||||
{"quick", "crockpot", m(20), false},
|
||||
} {
|
||||
if got := OfQuickKind(tc.kind, tc.method, tc.minutes); got != tc.want {
|
||||
t.Errorf("OfQuickKind(%s, %s, %v) = %v, want %v", tc.kind, tc.method, tc.minutes, got, tc.want)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(skills.QuickKinds, []string{skills.QuickCrockpot, skills.QuickUnder30}) {
|
||||
t.Errorf("kinds = %q", skills.QuickKinds)
|
||||
}
|
||||
}
|
||||
+6
-45
@@ -135,14 +135,12 @@ type Job struct {
|
||||
type runFunc func(ctx context.Context, payload string, publish func(json.RawMessage) error) (result json.RawMessage, code, reason string)
|
||||
|
||||
// handler is a registered kind: run runs a job, check reports whether a
|
||||
// payload decodes into the kind's payload type, serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind, and failedResult, when set, is what Get
|
||||
// shows of a failed job's stored result (see Kind.FailedResult).
|
||||
// payload decodes into the kind's payload type, and serviceOnly is set when
|
||||
// Runner.Enqueue refuses the kind.
|
||||
type handler struct {
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
failedResult func(stored json.RawMessage) (json.RawMessage, error)
|
||||
run runFunc
|
||||
check func(payload []byte) bool
|
||||
serviceOnly bool
|
||||
}
|
||||
|
||||
// Runner owns the job handlers and the worker pool.
|
||||
@@ -309,27 +307,6 @@ func (k *Kind[P]) ServiceOnly() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FailedResult makes Get return, for a failed job of the kind with a
|
||||
// stored result, show's view of that result instead of the result itself.
|
||||
// It is applied on every read, so it holds however the job failed: by its
|
||||
// handler, as interrupted by restart, or after a write meant to tidy the
|
||||
// result failed. A show error fails Get. A kind that never calls it shows a
|
||||
// failed job's last result as stored. It must be called before Start.
|
||||
func (k *Kind[P]) FailedResult(show func(stored json.RawMessage) (json.RawMessage, error)) error {
|
||||
if show == nil {
|
||||
return fmt.Errorf("jobs: failed result %q: nil func", k.name)
|
||||
}
|
||||
k.r.mu.Lock()
|
||||
defer k.r.mu.Unlock()
|
||||
if k.r.started {
|
||||
return fmt.Errorf("jobs: failed result %q: runner already started", k.name)
|
||||
}
|
||||
h := k.r.handlers[k.name]
|
||||
h.failedResult = show
|
||||
k.r.handlers[k.name] = h
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wake tells an idle worker to look for a job, as Enqueue does itself:
|
||||
// call it after committing the transaction a job was enqueued in.
|
||||
func (k *Kind[P]) Wake() { k.r.wakeOne() }
|
||||
@@ -425,8 +402,7 @@ func (r *Runner) Wait() {
|
||||
r.wg.Wait()
|
||||
}
|
||||
|
||||
// Get returns the job with id, or ErrNotFound. A failed job's result is
|
||||
// shown through its kind's FailedResult, when the kind set one.
|
||||
// Get returns the job with id, or ErrNotFound.
|
||||
func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
row, err := db.New(r.st.Reader).GetJob(ctx, id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -442,21 +418,6 @@ func (r *Runner) Get(ctx context.Context, id string) (Job, error) {
|
||||
if row.Error.Valid {
|
||||
job.Error = &row.Error.String
|
||||
}
|
||||
if job.Status == StatusFailed && job.Result != nil {
|
||||
r.mu.Lock()
|
||||
show := r.handlers[job.Kind].failedResult
|
||||
r.mu.Unlock()
|
||||
if show != nil {
|
||||
shown, err := show(job.Result)
|
||||
if err != nil {
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: %w", job.Kind, err)
|
||||
}
|
||||
if !json.Valid(shown) {
|
||||
return Job{}, fmt.Errorf("jobs: show failed %s result: not one JSON value", job.Kind)
|
||||
}
|
||||
job.Result = shown
|
||||
}
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -772,47 +772,3 @@ func TestServiceOnlyKind(t *testing.T) {
|
||||
t.Error("ServiceOnly after Start succeeded, want an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedResultAppliesOnlyToItsKindsFailedJobs(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := migratedStore(t)
|
||||
r := newRunner(t, st, 1)
|
||||
h := func(_ context.Context, p countPayload, publish func(countResult) error) (countResult, error) {
|
||||
if err := publish(countResult{Done: 5}); err != nil {
|
||||
return countResult{}, err
|
||||
}
|
||||
if p.Steps == 0 {
|
||||
return countResult{}, &Failure{Code: "stopped"}
|
||||
}
|
||||
return countResult{Done: p.Steps}, nil
|
||||
}
|
||||
hidden := register(t, r, "hidden", h, "stopped")
|
||||
plain := register(t, r, "plain", h, "stopped")
|
||||
if err := hidden.FailedResult(func(json.RawMessage) (json.RawMessage, error) {
|
||||
return json.RawMessage(`{"done":0}`), nil
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := hidden.FailedResult(nil); err == nil {
|
||||
t.Error("FailedResult(nil) succeeded, want an error")
|
||||
}
|
||||
start(t, r)
|
||||
if err := hidden.FailedResult(func(s json.RawMessage) (json.RawMessage, error) { return s, nil }); err == nil {
|
||||
t.Error("FailedResult after Start succeeded, want an error")
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
kind *Kind[countPayload]
|
||||
steps int
|
||||
want string
|
||||
}{
|
||||
{hidden, 0, `{"done":0}`}, // failed: shown through FailedResult
|
||||
{hidden, 3, `{"done":3}`}, // succeeded: as stored
|
||||
{plain, 0, `{"done":5}`}, // failed, kind without FailedResult: as stored
|
||||
} {
|
||||
job := waitFor(t, r, enqueue(t, tc.kind, countPayload{Steps: tc.steps}), finished)
|
||||
if string(job.Result) != tc.want {
|
||||
t.Errorf("%s job with %d steps: %s result = %s, want %s", job.Kind, tc.steps, job.Status, job.Result, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,40 +423,6 @@ func (s *Service) Swap(ctx context.Context, weekStart store.LocalDate, date, din
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Tonight returns today, the household-local date now in the household's
|
||||
// zone, and the Monday starting its week, when that week has a plan: Swap
|
||||
// of that week and day sets tonight's dinner. A week with no plan is
|
||||
// ErrNotFound; no plan is made for it, since a plan holds all seven days.
|
||||
// The week is always today's own, so Swap never writes a different day.
|
||||
func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err error) {
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return store.LocalDate{}, store.LocalDate{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: read week %s: %w", week, err)
|
||||
}
|
||||
return week, today, nil
|
||||
}
|
||||
|
||||
// isNew is plan_days.is_new for a dinner with status: 1 for the Quest log.
|
||||
func isNew(status string) int64 {
|
||||
if status == dinners.StatusQuestLog {
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestOnlyKeepPromotesToRegular(t *testing.T) {
|
||||
|
||||
// Each non-test Go file naming the regular status, and how often.
|
||||
uses := map[string]int{
|
||||
"dinners/dinners.go": 6, // the constant (2), listable, CreateRegular's status and origin, AddToQuestLog reusing a Regular as it is
|
||||
"dinners/dinners.go": 5, // the constant (2), listable, CreateRegular's status and origin
|
||||
"plan/plan.go": 1, // plannable
|
||||
"plan/rotation.go": 1, // decisionStatus[DecisionKeep]
|
||||
"suggest/batch.go": 1, // reads which dinners are Regulars to cite
|
||||
|
||||
@@ -292,22 +292,7 @@ type GroundResult struct {
|
||||
// affect the others.
|
||||
func (g *Grounder) GroundMany(ctx context.Context, reqs []GroundRequest) []GroundResult {
|
||||
results := make([]GroundResult, len(reqs))
|
||||
g.GroundEach(ctx, reqs, func(i int, r GroundResult) { results[i] = r })
|
||||
return results
|
||||
}
|
||||
|
||||
// GroundEach grounds each request as GroundMany does, but calls done with
|
||||
// each item's index and result as soon as that item finishes, one call at
|
||||
// a time. It returns once every item has finished and every call has
|
||||
// returned.
|
||||
func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done func(i int, r GroundResult)) {
|
||||
slots := make(chan struct{}, GroundConcurrency)
|
||||
var mu sync.Mutex
|
||||
report := func(i int, r GroundResult) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
done(i, r)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i, req := range reqs {
|
||||
wg.Add(1)
|
||||
@@ -316,15 +301,16 @@ func (g *Grounder) GroundEach(ctx context.Context, reqs []GroundRequest, done fu
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
report(i, GroundResult{Err: ctx.Err()})
|
||||
results[i] = GroundResult{Err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
defer func() { <-slots }()
|
||||
gr, err := g.Ground(ctx, req.DishName, req.Query)
|
||||
<-slots
|
||||
report(i, GroundResult{Grounded: gr, Err: err})
|
||||
results[i] = GroundResult{Grounded: gr, Err: err}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// stored returns the grounding of the recipe stored for sourceURL, and
|
||||
|
||||
@@ -23,16 +23,12 @@ import (
|
||||
)
|
||||
|
||||
// Search is a recipes.SearchSource and recipes.SearchProvider answering
|
||||
// each query from Results. Err, when set, fails every search, and Fail
|
||||
// fails the searches for its queries. Gate, when set, makes every search
|
||||
// wait for it to close, and Gates makes the searches for its queries wait
|
||||
// for theirs.
|
||||
// each query from Results. Err, when set, fails every search. Gate, when
|
||||
// set, makes every search wait for it to close.
|
||||
type Search struct {
|
||||
Results map[string][]recipes.Candidate
|
||||
Err error
|
||||
Fail map[string]error
|
||||
Gate chan struct{}
|
||||
Gates map[string]chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
queries []string
|
||||
@@ -44,12 +40,9 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
s.mu.Lock()
|
||||
s.queries = append(s.queries, query)
|
||||
s.mu.Unlock()
|
||||
for _, gate := range []chan struct{}{s.Gate, s.Gates[query]} {
|
||||
if gate == nil {
|
||||
continue
|
||||
}
|
||||
if s.Gate != nil {
|
||||
select {
|
||||
case <-gate:
|
||||
case <-s.Gate:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
@@ -57,9 +50,6 @@ func (s *Search) Search(ctx context.Context, query string, n int) ([]recipes.Can
|
||||
if s.Err != nil {
|
||||
return nil, s.Err
|
||||
}
|
||||
if err := s.Fail[query]; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := s.Results[query]
|
||||
return c[:min(n, len(c))], nil
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ func newAIRouter(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t),
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func newDinnersEnv(t *testing.T) *dinnersEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
svc, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func newGoogleEnv(t *testing.T) *googleEnv {
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(e.logs, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
e.h, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestIdeas(t), newTestRunner(t), newTestPhotos(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), svc, newTestRunner(t), newTestPhotos(t),
|
||||
fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// actionsZone is the household zone of newIdeaActionsRouter.
|
||||
const actionsZone = "America/New_York"
|
||||
|
||||
// newIdeaActionsRouter returns a started router whose ideas, dinners and
|
||||
// plan services share one store, whose household settings are seeded, so
|
||||
// an idea's actions read the job, make the dinner and swap the plan of the
|
||||
// same household. The ideas service runs both skills through the real
|
||||
// harness with the scripted model and grounds with the scripted fakes.
|
||||
func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, actionsZone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
ideasSvc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dinnerSvc, err := dinners.NewService(st, g, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
dinnerSvc, newTestSuggest(t), planSvc, newTestShopping(t), newTestGoogle(t), ideasSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
return h, st
|
||||
}
|
||||
|
||||
// finishedIdeasJob runs an ideas from ingredients job that keeps names, in
|
||||
// order, and returns the router, store, job id and its ideas' recipe ids.
|
||||
func finishedIdeasJob(t *testing.T, names ...string) (http.Handler, *store.Store, string, []string) {
|
||||
t.Helper()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, names, names, nil)
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" {
|
||||
t.Fatalf("ideas job = %v", job)
|
||||
}
|
||||
var recipeIDs []string
|
||||
for _, i := range job["result"].(map[string]any)["ideas"].([]any) {
|
||||
recipeIDs = append(recipeIDs, i.(map[string]any)["recipe_id"].(string))
|
||||
}
|
||||
if len(recipeIDs) != len(names) {
|
||||
t.Fatalf("ideas job kept %d ideas, want %d", len(recipeIDs), len(names))
|
||||
}
|
||||
return h, st, id, recipeIDs
|
||||
}
|
||||
|
||||
func ideaPath(jobID, i, action string) string {
|
||||
return "/api/v1/ideas/" + jobID + "/items/" + i + "/" + action
|
||||
}
|
||||
|
||||
// dinnerCount is how many dinners the store holds, archived or not.
|
||||
func dinnerCount(t *testing.T, st *store.Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM dinners`).Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// thisWeek is today in the household's zone and the Monday of its week.
|
||||
func thisWeek(t *testing.T) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
zone, err := household.LoadZone(actionsZone)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
return household.WeekStart(today), today
|
||||
}
|
||||
|
||||
// planThisWeek stores a plan of this week with a Regular on every day and
|
||||
// the given status, and returns the week's Monday and today.
|
||||
func planThisWeek(t *testing.T, st *store.Store, status string) (week, today store.LocalDate) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
week, today = thisWeek(t)
|
||||
days, err := household.WeekDays(week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(ctx, db.CreateWeekPlanParams{ID: planID, WeekStart: week.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, date := range days {
|
||||
name := "Regular " + date.String()
|
||||
d, err := q.CreateDinner(ctx, db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(ctx, db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: date.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := st.Writer.ExecContext(ctx, `UPDATE week_plans SET status = ? WHERE id = ?`, status, planID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return week, today
|
||||
}
|
||||
|
||||
// planRevision is the revision of the plan of week.
|
||||
func planRevision(t *testing.T, st *store.Store, week store.LocalDate) int64 {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetWeekPlan(t.Context(), week.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return row.Revision
|
||||
}
|
||||
|
||||
// Adding an idea to the Quest log makes a quest_log dinner grounded in the
|
||||
// idea's own recipe, with origin ingredients, without grounding it again;
|
||||
// asking again answers the same dinner and makes no second one.
|
||||
func TestPostIdeaQuestLogAddsGroundedDinnerOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST quest-log = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
if d["name"] != "Rice Pudding" || d["status"] != "quest_log" || d["origin"] != "ingredients" ||
|
||||
d["grounding_status"] != "grounded" || d["recipe_id"] != recipeIDs[1] || d["photo_url"] == nil ||
|
||||
d["method"] != "stovetop" || d["minutes_total"] != 25.0 || d["updated_by"] != testSession.Subject {
|
||||
t.Fatalf("dinner = %v, want Rice Pudding in the Quest log grounded in %s", d, recipeIDs[1])
|
||||
}
|
||||
|
||||
rec, again := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || again["id"] != d["id"] || again["status"] != "quest_log" {
|
||||
t.Fatalf("POST quest-log again = %d %v, want 200 with dinner %v", rec.Code, again, d["id"])
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 1 {
|
||||
t.Fatalf("dinners = %d, want 1", n)
|
||||
}
|
||||
if names := listNames(t, h, "quest_log"); len(names) != 1 || names[0] != "Rice Pudding" {
|
||||
t.Fatalf("Quest log = %q", names)
|
||||
}
|
||||
}
|
||||
|
||||
// A Quick dinners idea makes a dinner with origin quick.
|
||||
func TestPostIdeaQuestLogQuickOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickReply(model, [3]any{"Chicken Fried Rice", "stovetop", 20})
|
||||
h, _ := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST quick = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id := body["jobId"].(string)
|
||||
if job := waitJob(t, h, id); job["status"] != "succeeded" {
|
||||
t.Fatalf("quick job = %v", job)
|
||||
}
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusCreated || d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "quick" {
|
||||
t.Fatalf("POST quest-log = %d %v, want a quick Quest log dinner", rec.Code, d)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea repeating a dinner the household already has is that dinner, as
|
||||
// it is: a Regular stays a Regular. One awaiting the household's choice
|
||||
// is refused and nothing changes.
|
||||
func TestPostIdeaQuestLogReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice", "Rice Pudding")
|
||||
regular := createDinner(t, h, `{"name": "chicken fried rice"}`)
|
||||
|
||||
rec, d := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "quest-log"), "")
|
||||
if rec.Code != http.StatusOK || d["id"] != regular || d["status"] != "regular" || d["origin"] != "regular" {
|
||||
t.Fatalf("POST quest-log = %d %v, want 200 with Regular %s as it is", rec.Code, d, regular)
|
||||
}
|
||||
|
||||
now := store.Now().String()
|
||||
if _, err := db.New(st.Writer).CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: "Rice pudding", NormalizedName: harness.Normalize("Rice pudding"), Status: "suggested",
|
||||
Origin: "side_quest", GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "1", "quest-log"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "duplicate_dinner" {
|
||||
t.Fatalf("POST quest-log of a suggested Side quest = %d %v, want 409 duplicate_dinner", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 2 {
|
||||
t.Fatalf("dinners = %d, want the Regular and the Side quest only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight puts the idea's dinner on today of this week's plan through
|
||||
// the plan's swap: the day is new, the plan's revision rises and a synced
|
||||
// plan becomes changed. Asking again plans the same dinner.
|
||||
func TestPostIdeaCookTonightSwapsToday(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, recipeIDs := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
week, today := planThisWeek(t, st, plan.StatusSynced)
|
||||
before := planRevision(t, st, week)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("POST cook-tonight = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
d := body["dinner"].(map[string]any)
|
||||
if d["name"] != "Chicken Fried Rice" || d["status"] != "quest_log" || d["origin"] != "ingredients" || d["recipe_id"] != recipeIDs[0] {
|
||||
t.Fatalf("dinner = %v", d)
|
||||
}
|
||||
assertTonight := func(body map[string]any) {
|
||||
t.Helper()
|
||||
p := body["plan"].(map[string]any)
|
||||
if p["week_start"] != week.String() || p["status"] != plan.StatusChanged {
|
||||
t.Fatalf("plan = %v, want week %s changed", p, week)
|
||||
}
|
||||
found := false
|
||||
for _, day := range p["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
planned := day["dinner"].(map[string]any)["id"] == d["id"]
|
||||
if day["date"] == today.String() {
|
||||
found = true
|
||||
if !planned || day["is_new"] != true {
|
||||
t.Fatalf("today %s = %v, want %v new", today, day, d["id"])
|
||||
}
|
||||
} else if planned {
|
||||
t.Fatalf("dinner also on %v", day["date"])
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("plan has no day %s", today)
|
||||
}
|
||||
}
|
||||
assertTonight(body)
|
||||
if after := planRevision(t, st, week); after != before+1 {
|
||||
t.Fatalf("plan revision = %d, want %d", after, before+1)
|
||||
}
|
||||
|
||||
rec, body = callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != d["id"] {
|
||||
t.Fatalf("POST cook-tonight again = %d %v", rec.Code, body)
|
||||
}
|
||||
assertTonight(body)
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the seven Regulars and one idea", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Cook tonight reuses a dinner the household already has.
|
||||
func TestPostIdeaCookTonightReusesExistingDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
_, today := planThisWeek(t, st, plan.StatusDraft)
|
||||
regular := createDinner(t, h, `{"name": "Chicken fried rice"}`)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusOK || body["dinner"].(map[string]any)["id"] != regular {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want Regular %s", rec.Code, body, regular)
|
||||
}
|
||||
for _, day := range body["plan"].(map[string]any)["days"].([]any) {
|
||||
day := day.(map[string]any)
|
||||
if day["date"] == today.String() && (day["dinner"].(map[string]any)["id"] != regular || day["is_new"] != false) {
|
||||
t.Fatalf("today = %v, want Regular %s", day, regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no plan for this week, cook tonight answers 409 no_plan and changes
|
||||
// nothing: no dinner is made and no plan is drafted or stored.
|
||||
func TestPostIdeaCookTonightWithoutPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", "cook-tonight"), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "no_plan" {
|
||||
t.Fatalf("POST cook-tonight = %d %v, want 409 no_plan", rec.Code, body)
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
var plans, drafts int
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM week_plans`).Scan(&plans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Reader.QueryRowContext(t.Context(), `SELECT COUNT(*) FROM jobs WHERE kind = ?`, plan.KindDraftWeek).Scan(&drafts); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plans != 0 || drafts != 0 {
|
||||
t.Fatalf("plans = %d, drafts = %d, want none", plans, drafts)
|
||||
}
|
||||
}
|
||||
|
||||
// An idea that is not an index of a succeeded ideas job is refused.
|
||||
func TestPostIdeaActionsRefuse(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, st, id, _ := finishedIdeasJob(t, "Chicken Fried Rice")
|
||||
planThisWeek(t, st, plan.StatusDraft)
|
||||
// A Regular's grounding job is a job, but finds no ideas.
|
||||
rec, body := callDinners(t, h, http.MethodPost, dinnersPath, `{"name": "Beef Stew"}`)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("POST /dinners = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
other := body["grounding_job_id"].(string)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
for _, tc := range []struct {
|
||||
job, i string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{id, "x", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "-1", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "01", http.StatusBadRequest, "invalid_request"},
|
||||
{id, "1", http.StatusNotFound, "not_found"},
|
||||
{"unknown", "0", http.StatusNotFound, "not_found"},
|
||||
{other, "0", http.StatusNotFound, "not_found"},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(tc.job, tc.i, action), "")
|
||||
if rec.Code != tc.status || errorCode(body) != tc.code {
|
||||
t.Errorf("POST %s %s/%s = %d %v, want %d %s", action, tc.job, tc.i, rec.Code, body, tc.status, tc.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := dinnerCount(t, st); n != 8 {
|
||||
t.Fatalf("dinners = %d, want the plan's seven Regulars and Beef Stew only", n)
|
||||
}
|
||||
}
|
||||
|
||||
// While the job still finds ideas, an idea is not acted on: its index may
|
||||
// still move.
|
||||
func TestPostIdeaActionsWhileJobRuns(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, []string{"Chicken Fried Rice"}, []string{"Chicken Fried Rice"}, nil)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Chicken Fried Rice": gate}
|
||||
h, st := newIdeaActionsRouter(t, model, normalize, search, pages, images)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
for _, action := range []string{"quest-log", "cook-tonight"} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideaPath(id, "0", action), "")
|
||||
if rec.Code != http.StatusConflict || errorCode(body) != "ideas_not_ready" {
|
||||
t.Errorf("POST %s = %d %v, want 409 ideas_not_ready", action, rec.Code, body)
|
||||
}
|
||||
}
|
||||
close(gate)
|
||||
waitJob(t, h, id)
|
||||
if n := dinnerCount(t, st); n != 0 {
|
||||
t.Fatalf("dinners = %d, want none", n)
|
||||
}
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
)
|
||||
|
||||
// maxIdeasBody bounds a POST /ideas/from-ingredients or /ideas/quick body.
|
||||
const maxIdeasBody = 16 << 10
|
||||
|
||||
// ideasFromIngredients is the POST /ideas/from-ingredients body. Both
|
||||
// fields are required.
|
||||
type ideasFromIngredients struct {
|
||||
Ingredients []string `json:"ingredients"`
|
||||
QuickOnly *bool `json:"quick_only"`
|
||||
}
|
||||
|
||||
// postIdeasFromIngredients starts finding ideas from the ingredients on
|
||||
// hand and answers 202 with the jobId that GET /jobs/{id} reports on,
|
||||
// without waiting for the job. An ingredient list the household must
|
||||
// correct is 400.
|
||||
func postIdeasFromIngredients(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasFromIngredients](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Ingredients == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "ingredients: required")
|
||||
return
|
||||
case body.QuickOnly == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "quick_only: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartFromIngredients(r.Context(), body.Ingredients, *body.QuickOnly)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start ideas from ingredients", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// ideasQuick is the POST /ideas/quick body. Both fields are required:
|
||||
// exclude is [] on a first page.
|
||||
type ideasQuick struct {
|
||||
Kind *string `json:"kind"`
|
||||
Exclude []string `json:"exclude"`
|
||||
}
|
||||
|
||||
// postIdeasQuick starts finding a page of Quick dinner ideas of a kind,
|
||||
// crockpot or under30, none named in exclude, and answers 202 with the
|
||||
// jobId that GET /jobs/{id} reports on, without waiting for the job. A
|
||||
// missing or unknown kind, or an exclude list the household must correct,
|
||||
// is 400.
|
||||
func postIdeasQuick(logger *slog.Logger, svc *ideas.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := decodePatch[ideasQuick](http.MaxBytesReader(w, r.Body, maxIdeasBody))
|
||||
if err != nil {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", err.Error())
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Kind == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "kind: required")
|
||||
return
|
||||
case body.Exclude == nil:
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "exclude: required")
|
||||
return
|
||||
}
|
||||
started, err := svc.StartQuick(r.Context(), *body.Kind, body.Exclude)
|
||||
var invalid *ideas.InvalidError
|
||||
switch {
|
||||
case errors.As(err, &invalid):
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", invalid.Message)
|
||||
return
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "start quick ideas", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusAccepted, started)
|
||||
}
|
||||
}
|
||||
|
||||
// chosenIdeaOrFail returns the idea {i} of ideas job {jobId}, or answers
|
||||
// 400 invalid_request when i is not an index (0, 1, ...), 404 not_found
|
||||
// for an idea no succeeded ideas job shows, or 409 ideas_not_ready while
|
||||
// the job is still finding ideas.
|
||||
func chosenIdeaOrFail(w http.ResponseWriter, r *http.Request, logger *slog.Logger, svc *ideas.Service) (ideas.Chosen, bool) {
|
||||
raw := chi.URLParam(r, "i")
|
||||
i, err := strconv.Atoi(raw)
|
||||
if err != nil || i < 0 || strconv.Itoa(i) != raw {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", "i: must be an idea's index, 0 or more")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
idea, err := svc.Idea(r.Context(), chi.URLParam(r, "jobId"), i)
|
||||
switch {
|
||||
case errors.Is(err, ideas.ErrIdeaNotFound):
|
||||
writeError(w, r, logger, http.StatusNotFound, "not_found", "idea not found")
|
||||
return ideas.Chosen{}, false
|
||||
case errors.Is(err, ideas.ErrIdeasNotReady):
|
||||
writeError(w, r, logger, http.StatusConflict, "ideas_not_ready", err.Error())
|
||||
return ideas.Chosen{}, false
|
||||
case err != nil:
|
||||
logger.ErrorContext(r.Context(), "read idea", "error", err)
|
||||
writeError(w, r, logger, http.StatusInternalServerError, "internal", "internal error")
|
||||
return ideas.Chosen{}, false
|
||||
}
|
||||
return idea, true
|
||||
}
|
||||
|
||||
// postIdeaQuestLog adds idea {i} of ideas job {jobId} to the Quest log,
|
||||
// recording the session's actor as updated_by, as a dinner grounded in
|
||||
// the recipe the idea was grounded in, with origin ingredients or quick.
|
||||
// It answers 201 with the dinner made, or 200 with the dinner the idea
|
||||
// repeats (see dinners.Service.AddToQuestLog), so asking again answers the
|
||||
// same dinner. A repeated dinner awaiting the household's choice is 409
|
||||
// duplicate_dinner.
|
||||
func postIdeaQuestLog(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
d, created, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to Quest log", err)
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, r, logger, status, d)
|
||||
}
|
||||
}
|
||||
|
||||
// cookedTonight is the POST /ideas/{jobId}/items/{i}/cook-tonight answer:
|
||||
// the idea's dinner and the week's plan with it tonight.
|
||||
type cookedTonight struct {
|
||||
Dinner dinners.Dinner `json:"dinner"`
|
||||
Plan plan.Plan `json:"plan"`
|
||||
}
|
||||
|
||||
// postIdeaCookTonight plans idea {i} of ideas job {jobId} tonight: today,
|
||||
// in the household's zone, of the plan of today's week. The idea's dinner
|
||||
// is added or found as postIdeaQuestLog does, then swapped onto today
|
||||
// through plan.Service.Swap, exactly as a swap on the Draft board, so the
|
||||
// plan is marked edited and a synced plan becomes changed. It answers 200
|
||||
// with the dinner and the plan as swapped; asking again plans the same
|
||||
// dinner again.
|
||||
//
|
||||
// When today's week has no plan it answers 409 no_plan and changes
|
||||
// nothing: no dinner is added and no plan is made, since a plan holds all
|
||||
// seven days; the household plans the week first. The week is always
|
||||
// today's own, so no other day is ever written. A dinner already on
|
||||
// another day of the week is 409 dinner_already_planned, after the dinner
|
||||
// is added or found.
|
||||
func postIdeaCookTonight(logger *slog.Logger, ideasSvc *ideas.Service, dinnerSvc *dinners.Service, planSvc *plan.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
actor, ok := actorOrFail(w, r, logger)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idea, ok := chosenIdeaOrFail(w, r, logger, ideasSvc)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
week, today, err := planSvc.Tonight(r.Context())
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "find tonight's plan", err)
|
||||
return
|
||||
}
|
||||
d, _, err := dinnerSvc.AddToQuestLog(r.Context(), dinners.NewQuestLog(idea), actor.Subject)
|
||||
if err != nil {
|
||||
writeDinnerError(w, r, logger, "add idea to cook tonight", err)
|
||||
return
|
||||
}
|
||||
p, err := planSvc.Swap(r.Context(), week, today.String(), d.ID)
|
||||
if errors.Is(err, plan.ErrNotFound) {
|
||||
// Plans are never deleted, so the plan Tonight found is still
|
||||
// there; this is said loudly rather than answered as success.
|
||||
logger.ErrorContext(r.Context(), "plan of tonight's week gone before the swap", "week", week.String())
|
||||
writeNoPlan(w, r, logger)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writePlanError(w, r, logger, "cook idea tonight", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, cookedTonight{Dinner: d, Plan: p})
|
||||
}
|
||||
}
|
||||
|
||||
// writeNoPlan answers 409 no_plan: today's week has no plan to put
|
||||
// tonight's dinner on, so the household plans the week first.
|
||||
func writeNoPlan(w http.ResponseWriter, r *http.Request, logger *slog.Logger) {
|
||||
writeError(w, r, logger, http.StatusConflict, "no_plan", "this week has no plan yet; plan this week first")
|
||||
}
|
||||
@@ -1,350 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
const ideasPath = "/api/v1/ideas/from-ingredients"
|
||||
|
||||
// newIdeasRouter returns a router whose ideas service runs
|
||||
// ideas_from_ingredients and quick_ideas through the real harness with the
|
||||
// scripted model and grounds with fake search, pages and images and the
|
||||
// scripted normalize, with its job runner and store, whose household
|
||||
// settings are seeded. The runner is started when start is set.
|
||||
func newIdeasRouter(t *testing.T, model, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, start bool) (http.Handler, *jobs.Runner, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
if err := household.SeedSettings(t.Context(), st, "America/New_York"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), search, pages, images, normalize)
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := harness.Config{
|
||||
Settings: llm.Settings{Provider: "anthropic", Model: "claude-test", Temperature: 0.7},
|
||||
NewProvider: func(llm.Settings, string) (llm.Provider, error) { return model, nil },
|
||||
Store: st,
|
||||
Guard: guard,
|
||||
}
|
||||
generate := func(ctx context.Context, scope string, in skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.IdeasFromIngredients(ctx, cfg, scope, in)
|
||||
}
|
||||
quick := func(ctx context.Context, scope string, in skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeas(ctx, cfg, scope, in)
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, generate, quick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), svc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if start {
|
||||
startRunner(t, runner)
|
||||
}
|
||||
return h, runner, st
|
||||
}
|
||||
|
||||
// startRunner starts runner until the test ends.
|
||||
func startRunner(t *testing.T, runner *jobs.Runner) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if err := runner.Start(ctx); err != nil {
|
||||
cancel()
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
cancel()
|
||||
runner.Wait()
|
||||
})
|
||||
}
|
||||
|
||||
// scriptIdeas scripts ideas_from_ingredients to propose names, in order;
|
||||
// a recipe grounds those in grounds, the others ground none, and search
|
||||
// fails as fail says. It returns the router's fakes.
|
||||
func scriptIdeas(t *testing.T, names, grounds []string, fail map[string]error) (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
t.Helper()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{}, Fail: fail}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
for i, name := range grounds {
|
||||
page, photo := fmt.Sprintf("https://r.example/%d", i), fmt.Sprintf("https://r.example/%d.jpg", i)
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(name, "R Kitchen", photo, 25)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(i+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
}
|
||||
list := make([]string, len(names))
|
||||
for i, name := range names {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"uses":["rice"],"missing":[],"est_minutes":30,"method":"stovetop","difficulty":"easy","search_query":%q}`, name, name)
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
return model, normalize, search, pages, images
|
||||
}
|
||||
|
||||
// startIdeasJob posts a request for ideas from rice and returns the job id.
|
||||
func startIdeasJob(t *testing.T, h http.Handler) string {
|
||||
t.Helper()
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["rice"], "quick_only": false}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
return body["jobId"].(string)
|
||||
}
|
||||
|
||||
// assertFailedWithoutIdeas checks that GET /jobs/{id} reports job failed
|
||||
// with code and a result showing no ideas.
|
||||
func assertFailedWithoutIdeas(t *testing.T, job map[string]any, code string) {
|
||||
t.Helper()
|
||||
if job["status"] != "failed" || job["error"] != code {
|
||||
t.Fatalf("GET job = %v, want failed with %s", job, code)
|
||||
}
|
||||
res, _ := job["result"].(map[string]any)
|
||||
if res == nil {
|
||||
t.Fatalf("failed job result = %v, want one showing no ideas", job["result"])
|
||||
}
|
||||
if list, ok := res["ideas"].([]any); !ok || len(list) != 0 {
|
||||
t.Fatalf("failed job ideas = %v, want none", res["ideas"])
|
||||
}
|
||||
}
|
||||
|
||||
// storedIdeas is how many ideas job id's stored result holds.
|
||||
func storedIdeas(t *testing.T, st *store.Store, id string) int {
|
||||
t.Helper()
|
||||
row, err := db.New(st.Reader).GetJob(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var res ideas.Result
|
||||
if err := json.Unmarshal([]byte(row.Result.String), &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return len(res.Ideas)
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"quick_only": false}`, "ingredients: required"},
|
||||
{`{"ingredients": ["rice"]}`, "quick_only: required"},
|
||||
{`{"ingredients": [], "quick_only": false}`, "ingredients: list at least one ingredient"},
|
||||
{`{"ingredients": ["rice", " "], "quick_only": false}`, "ingredients: ingredient 2 is blank"},
|
||||
{`{"ingredients": ["Rice", "rice"], "quick_only": false}`, `ingredients: ingredient 2, "rice", repeats "Rice"`},
|
||||
{`{"ingredients": ["rice"], "quick_only": "yes"}`, "quick_only: want a JSON bool"},
|
||||
{`{"ingredients": ["rice"], "quick_only": false, "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostIdeasFromIngredientsRunsTheJob(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize := &llm.Fake{}, &llm.Fake{}
|
||||
search := &recipestest.Search{Results: map[string][]recipes.Candidate{"chicken fried rice": {{URL: "https://r.example/fried-rice"}}}}
|
||||
pages := &recipestest.Pages{Bodies: map[string][]byte{
|
||||
"https://r.example/fried-rice": recipestest.RecipePage("Chicken Fried Rice", "R Kitchen", "https://r.example/fried-rice.jpg", 25),
|
||||
}}
|
||||
images := &recipestest.Images{JPEGs: map[string][]byte{"https://r.example/fried-rice.jpg": recipestest.JPEG(t, 400, 320, 1)}}
|
||||
js := `{"ideas":[{"name":"Chicken Fried Rice","uses":["rice","chicken thighs","peas"],"missing":["2 eggs"],"est_minutes":25,"method":"stovetop","difficulty":"easy","search_query":"chicken fried rice"}]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
|
||||
h, runner, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, ideasPath, `{"ingredients": ["Chicken thighs", "rice", "scallions"], "quick_only": true}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := runner.Get(t.Context(), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusSucceeded || job.Status == jobs.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("job still %s", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
rec, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if rec.Code != http.StatusOK || job["status"] != "succeeded" || job["kind"] != "ideas_from_ingredients" {
|
||||
t.Fatalf("GET job = %d %v", rec.Code, job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["quick_only"] != true || res["dropped"] != 0.0 {
|
||||
t.Fatalf("result = %v", res)
|
||||
}
|
||||
list := res["ideas"].([]any)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("ideas = %v, want 1", list)
|
||||
}
|
||||
got := list[0].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"name": "Chicken Fried Rice",
|
||||
"summary": "Uses 2 of 3 · missing: 2 eggs",
|
||||
"method": "stovetop",
|
||||
"minutes_total": 25.0,
|
||||
"quick": true,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/fried-rice",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if uses, _ := json.Marshal(got["uses"]); string(uses) != `["Chicken thighs","rice"]` {
|
||||
t.Errorf("idea uses = %s, want the household's two listed ingredients", uses)
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" {
|
||||
t.Errorf("idea = %v, want a recipe and photo", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedIdeasJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// grounds are the ideas a recipe grounds; the others ground none.
|
||||
grounds []string
|
||||
ideas []string
|
||||
fail map[string]error
|
||||
code string
|
||||
}{
|
||||
{"no idea kept", nil, []string{"Mystery Stew"}, nil, "no_ideas_found"},
|
||||
{"a later round fails after ideas were shown",
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")}, "search_failed"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t, tc.ideas, tc.grounds, tc.fail)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, startIdeasJob(t, h)), tc.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A job left running with ideas shown is failed at the next start as
|
||||
// interrupted, without its handler running again; it still shows no ideas.
|
||||
func TestIdeasJobInterruptedByRestartShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, runner, st := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
id := startIdeasJob(t, h)
|
||||
|
||||
// The previous process claimed the job and showed an idea, then stopped.
|
||||
q := db.New(st.Writer)
|
||||
now := store.Now().String()
|
||||
if _, err := q.ClaimNextJob(t.Context(), sql.NullString{String: now, Valid: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partial, err := ideas.Result{
|
||||
Stage: ideas.StageGrounding,
|
||||
Ingredients: []string{"rice"},
|
||||
Ideas: []ideas.Idea{{Name: "Fried Rice", Uses: []string{"rice"}, Missing: []string{}, Method: "stovetop", Difficulty: "easy"}},
|
||||
}.PublicJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, err := q.PublishJobResult(t.Context(), db.PublishJobResultParams{
|
||||
Result: sql.NullString{String: string(partial), Valid: true}, UpdatedAt: now, ID: id,
|
||||
}); err != nil || n != 1 {
|
||||
t.Fatalf("publish partial result = %d, %v", n, err)
|
||||
}
|
||||
|
||||
startRunner(t, runner)
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
assertFailedWithoutIdeas(t, job, jobs.CodeInterrupted)
|
||||
if res := job["result"].(map[string]any); res["stage"] != "grounding" {
|
||||
t.Errorf("result = %v, want the rest of the partial result kept", res)
|
||||
}
|
||||
if n := storedIdeas(t, st, id); n != 1 {
|
||||
t.Fatalf("stored ideas = %d, want the interrupted job's 1 still stored", n)
|
||||
}
|
||||
}
|
||||
|
||||
// When the write clearing a failed job's ideas itself fails, the job fails
|
||||
// with the runner's internal_error and its stored result keeps the ideas;
|
||||
// it still shows none.
|
||||
func TestIdeasJobWhoseClearingWriteFailsShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := scriptIdeas(t,
|
||||
[]string{"Fried Rice", "Mystery Stew", "Rice Bowls", "Rice Soup", "Rice Salad", "Rice Pilaf"},
|
||||
[]string{"Fried Rice", "Rice Bowls", "Rice Soup", "Rice Salad"},
|
||||
map[string]error{"Rice Pilaf": errors.New("boom")})
|
||||
h, runner, st := newIdeasRouter(t, model, normalize, search, pages, images, false)
|
||||
// Refuse any write that empties a running job's shown ideas.
|
||||
if _, err := st.Writer.ExecContext(t.Context(), `
|
||||
CREATE TRIGGER refuse_clearing_ideas BEFORE UPDATE OF result ON jobs
|
||||
WHEN NEW.status = 'running'
|
||||
AND json_array_length(OLD.result, '$.ideas') > 0
|
||||
AND json_array_length(NEW.result, '$.ideas') = 0
|
||||
BEGIN SELECT RAISE(ABORT, 'clearing refused'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startRunner(t, runner)
|
||||
|
||||
id := startIdeasJob(t, h)
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, id), jobs.CodeInternal)
|
||||
if n := storedIdeas(t, st, id); n != 4 {
|
||||
t.Fatalf("stored ideas = %d, want the 4 the refused write left", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesIdeasKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "ideas_from_ingredients", "payload": {"ingredients": [], "quick_only": false}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func newLoginRouter(t *testing.T, issuer string) http.Handler {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := NewRouter(logger, "dev", trusted, flow, sessions, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func newMediaRouter(t *testing.T) (r http.Handler, id, orphan string) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err = NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
|
||||
photos, fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -45,7 +45,7 @@ func newOriginRouter(t *testing.T, logs io.Writer) http.Handler {
|
||||
}
|
||||
logger := slog.New(forwarded.LogHandler(slog.NewJSONHandler(logs, nil)))
|
||||
r, err := NewRouter(logger, "dev", trusted, probeAuth{}, stubSessions{}, newTestSettings(t),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ type plansEnv struct {
|
||||
h http.Handler
|
||||
st *store.Store
|
||||
svc *plan.Service
|
||||
shop *shopping.Service
|
||||
runner *jobs.Runner
|
||||
questLog string
|
||||
// google is the fake Google the plan service pushes to; it starts
|
||||
@@ -94,7 +93,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), newTestSuggest(t), svc, shop, googleSvc, runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -128,7 +127,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return plansEnv{h: h, st: st, svc: svc, shop: shop, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
return plansEnv{h: h, st: st, svc: svc, runner: runner, questLog: dinner("Pad Thai", "quest_log", "stovetop", 35),
|
||||
google: google, gcal: googleSvc, logs: logs}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes/recipestest"
|
||||
)
|
||||
|
||||
const quickPath = "/api/v1/ideas/quick"
|
||||
|
||||
// quickRecipe makes the search for name find a page titled title with a
|
||||
// total time of minutes, and scripts the normalize_recipe reply grounding
|
||||
// it takes. "Slow Cooker" in title grounds the method as crockpot.
|
||||
func quickRecipe(t *testing.T, normalize *llm.Fake, search *recipestest.Search, pages *recipestest.Pages, images *recipestest.Images, name, title string, minutes int) {
|
||||
t.Helper()
|
||||
slug := strings.ReplaceAll(strings.ToLower(name), " ", "-")
|
||||
page, photo := "https://r.example/"+slug, "https://r.example/"+slug+".jpg"
|
||||
search.Results[name] = []recipes.Candidate{{URL: page}}
|
||||
pages.Bodies[page] = recipestest.RecipePage(title, "R Kitchen", photo, minutes)
|
||||
images.JPEGs[photo] = recipestest.JPEG(t, 400, 320, uint8(len(images.JPEGs)+1))
|
||||
normalize.QueueGenerate(recipestest.NormalizedReply("stovetop", minutes))
|
||||
}
|
||||
|
||||
// quickReply scripts one quick_ideas reply holding the ideas named, each
|
||||
// searched by its name.
|
||||
func quickReply(model *llm.Fake, ideas ...[3]any) {
|
||||
list := make([]string, len(ideas))
|
||||
for i, idea := range ideas {
|
||||
list[i] = fmt.Sprintf(`{"name":%q,"est_minutes":%d,"method":%q,"difficulty":"medium","search_query":%q}`, idea[0], idea[2], idea[1], idea[0])
|
||||
}
|
||||
js := `{"ideas":[` + strings.Join(list, ",") + `]}`
|
||||
model.QueueGenerate(llm.GenerateResult{Response: llm.Response{Text: js, JSON: json.RawMessage(js)}})
|
||||
}
|
||||
|
||||
func newQuickFakes() (*llm.Fake, *llm.Fake, *recipestest.Search, *recipestest.Pages, *recipestest.Images) {
|
||||
return &llm.Fake{}, &llm.Fake{}, &recipestest.Search{Results: map[string][]recipes.Candidate{}},
|
||||
&recipestest.Pages{Bodies: map[string][]byte{}}, &recipestest.Images{JPEGs: map[string][]byte{}}
|
||||
}
|
||||
|
||||
func TestPostIdeasQuickRefusesBadRequests(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
|
||||
for _, tc := range []struct{ body, message string }{
|
||||
{`{"exclude": []}`, "kind: required"},
|
||||
{`{"kind": "crockpot"}`, "exclude: required"},
|
||||
{`{"kind": "", "exclude": []}`, `kind: kind "" is not one of crockpot, under30`},
|
||||
{`{"kind": "quick", "exclude": []}`, `kind: kind "quick" is not one of crockpot, under30`},
|
||||
{`{"kind": "Crockpot", "exclude": []}`, `kind: kind "Crockpot" is not one of crockpot, under30`},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", " "]}`, "exclude: excluded name 2 is blank"},
|
||||
{`{"kind": "under30", "exclude": ["Beef Stew", "beef-stew"]}`, `exclude: excluded name 2, "beef-stew", repeats "Beef Stew"`},
|
||||
{`{"kind": 30, "exclude": []}`, "kind: want a JSON string"},
|
||||
{`{"kind": "under30", "exclude": [], "extra": 1}`, `invalid request body: json: unknown field "extra"`},
|
||||
} {
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, tc.body)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("POST %s = %d %v, want 400 invalid_request", tc.body, rec.Code, body)
|
||||
continue
|
||||
}
|
||||
if msg := body["error"].(map[string]any)["message"]; msg != tc.message {
|
||||
t.Errorf("POST %s message = %q, want %q", tc.body, msg, tc.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The job answers 202 at once and its result fills idea by idea: one
|
||||
// idea is shown while another still grounds. The kind is checked against
|
||||
// the grounded recipe and excluded names never come back.
|
||||
func TestPostIdeasQuickRunsTheJobWithProgressiveResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Fried Rice", "Chicken Fried Rice", 25)
|
||||
quickRecipe(t, normalize, search, pages, images, "Beef Stroganoff", "Beef Stroganoff", 45)
|
||||
quickRecipe(t, normalize, search, pages, images, "Garlic Shrimp Pasta", "Garlic Shrimp Pasta", 30)
|
||||
gate := make(chan struct{})
|
||||
search.Gates = map[string]chan struct{}{"Garlic Shrimp Pasta": gate}
|
||||
quickReply(model,
|
||||
[3]any{"Easy Shrimp Tacos", "stovetop", 15},
|
||||
[3]any{"Chicken Fried Rice", "stovetop", 20},
|
||||
[3]any{"Beef Stroganoff", "stovetop", 25},
|
||||
[3]any{"Garlic Shrimp Pasta", "stovetop", 20},
|
||||
)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "under30", "exclude": ["Shrimp Tacos"]}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
id, _ := body["jobId"].(string)
|
||||
if id == "" || len(body) != 1 {
|
||||
t.Fatalf("POST answered %v, want only a jobId", body)
|
||||
}
|
||||
|
||||
// While Garlic Shrimp Pasta still grounds, Chicken Fried Rice shows.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
_, job := callDinners(t, h, http.MethodGet, "/api/v1/jobs/"+id, "")
|
||||
if res, _ := job["result"].(map[string]any); res != nil {
|
||||
if list, _ := res["ideas"].([]any); len(list) == 1 {
|
||||
if job["status"] != "running" || res["stage"] != "grounding" || list[0].(map[string]any)["name"] != "Chicken Fried Rice" {
|
||||
t.Fatalf("partial job = %v", job)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("no idea was shown while another still grounded")
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
close(gate)
|
||||
|
||||
job := waitJob(t, h, id)
|
||||
if job["status"] != "succeeded" || job["kind"] != "quick_ideas" {
|
||||
t.Fatalf("GET job = %v", job)
|
||||
}
|
||||
res := job["result"].(map[string]any)
|
||||
if res["stage"] != "done" || res["kind"] != "under30" || res["dropped"] != 2.0 {
|
||||
t.Fatalf("result = %v, want done with the excluded and the 45-minute ideas dropped", res)
|
||||
}
|
||||
if ex, _ := json.Marshal(res["exclude"]); string(ex) != `["Shrimp Tacos"]` {
|
||||
t.Errorf("result exclude = %s", ex)
|
||||
}
|
||||
var names []string
|
||||
for _, i := range res["ideas"].([]any) {
|
||||
names = append(names, i.(map[string]any)["name"].(string))
|
||||
}
|
||||
if want := []string{"Chicken Fried Rice", "Garlic Shrimp Pasta"}; !reflect.DeepEqual(names, want) {
|
||||
t.Fatalf("ideas = %q, want %q", names, want)
|
||||
}
|
||||
got := res["ideas"].([]any)[1].(map[string]any)
|
||||
for key, want := range map[string]any{
|
||||
"method": "stovetop",
|
||||
"minutes_total": 30.0,
|
||||
"site_name": "R Kitchen",
|
||||
"source_url": "https://r.example/garlic-shrimp-pasta",
|
||||
} {
|
||||
if got[key] != want {
|
||||
t.Errorf("idea %s = %v, want %v", key, got[key], want)
|
||||
}
|
||||
}
|
||||
if got["recipe_id"] == "" || got["photo_url"] == "" || got["difficulty"] == nil {
|
||||
t.Errorf("idea = %v, want a recipe, photo and difficulty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailedQuickJobShowsNoIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
model, normalize, search, pages, images := newQuickFakes()
|
||||
quickRecipe(t, normalize, search, pages, images, "Chicken Chili", "Slow Cooker Chicken Chili", 240)
|
||||
search.Fail = map[string]error{"Lentil Soup": errors.New("boom")}
|
||||
ideas := [][3]any{{"Chicken Chili", "crockpot", 240}}
|
||||
for i := range 5 {
|
||||
ideas = append(ideas, [3]any{fmt.Sprintf("Mystery Stew %d", i+1), "crockpot", 300})
|
||||
}
|
||||
quickReply(model, append(ideas, [3]any{"Lentil Soup", "crockpot", 300})...)
|
||||
h, _, _ := newIdeasRouter(t, model, normalize, search, pages, images, true)
|
||||
rec, body := callDinners(t, h, http.MethodPost, quickPath, `{"kind": "crockpot", "exclude": []}`)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
|
||||
}
|
||||
assertFailedWithoutIdeas(t, waitJob(t, h, body["jobId"].(string)), "search_failed")
|
||||
}
|
||||
|
||||
func TestJobsEndpointRefusesQuickKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
h, _, _ := newIdeasRouter(t, &llm.Fake{}, &llm.Fake{}, &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, false)
|
||||
rec, body := callDinners(t, h, http.MethodPost, "/api/v1/jobs", `{"kind": "quick_ideas", "payload": {"kind": "crockpot", "exclude": []}}`)
|
||||
if rec.Code != http.StatusBadRequest || errorCode(body) != "service_only_kind" {
|
||||
t.Fatalf("POST /jobs = %d %v, want 400 service_only_kind", rec.Code, body)
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ const (
|
||||
func newRecipeSearchRouter(t *testing.T, search *recipes.SearchSettingsService) http.Handler {
|
||||
t.Helper()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t),
|
||||
r, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), search, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t),
|
||||
newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/frontend"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -50,21 +49,17 @@ type RouteMounter interface {
|
||||
// /api/v1/plans/{weekStart}/days/{date}, GET /api/v1/rotation-reviews and
|
||||
// POST /api/v1/dinners/{id}/rotation-review, and POST
|
||||
// /api/v1/plans/{weekStart}/push-calendar with shopSvc, which makes the
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/archive,
|
||||
// GET /api/v1/shopping/{weekStart},
|
||||
// pushed week's Supply run; shopSvc serves GET /api/v1/shopping/{weekStart},
|
||||
// POST /api/v1/shopping/{weekStart}/generate and PATCH
|
||||
// /api/v1/shopping/items/{id}; googleSvc serves GET
|
||||
// /api/v1/integrations/google and its /connect, /callback and /calendars,
|
||||
// PUT /api/v1/integrations/google/calendar and DELETE
|
||||
// /api/v1/integrations/google; ideasSvc serves POST
|
||||
// /api/v1/ideas/from-ingredients and POST /api/v1/ideas/quick, and with
|
||||
// dinnerSvc POST /api/v1/ideas/{jobId}/items/{i}/quest-log and, with
|
||||
// planSvc too, /cook-tonight; runner serves
|
||||
// /api/v1/integrations/google; runner serves
|
||||
// POST /api/v1/jobs and GET /api/v1/jobs/{id}; photos serves
|
||||
// GET /media/photos/{id}-{size}.jpg; assets is the web build
|
||||
// served for every other GET. /health, /auth and the web build are public.
|
||||
// Requests to /api/v1 other than GET also need the session's CSRF token in CSRFHeader.
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, ideasSvc *ideas.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, auth RouteMounter, sessions SessionLookup, settings *household.SettingsService, search *recipes.SearchSettingsService, ai *aisettings.Service, dinnerSvc *dinners.Service, suggestSvc *suggest.Service, planSvc *plan.Service, shopSvc *shopping.Service, googleSvc *gcal.Service, runner *jobs.Runner, photos PhotoFiles, assets fs.FS) (http.Handler, error) {
|
||||
if settings == nil {
|
||||
return nil, errors.New("server: nil household settings service")
|
||||
}
|
||||
@@ -89,9 +84,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
if googleSvc == nil {
|
||||
return nil, errors.New("server: nil Google Calendar service")
|
||||
}
|
||||
if ideasSvc == nil {
|
||||
return nil, errors.New("server: nil ideas service")
|
||||
}
|
||||
if runner == nil {
|
||||
return nil, errors.New("server: nil job runner")
|
||||
}
|
||||
@@ -150,7 +142,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Post("/plans/{weekStart}/draft", postPlanDraft(logger, planSvc))
|
||||
apiV1.Put("/plans/{weekStart}/days/{date}", putPlanDay(logger, planSvc))
|
||||
apiV1.Post("/plans/{weekStart}/push-calendar", postPlanPush(logger, planSvc, shopSvc))
|
||||
apiV1.Get("/shopping/archive", getSupplyArchive(logger, shopSvc))
|
||||
apiV1.Get("/shopping/{weekStart}", getSupplyRun(logger, shopSvc))
|
||||
apiV1.Post("/shopping/{weekStart}/generate", postSupplyRunGenerate(logger, shopSvc))
|
||||
apiV1.Patch("/shopping/items/{id}", patchShoppingItem(logger, shopSvc))
|
||||
@@ -165,10 +156,6 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
|
||||
apiV1.Delete("/integrations/google", deleteGoogle(logger, googleSvc))
|
||||
apiV1.Get("/integrations/google/calendars", getGoogleCalendars(logger, googleSvc))
|
||||
apiV1.Put("/integrations/google/calendar", putGoogleCalendar(logger, googleSvc))
|
||||
apiV1.Post("/ideas/from-ingredients", postIdeasFromIngredients(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/quick", postIdeasQuick(logger, ideasSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/quest-log", postIdeaQuestLog(logger, ideasSvc, dinnerSvc))
|
||||
apiV1.Post("/ideas/{jobId}/items/{i}/cook-tonight", postIdeaCookTonight(logger, ideasSvc, dinnerSvc, planSvc))
|
||||
apiV1.Post("/jobs", postJob(logger, runner))
|
||||
apiV1.Get("/jobs/{id}", getJob(logger, runner))
|
||||
media.Get("/photos/{name}", getPhoto(logger, photos))
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/dinners"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/gcal"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ideas"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/jobs"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/recipes"
|
||||
@@ -55,7 +54,7 @@ func newTestRouterWith(t *testing.T, version string, settings *household.Setting
|
||||
"index.html": {Data: []byte(fixtureIndex)},
|
||||
"assets/index-AbC123.js": {Data: []byte(fixtureAsset)},
|
||||
}
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), assets)
|
||||
r, err := NewRouter(logger, version, nil, stubAuth{}, stubSessions{}, settings, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), assets)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -191,30 +190,6 @@ func newTestSuggest(t *testing.T) *suggest.Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestIdeas returns an ideas service with its own unstarted job runner
|
||||
// and generators that always fail.
|
||||
func newTestIdeas(t *testing.T) *ideas.Service {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
runner, err := jobs.NewRunner(st, logger, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := recipestest.NewGrounder(t, st, t.TempDir(), &recipestest.Search{}, &recipestest.Pages{}, &recipestest.Images{}, &llm.Fake{})
|
||||
fail := func(context.Context, string, skills.IdeasRequest) (skills.Ideas, error) {
|
||||
return skills.Ideas{}, errors.New("no model in this test")
|
||||
}
|
||||
failQuick := func(context.Context, string, skills.QuickIdeasRequest) (skills.QuickIdeaList, error) {
|
||||
return skills.QuickIdeaList{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := ideas.NewService(st, g, fail, failQuick, runner, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// newTestPlan returns a plan service over a migrated temp database, with
|
||||
// its own unstarted job runner and a drafter that always fails.
|
||||
func newTestPlan(t *testing.T) *plan.Service {
|
||||
@@ -312,7 +287,7 @@ func TestNewRouterRequiresAISettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), nil, newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil AI settings service")
|
||||
}
|
||||
}
|
||||
@@ -321,7 +296,7 @@ func TestNewRouterRequiresSearchSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), nil, newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil recipe search settings service")
|
||||
}
|
||||
}
|
||||
@@ -330,7 +305,7 @@ func TestNewRouterRequiresPhotoFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), nil, fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted nil photo files")
|
||||
}
|
||||
}
|
||||
@@ -339,7 +314,7 @@ func TestNewRouterRequiresSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, nil, newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil settings service")
|
||||
}
|
||||
}
|
||||
@@ -348,7 +323,7 @@ func TestNewRouterRequiresDinners(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), nil, newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil dinners service")
|
||||
}
|
||||
}
|
||||
@@ -357,7 +332,7 @@ func TestNewRouterRequiresSuggestions(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), nil, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil suggestions service")
|
||||
}
|
||||
}
|
||||
@@ -366,7 +341,7 @@ func TestNewRouterRequiresPlan(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), nil, newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil plan service")
|
||||
}
|
||||
}
|
||||
@@ -375,25 +350,16 @@ func TestNewRouterRequiresShopping(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), nil, newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil shopping service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresIdeas(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil ideas service")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRouterRequiresGoogle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), nil, newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil Google Calendar service")
|
||||
}
|
||||
}
|
||||
@@ -402,7 +368,7 @@ func TestNewRouterRequiresJobRunner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
if _, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), nil, newTestPhotos(t), fstest.MapFS{"index.html": {}}); err == nil {
|
||||
t.Fatal("NewRouter accepted a nil job runner")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ func TestHouseholdSettingsWithRealSession(t *testing.T) {
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
router, err := NewRouter(logger, "dev", nil, stubAuth{}, sessions, household.NewSettingsService(st),
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestSearch(t), newTestAI(t), newTestDinners(t), newTestSuggest(t), newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestRunner(t), newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -143,70 +143,6 @@ func patchShoppingItem(logger *slog.Logger, svc *shopping.Service) http.HandlerF
|
||||
}
|
||||
}
|
||||
|
||||
// supplyArchive is the GET /shopping/archive answer: the Supply archive's
|
||||
// periods, newest first, as grouped by by.
|
||||
type supplyArchive struct {
|
||||
By string `json:"by"`
|
||||
Periods []archivePeriod `json:"periods"`
|
||||
}
|
||||
|
||||
// archivePeriod is one heading of the Supply archive and its posts,
|
||||
// newest first.
|
||||
type archivePeriod struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []archivePost `json:"posts"`
|
||||
}
|
||||
|
||||
// archivePost is one week's Supply run in the Supply archive. label is
|
||||
// the week label, which stands in for title while it is null, and
|
||||
// onHandCount counts the items marked "have it".
|
||||
type archivePost struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
}
|
||||
|
||||
// getSupplyArchive answers GET /shopping/archive?by=week|month|year with
|
||||
// the Supply archive grouped by that period.
|
||||
func getSupplyArchive(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
by := r.URL.Query().Get("by")
|
||||
periods, err := svc.Archive(r.Context(), by)
|
||||
if errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
writeError(w, r, logger, http.StatusBadRequest, "invalid_request", shopping.ErrInvalidPeriod.Error())
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeShoppingError(w, r, logger, "read Supply archive", err)
|
||||
return
|
||||
}
|
||||
out := supplyArchive{By: by, Periods: make([]archivePeriod, len(periods))}
|
||||
for i, p := range periods {
|
||||
posts := make([]archivePost, len(p.Posts))
|
||||
for j, post := range p.Posts {
|
||||
posts[j] = archivePost{
|
||||
WeekStart: post.WeekStart,
|
||||
Label: post.Label,
|
||||
Title: post.Title,
|
||||
DinnerCount: len(post.Dinners),
|
||||
ItemCount: post.Items,
|
||||
OnHandCount: post.OnHand,
|
||||
Dinners: post.Dinners,
|
||||
Current: post.Current,
|
||||
}
|
||||
}
|
||||
out.Periods[i] = archivePeriod{Key: p.Key, Heading: p.Heading, Posts: posts}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// supplyRunOf is l, the Supply run of the week starting week, as the API
|
||||
// shows it.
|
||||
func supplyRunOf(week store.LocalDate, l shopping.List) (supplyRun, error) {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
@@ -13,12 +11,9 @@ import (
|
||||
"testing"
|
||||
"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/household"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/plan"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
@@ -639,151 +634,3 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Errorf("archived Supply run after push = %v, want unchanged", l.render())
|
||||
}
|
||||
}
|
||||
|
||||
// archiveJSON is a GET /shopping/archive answer decoded with every field
|
||||
// the API promises.
|
||||
type archiveJSON struct {
|
||||
By string `json:"by"`
|
||||
Periods []struct {
|
||||
Key string `json:"key"`
|
||||
Heading string `json:"heading"`
|
||||
Posts []struct {
|
||||
WeekStart string `json:"weekStart"`
|
||||
Label string `json:"label"`
|
||||
Title *string `json:"title"`
|
||||
DinnerCount int `json:"dinnerCount"`
|
||||
ItemCount int `json:"itemCount"`
|
||||
OnHandCount int `json:"onHandCount"`
|
||||
Dinners []string `json:"dinners"`
|
||||
Current bool `json:"current"`
|
||||
} `json:"posts"`
|
||||
} `json:"periods"`
|
||||
}
|
||||
|
||||
// weekSupplyRun plans a new dinner named name, grounded in a recipe
|
||||
// listing ings, on the Monday of the week starting monday, and generates
|
||||
// the week's Supply run.
|
||||
func (e plansEnv) weekSupplyRun(t *testing.T, monday store.LocalDate, name string, ings ...shopIng) supplyRunJSON {
|
||||
t.Helper()
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
d, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
|
||||
ID: store.NewID(), Name: name, NormalizedName: harness.Normalize(name), Status: "regular", Origin: "regular",
|
||||
GroundingStatus: "manual", CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday.String(), CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{ID: store.NewID(), PlanID: planID, Date: monday.String(),
|
||||
DinnerID: sql.NullString{String: d.ID, Valid: true}, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.groundDay(t, monday.String(), ings...)
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+monday.String()+"/generate", "", &l); status != http.StatusOK {
|
||||
t.Fatalf("generate %s = %d %v", monday, status, body)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// TestSupplyArchive freezes last week's Supply run and reads the Supply
|
||||
// archive: posts newest first with exactly the promised fields, this
|
||||
// week's marked current. The frozen list is served read-only.
|
||||
func TestSupplyArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
// The fixture's plan is for a fixed week; this test plans weeks
|
||||
// around today instead.
|
||||
if _, err := e.st.Writer.ExecContext(t.Context(), `DELETE FROM plan_days; DELETE FROM week_plans`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zone, err := household.LoadZone("America/New_York") // the env's household zone
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thisWeek := household.WeekStart(household.Today(time.Now(), zone))
|
||||
y, m, d := thisWeek.Date()
|
||||
lastWeek := store.LocalDateOf(time.Date(y, m, d-7, 0, 0, 0, 0, time.UTC))
|
||||
|
||||
last := e.weekSupplyRun(t, lastWeek, "Beef Stew", shopIng{"stewing beef", "2", "lb"}, shopIng{"carrots", "4", ""}, shopIng{"onion", "1", ""})
|
||||
onion := last.itemID(t, "onion")
|
||||
if status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+onion, `{"needed":false}`, nil); status != http.StatusOK {
|
||||
t.Fatalf("PATCH = %d %v", status, body)
|
||||
}
|
||||
e.weekSupplyRun(t, thisWeek, "Lemon Chicken", shopIng{"chicken thighs", "6", ""}, shopIng{"lemon", "1", ""})
|
||||
if err := e.shop.Freeze(t.Context(), lastWeek); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.shop.Freeze(t.Context(), thisWeek); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze this week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
var got archiveJSON
|
||||
status, raw := callShopping(t, e, http.MethodGet, shoppingPath+"archive?by=week", "", &got)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET archive = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, []string{"by", "periods"}) {
|
||||
t.Errorf("archive fields = %v", k)
|
||||
}
|
||||
period := raw["periods"].([]any)[0].(map[string]any)
|
||||
if k := keys(period); !reflect.DeepEqual(k, []string{"heading", "key", "posts"}) {
|
||||
t.Errorf("period fields = %v", k)
|
||||
}
|
||||
wantPost := []string{"current", "dinnerCount", "dinners", "itemCount", "label", "onHandCount", "title", "weekStart"}
|
||||
if k := keys(period["posts"].([]any)[0].(map[string]any)); !reflect.DeepEqual(k, wantPost) {
|
||||
t.Errorf("post fields = %v, want %v", k, wantPost)
|
||||
}
|
||||
var posts []string
|
||||
for _, p := range got.Periods {
|
||||
if len(p.Posts) != 1 || p.Key != household.PeriodKey(mustLocalDate(t, p.Posts[0].WeekStart)) || p.Heading != p.Posts[0].Label {
|
||||
t.Errorf("period %+v, want one post under its week", p)
|
||||
}
|
||||
for _, post := range p.Posts {
|
||||
label, err := household.WeekLabel(mustLocalDate(t, post.WeekStart))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if post.Label != label || post.Title != nil {
|
||||
t.Errorf("post %s: label %q title %v, want %q and null", post.WeekStart, post.Label, post.Title, label)
|
||||
}
|
||||
posts = append(posts, fmt.Sprintf("%s %d dinners %d items %d on hand %v current=%t",
|
||||
post.WeekStart, post.DinnerCount, post.ItemCount, post.OnHandCount, post.Dinners, post.Current))
|
||||
}
|
||||
}
|
||||
want := []string{
|
||||
thisWeek.String() + " 1 dinners 2 items 0 on hand [Lemon Chicken] current=true",
|
||||
lastWeek.String() + " 1 dinners 3 items 1 on hand [Beef Stew] current=false",
|
||||
}
|
||||
if got.By != "week" || !reflect.DeepEqual(posts, want) {
|
||||
t.Errorf("archive by %s =\n %s\nwant\n %s", got.By, strings.Join(posts, "\n "), strings.Join(want, "\n "))
|
||||
}
|
||||
|
||||
for _, q := range []string{"", "?by=", "?by=day", "?by=MONTH"} {
|
||||
status, body := callShopping(t, e, http.MethodGet, shoppingPath+"archive"+q, "", nil)
|
||||
if status != http.StatusBadRequest || errorCode(body) != "invalid_request" {
|
||||
t.Errorf("GET archive%s = %d %v, want 400 invalid_request", q, status, body)
|
||||
}
|
||||
}
|
||||
|
||||
status, body := callShopping(t, e, http.MethodPatch, shoppingPath+"items/"+last.itemID(t, "carrots"), `{"needed":false}`, nil)
|
||||
if status != http.StatusConflict || errorCode(body) != "archived" {
|
||||
t.Errorf("PATCH after Freeze = %d %v, want 409 archived", status, body)
|
||||
}
|
||||
var frozen supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodGet, shoppingPath+lastWeek.String(), "", &frozen); status != http.StatusOK {
|
||||
t.Fatalf("GET frozen list = %d %v", status, body)
|
||||
}
|
||||
wantLines := []string{
|
||||
"Monday " + lastWeek.String() + " Beef Stew: stewing beef [2 lb] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: carrots [4] needed",
|
||||
"Monday " + lastWeek.String() + " Beef Stew: onion [1] have it",
|
||||
}
|
||||
if frozen.Status != "archived" || frozen.NeededCount != 2 || frozen.TotalCount != 3 || !reflect.DeepEqual(frozen.render(), wantLines) {
|
||||
t.Errorf("frozen list = %s %d/%d %v, want archived 2/3 %v", frozen.Status, frozen.NeededCount, frozen.TotalCount, frozen.render(), wantLines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func newSuggestionsRouter(t *testing.T, names ...string) (http.Handler, *store.S
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := NewRouter(logger, "dev", nil, stubAuth{}, stubSessions{}, newTestSettings(t), newTestSearch(t), newTestAI(t),
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), newTestIdeas(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
newTestDinners(t), svc, newTestPlan(t), newTestShopping(t), newTestGoogle(t), runner, newTestPhotos(t), fstest.MapFS{"index.html": {Data: []byte(fixtureIndex)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"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/store"
|
||||
)
|
||||
|
||||
// Archive groupings: the period Archive groups its posts by.
|
||||
const (
|
||||
ByWeek = "week"
|
||||
ByMonth = "month"
|
||||
ByYear = "year"
|
||||
)
|
||||
|
||||
// Freeze moves the Supply run of the week starting weekStart into the
|
||||
// Supply archive: its status becomes archived and frozen_at is set, after
|
||||
// which it is read-only (Generate and SetNeeded are ErrFrozen). Freezing a
|
||||
// frozen list does nothing and is no error.
|
||||
//
|
||||
// The week must have ended in the household's time zone: a week not yet
|
||||
// ended is ErrWeekNotEnded, and a week with no Supply run ErrListNotFound.
|
||||
//
|
||||
// A list still untitled is titled first, with archive_title and the same
|
||||
// conditional write as a KindTitleSupplyRun job, from the week's plan as
|
||||
// Generate titles it, so its post in the Supply archive gets a title when
|
||||
// the AI can write one. Titling never fails the freeze: a title that
|
||||
// cannot be written is logged and the list is frozen untitled, when its
|
||||
// week label stands in for the title for good. Freeze first waits for the
|
||||
// title jobs in flight to store their titles or fail, and holds off new
|
||||
// ones until the list is frozen, so a title a job's archive_title run
|
||||
// wrote is stored before the freeze, and the freeze runs archive_title
|
||||
// only when no job is running it. A job that runs after the freeze finds
|
||||
// the list titled, or frozen untitled, and stores nothing.
|
||||
func (s *Service) Freeze(ctx context.Context, weekStart store.LocalDate) error {
|
||||
if _, err := household.WeekDays(weekStart); err != nil {
|
||||
return fmt.Errorf("shopping: freeze: %w", err)
|
||||
}
|
||||
q := db.New(s.st.Writer)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if weekStart.String() >= thisWeek.String() {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, ErrWeekNotEnded)
|
||||
}
|
||||
s.titling.Lock()
|
||||
defer s.titling.Unlock()
|
||||
list, err := q.GetShoppingList(ctx, weekStart.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ErrListNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("shopping: freeze: read list: %w", err)
|
||||
}
|
||||
if frozen(list) {
|
||||
return nil
|
||||
}
|
||||
if !list.Title.Valid {
|
||||
p, err := freezeTitleJob(ctx, q, list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.storeTitle(ctx, p); err != nil {
|
||||
s.logger.WarnContext(ctx, "Supply run frozen untitled; the Supply archive shows its week label",
|
||||
"week", list.WeekStart, "list", list.ID, "error", err)
|
||||
}
|
||||
}
|
||||
// A list frozen meanwhile is left as it is: the freeze is done.
|
||||
now := store.Now().String()
|
||||
if _, err := q.FreezeShoppingList(ctx, db.FreezeShoppingListParams{
|
||||
FrozenAt: sql.NullString{String: now, Valid: true}, UpdatedAt: now, ID: list.ID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("shopping: freeze %s: %w", weekStart, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freezeTitleJob is the title job of list as Generate would enqueue it:
|
||||
// the week's plan revision and its dinners in day order.
|
||||
func freezeTitleJob(ctx context.Context, q *db.Queries, list db.ShoppingList) (TitleJob, error) {
|
||||
plan, err := q.GetWeekPlan(ctx, list.WeekStart)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan: %w", err)
|
||||
}
|
||||
days, err := q.ListPlanDays(ctx, plan.ID)
|
||||
if err != nil {
|
||||
return TitleJob{}, fmt.Errorf("shopping: freeze: read plan days: %w", err)
|
||||
}
|
||||
names := make([]string, len(days))
|
||||
for i, d := range days {
|
||||
names[i] = d.Dinner.Name
|
||||
}
|
||||
return TitleJob{ListID: list.ID, WeekStart: list.WeekStart, Revision: plan.Revision, Dinners: names}, nil
|
||||
}
|
||||
|
||||
// thisWeek returns the Monday of the household's current week, in its
|
||||
// time zone, by the service's clock.
|
||||
func (s *Service) thisWeek(ctx context.Context, q *db.Queries) (store.LocalDate, error) {
|
||||
settings, err := q.GetHouseholdSettings(ctx)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: read household settings: %w", err)
|
||||
}
|
||||
zone, err := household.LoadZone(settings.Timezone)
|
||||
if err != nil {
|
||||
return store.LocalDate{}, fmt.Errorf("shopping: household settings: %w", err)
|
||||
}
|
||||
return household.WeekStart(household.Today(s.now(), zone)), nil
|
||||
}
|
||||
|
||||
// Period is one heading of the Supply archive and its posts, newest first.
|
||||
type Period struct {
|
||||
// Key identifies the period: the ISO week ("2026-W41"), the month
|
||||
// ("2026-10") or the year ("2026").
|
||||
Key string
|
||||
// Heading is the period as shown: the week label, the month and year
|
||||
// ("October 2026") or the year ("2026").
|
||||
Heading string
|
||||
Posts []Post
|
||||
}
|
||||
|
||||
// Post is one Supply run in the Supply archive.
|
||||
type Post struct {
|
||||
WeekStart string
|
||||
// Label is the week label, such as "Week 41 · Oct 5 – 11".
|
||||
Label string
|
||||
// Title is nil while the list is untitled, when Label stands in.
|
||||
Title *string
|
||||
// Dinners are the names of the list's dinners, in day order.
|
||||
Dinners []string
|
||||
// Items is how many items the list has, and OnHand how many of them
|
||||
// the household marked "have it".
|
||||
Items, OnHand int
|
||||
// Current is true for the household's current week.
|
||||
Current bool
|
||||
}
|
||||
|
||||
// Archive returns the Supply archive: the Supply run of every week up to
|
||||
// and including the household's current week, newest first, grouped by
|
||||
// by, which is ByWeek, ByMonth or ByYear (else ErrInvalidPeriod). A
|
||||
// week's month and year are those of its Monday, a household-local date,
|
||||
// so a week belongs to one period whatever the server's zone; the week
|
||||
// starting Monday 2026-12-28 is in December 2026.
|
||||
func (s *Service) Archive(ctx context.Context, by string) (out []Period, err error) {
|
||||
// period returns the key and heading of the period holding p, whose
|
||||
// week starts on Monday monday.
|
||||
var period func(monday store.LocalDate, p Post) (string, string)
|
||||
switch by {
|
||||
case ByWeek:
|
||||
period = func(monday store.LocalDate, p Post) (string, string) {
|
||||
return household.PeriodKey(monday), p.Label
|
||||
}
|
||||
case ByMonth:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, m, _ := monday.Date()
|
||||
return fmt.Sprintf("%04d-%02d", y, int(m)), fmt.Sprintf("%s %d", m, y)
|
||||
}
|
||||
case ByYear:
|
||||
period = func(monday store.LocalDate, _ Post) (string, string) {
|
||||
y, _, _ := monday.Date()
|
||||
return strconv.Itoa(y), strconv.Itoa(y)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("shopping: archive by %q: %w", by, ErrInvalidPeriod)
|
||||
}
|
||||
tx, err := s.st.Reader.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: begin read: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, tx.Rollback())
|
||||
}()
|
||||
q := db.New(tx)
|
||||
thisWeek, err := s.thisWeek(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lists, err := q.ListShoppingListsThrough(ctx, thisWeek.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: read Supply runs: %w", err)
|
||||
}
|
||||
out = []Period{}
|
||||
for _, row := range lists {
|
||||
monday, err := store.ParseLocalDate(row.WeekStart)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shopping: Supply run %s week: %w", row.ID, err)
|
||||
}
|
||||
l, err := readList(ctx, q, row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p, err := postOf(monday, l, monday == thisWeek)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
k, heading := period(monday, p)
|
||||
if n := len(out); n == 0 || out[n-1].Key != k {
|
||||
out = append(out, Period{Key: k, Heading: heading})
|
||||
}
|
||||
out[len(out)-1].Posts = append(out[len(out)-1].Posts, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// postOf is l, the Supply run of the week starting monday, as a post in
|
||||
// the Supply archive.
|
||||
func postOf(monday store.LocalDate, l List, current bool) (Post, error) {
|
||||
label, err := household.WeekLabel(monday)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("shopping: Supply run %s: %w", l.ID, err)
|
||||
}
|
||||
dinners := make([]string, len(l.Groups))
|
||||
for i, g := range l.Groups {
|
||||
dinners[i] = g.DinnerName
|
||||
}
|
||||
c := l.Counts()
|
||||
return Post{
|
||||
WeekStart: l.WeekStart,
|
||||
Label: label,
|
||||
Title: l.Title,
|
||||
Dinners: dinners,
|
||||
Items: c.Total,
|
||||
OnHand: c.Total - c.Needed,
|
||||
Current: current,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,477 +0,0 @@
|
||||
package shopping_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/shopping"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
|
||||
)
|
||||
|
||||
// zoned makes zone the household's time zone and at the instant the
|
||||
// service's clock reads.
|
||||
func (e *env) zoned(t *testing.T, zone, at string) {
|
||||
t.Helper()
|
||||
if err := household.SeedSettings(t.Context(), e.st, zone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.at(t, at)
|
||||
}
|
||||
|
||||
// at sets the service's clock to the RFC 3339 instant at.
|
||||
func (e *env) at(t *testing.T, at string) {
|
||||
t.Helper()
|
||||
now, err := time.Parse(time.RFC3339, at)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shopping.SetClock(e.svc, func() time.Time { return now })
|
||||
}
|
||||
|
||||
// frozenState returns the week's list's status and frozen_at; frozen_at is
|
||||
// "" while NULL.
|
||||
func (e *env) frozenState(t *testing.T, monday store.LocalDate) (string, string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var frozenAt sql.NullString
|
||||
if err := e.st.Reader.QueryRow(`SELECT status, frozen_at FROM shopping_lists WHERE week_start = ?`, monday.String()).Scan(&status, &frozenAt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return status, frozenAt.String
|
||||
}
|
||||
|
||||
// weekList plans the dinners named names on the days of the week starting
|
||||
// monday, in order, and generates its Supply run.
|
||||
func (e *env) weekList(t *testing.T, monday string, names ...string) shopping.List {
|
||||
t.Helper()
|
||||
start := mustDate(monday)
|
||||
days, err := household.WeekDays(start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := db.New(e.st.Writer)
|
||||
now := store.Now().String()
|
||||
planID := store.NewID()
|
||||
if err := q.CreateWeekPlan(t.Context(), db.CreateWeekPlanParams{ID: planID, WeekStart: monday, CreatedAt: now, UpdatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, name := range names {
|
||||
if err := q.PutPlanDay(t.Context(), db.PutPlanDayParams{
|
||||
ID: store.NewID(), PlanID: planID, Date: days[i].String(),
|
||||
DinnerID: sql.NullString{String: e.ids[name], Valid: true}, CreatedAt: now, UpdatedAt: now,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
g, err := e.svc.Generate(t.Context(), start)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g.List
|
||||
}
|
||||
|
||||
// endedWeek is the env with the tests' week generated, in New York, on
|
||||
// the Monday after it, so the week has ended.
|
||||
func endedWeek(t *testing.T) (*env, shopping.List) {
|
||||
t.Helper()
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""})
|
||||
e.plan(t, "Beef Chili")
|
||||
return e, e.generate(t).List
|
||||
}
|
||||
|
||||
// TestFreeze freezes an ended week's list: it is archived with frozen_at,
|
||||
// read back read-only, and freezing it again changes nothing.
|
||||
func TestFreeze(t *testing.T) {
|
||||
e, l := endedWeek(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
status, frozenAt := e.frozenState(t, week)
|
||||
if status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Fatalf("after Freeze: status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
got, err := e.svc.Get(t.Context(), week)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Status != shopping.StatusArchived {
|
||||
t.Errorf("read back status = %q, want archived", got.Status)
|
||||
}
|
||||
wantLines(t, "read back", lines(got), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Millisecond) // a second freeze would stamp a later frozen_at
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("second Freeze = %v, want no error", err)
|
||||
}
|
||||
if s, again := e.frozenState(t, week); s != shopping.StatusArchived || again != frozenAt {
|
||||
t.Errorf("after second Freeze: status %q, frozen_at %q; want archived, %q kept", s, again, frozenAt)
|
||||
}
|
||||
|
||||
if _, err := e.svc.SetNeeded(t.Context(), l.Groups[0].Items[1].ID, false); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("SetNeeded after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
if _, err := e.svc.Generate(t.Context(), week); !errors.Is(err, shopping.ErrFrozen) {
|
||||
t.Errorf("Generate after Freeze = %v, want ErrFrozen", err)
|
||||
}
|
||||
wantLines(t, "stored list", e.stored(t), []string{
|
||||
"0 2026-10-12 Beef Chili: 0 ground beef [1 lb] needed",
|
||||
"0 2026-10-12 Beef Chili: 1 onion [1] needed",
|
||||
})
|
||||
}
|
||||
|
||||
// TestFreezeWeekNotEnded: a week has not ended until Monday comes in the
|
||||
// household's zone. At 23:30 on the Sunday in Los Angeles it is already
|
||||
// Monday in UTC, and the list is still not frozen; at 00:05 on the Monday
|
||||
// it is.
|
||||
func TestFreezeWeekNotEnded(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/Los_Angeles", "2026-10-19T06:30:00Z") // Sun 18 Oct 23:30 PDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.generate(t)
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Fatalf("Freeze on the Sunday = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusCurrent || frozenAt != "" {
|
||||
t.Fatalf("after refused Freeze: status %q, frozen_at %q; want current, unset", status, frozenAt)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-19")); !errors.Is(err, shopping.ErrWeekNotEnded) {
|
||||
t.Errorf("Freeze of a later week = %v, want ErrWeekNotEnded", err)
|
||||
}
|
||||
|
||||
e.at(t, "2026-10-19T07:05:00Z") // Mon 19 Oct 00:05 PDT
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatalf("Freeze on the Monday = %v", err)
|
||||
}
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreezeWeekWithoutList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
if err := e.svc.Freeze(t.Context(), week); !errors.Is(err, shopping.ErrListNotFound) {
|
||||
t.Fatalf("Freeze = %v, want ErrListNotFound", err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), mustDate("2026-10-13")); err == nil {
|
||||
t.Fatal("Freeze of a Tuesday succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeTitlesUntitledList: a list still untitled when its week is
|
||||
// frozen is titled by archive_title first, from the plan's dinners, and
|
||||
// frozen with that title. Its title job, still queued, runs after the
|
||||
// freeze, finds the title and succeeds without asking the model again.
|
||||
func TestFreezeTitlesUntitledList(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.dinner(t, "Fish Tacos")
|
||||
e.plan(t, "Fish Tacos", "Beef Chili")
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
e.model.QueueGenerate(titled("Tacos then Chili"))
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Tacos then Chili")
|
||||
if status, frozenAt := e.frozenState(t, week); status != shopping.StatusArchived || frozenAt == "" {
|
||||
t.Errorf("status %q, frozen_at %q; want archived and set", status, frozenAt)
|
||||
}
|
||||
reqs := e.model.Requests()
|
||||
if len(reqs) != 1 {
|
||||
t.Fatalf("%d model calls, want 1", len(reqs))
|
||||
}
|
||||
if want := "Title the week whose dinners are, in day order from Monday:\n- Fish Tacos\n- Beef Chili"; reqs[0].Messages[0].Content != want {
|
||||
t.Errorf("user message = %q, want %q", reqs[0].Messages[0].Content, want)
|
||||
}
|
||||
|
||||
e.start(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job finds the list titled", n)
|
||||
}
|
||||
wantTitle(t, "stored title after the job", e.storedTitle(t), "Tacos then Chili")
|
||||
}
|
||||
|
||||
// pauseTitle makes the env's first archive_title run pause once it has
|
||||
// returned, before its title is stored, until the returned release is
|
||||
// called or the test ends; paused is closed as it pauses.
|
||||
func (e *env) pauseTitle(t *testing.T) (paused <-chan struct{}, release func()) {
|
||||
t.Helper()
|
||||
p, r := make(chan struct{}), make(chan struct{})
|
||||
var first atomic.Bool
|
||||
e.afterTitle = func() {
|
||||
if first.CompareAndSwap(false, true) {
|
||||
close(p)
|
||||
select {
|
||||
case <-r:
|
||||
case <-t.Context().Done():
|
||||
}
|
||||
}
|
||||
}
|
||||
var released sync.Once
|
||||
return p, func() { released.Do(func() { close(r) }) }
|
||||
}
|
||||
|
||||
// waitClosed fails t unless c is closed within 10 seconds.
|
||||
func waitClosed(t *testing.T, what string, c <-chan struct{}) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-c:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatalf("timed out waiting for %s", what)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeWaitsForTitleJob: a freeze that starts while a title job's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the job to store it, freezes the list with that title, and asks the
|
||||
// model nothing itself.
|
||||
func TestFreezeWaitsForTitleJob(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
paused, release := e.pauseTitle(t)
|
||||
e.start(t)
|
||||
g := e.generate(t)
|
||||
waitClosed(t, "the title job's archive_title run", paused)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
var err error
|
||||
select {
|
||||
case err = <-froze:
|
||||
t.Errorf("Freeze = %v before the title job stored its title, want it to wait", err)
|
||||
release()
|
||||
case <-time.After(time.Second):
|
||||
release()
|
||||
err = <-froze
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the freeze waits for the job's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTitleJobWaitsForFreeze: a title job that starts while a freeze's
|
||||
// archive_title run has returned but its title is not yet stored waits for
|
||||
// the freeze, then finds the list titled and asks the model nothing.
|
||||
func TestTitleJobWaitsForFreeze(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"})
|
||||
e.plan(t, "Beef Chili")
|
||||
e.model.QueueGenerate(titled("Chili Night"))
|
||||
g := e.generate(t) // the runner is not started: its title job waits
|
||||
paused, release := e.pauseTitle(t)
|
||||
|
||||
froze := make(chan error, 1)
|
||||
go func() { froze <- e.svc.Freeze(context.Background(), week) }()
|
||||
waitClosed(t, "the freeze's archive_title run", paused)
|
||||
e.start(t)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
job, err := e.runner.Get(t.Context(), *g.TitleJobID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if job.Status == jobs.StatusRunning {
|
||||
break
|
||||
}
|
||||
if job.Status != jobs.StatusQueued || time.Now().After(deadline) {
|
||||
t.Fatalf("title job %s, want it running while the freeze is paused", job.Status)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
release()
|
||||
if err := <-froze; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusSucceeded, "")
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if n := len(e.model.Requests()); n != 1 {
|
||||
t.Errorf("%d model calls, want 1: the job waits for the freeze's title", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeUntitledWhenTitleFails: when archive_title cannot title the
|
||||
// list, it is frozen untitled all the same, the failure is logged, and the
|
||||
// Supply archive shows its week label for good.
|
||||
func TestFreezeUntitledWhenTitleFails(t *testing.T) {
|
||||
e, _ := endedWeek(t) // nothing queued on the model: archive_title fails
|
||||
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantUntitled(t, "stored title", e.storedTitle(t))
|
||||
if status, _ := e.frozenState(t, week); status != shopping.StatusArchived {
|
||||
t.Errorf("status = %q, want archived", status)
|
||||
}
|
||||
if !strings.Contains(e.log.String(), "Supply run frozen untitled") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
periods, err := e.svc.Archive(t.Context(), shopping.ByWeek)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(periods) != 1 || len(periods[0].Posts) != 1 || periods[0].Posts[0].Title != nil ||
|
||||
periods[0].Posts[0].Label != "Week 42 · Oct 12 – 18" {
|
||||
t.Errorf("archive = %+v, want the untitled week under its label", periods)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreezeKeepsTitle: a titled list is frozen with its title and the
|
||||
// model is not asked again.
|
||||
func TestFreezeKeepsTitle(t *testing.T) {
|
||||
e, _ := endedWeek(t)
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili Night'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.svc.Freeze(t.Context(), week); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantTitle(t, "stored title", e.storedTitle(t), "Chili Night")
|
||||
if n := len(e.model.Requests()); n != 0 {
|
||||
t.Errorf("%d model calls, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
// postLine renders p as "weekStart label | title | dinners | items/on hand
|
||||
// | current".
|
||||
func postLine(p shopping.Post) string {
|
||||
title := "-"
|
||||
if p.Title != nil {
|
||||
title = *p.Title
|
||||
}
|
||||
cur := ""
|
||||
if p.Current {
|
||||
cur = " | current"
|
||||
}
|
||||
return p.WeekStart + " " + p.Label + " | " + title + " | " + strings.Join(p.Dinners, ", ") + " | " +
|
||||
strconv.Itoa(p.Items) + " items, " + strconv.Itoa(p.OnHand) + " on hand" + cur
|
||||
}
|
||||
|
||||
// periodLines renders periods one line per heading and post.
|
||||
func periodLines(periods []shopping.Period) []string {
|
||||
var out []string
|
||||
for _, p := range periods {
|
||||
out = append(out, "# "+p.Key+" "+p.Heading)
|
||||
for _, post := range p.Posts {
|
||||
out = append(out, postLine(post))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArchiveGroups reads the Supply archive across a month and a year
|
||||
// boundary in Auckland, where it is already Monday 4 January 2027 while
|
||||
// UTC still reads Sunday 3 January: that week is current, the next one is
|
||||
// not yet in the archive, and each week falls in the month and year of
|
||||
// its Monday.
|
||||
func TestArchiveGroups(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "Pacific/Auckland", "2027-01-03T12:00:00Z") // Mon 4 Jan 2027 01:00 NZDT
|
||||
e.dinner(t, "Beef Chili", ing{"ground beef", "1", "lb"}, ing{"onion", "1", ""}, ing{"kidney beans", "2", "cans"})
|
||||
e.dinner(t, "Pad Thai", ing{"rice noodles", "8", "oz"}, ing{"lime", "2", ""})
|
||||
e.dinner(t, "Fish Tacos") // no recipe: no group, not counted
|
||||
e.dinner(t, "Pot Roast", ing{"carrots", "4", ""})
|
||||
|
||||
nov := e.weekList(t, "2026-11-30", "Pot Roast") // Nov 30 – Dec 6
|
||||
e.weekList(t, "2026-12-07", "Pad Thai", "Fish Tacos")
|
||||
dec := e.weekList(t, "2026-12-28", "Beef Chili", "Fish Tacos", "Pad Thai") // ISO 2026-W53, into January
|
||||
jan := e.weekList(t, "2027-01-04", "Pad Thai", "Beef Chili")
|
||||
e.weekList(t, "2027-01-11", "Pot Roast") // next week: not in the archive yet
|
||||
e.haveIt(t, dec, "Beef Chili", "onion")
|
||||
e.haveIt(t, dec, "Pad Thai", "lime")
|
||||
e.haveIt(t, jan, "Pad Thai", "lime")
|
||||
e.haveIt(t, nov, "Pot Roast", "carrots")
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET title = 'Chili, Noodles & Co' WHERE week_start = '2026-12-28'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.st.Writer.Exec(`UPDATE shopping_lists SET status = 'archived', frozen_at = '2027-01-03T11:05:00.000Z' WHERE week_start < '2027-01-04'`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
posts := map[string]string{
|
||||
"jan": "2027-01-04 Week 1 · Jan 4 – 10 | - | Pad Thai, Beef Chili | 5 items, 1 on hand | current",
|
||||
"dec": "2026-12-28 Week 53 · Dec 28 – Jan 3 | Chili, Noodles & Co | Beef Chili, Pad Thai | 5 items, 2 on hand",
|
||||
"d07": "2026-12-07 Week 50 · Dec 7 – 13 | - | Pad Thai | 2 items, 0 on hand",
|
||||
"nov": "2026-11-30 Week 49 · Nov 30 – Dec 6 | - | Pot Roast | 1 items, 1 on hand",
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
by string
|
||||
want []string
|
||||
}{
|
||||
{shopping.ByWeek, []string{
|
||||
"# 2027-W01 Week 1 · Jan 4 – 10", posts["jan"],
|
||||
"# 2026-W53 Week 53 · Dec 28 – Jan 3", posts["dec"],
|
||||
"# 2026-W50 Week 50 · Dec 7 – 13", posts["d07"],
|
||||
"# 2026-W49 Week 49 · Nov 30 – Dec 6", posts["nov"],
|
||||
}},
|
||||
{shopping.ByMonth, []string{
|
||||
"# 2027-01 January 2027", posts["jan"],
|
||||
"# 2026-12 December 2026", posts["dec"], posts["d07"],
|
||||
"# 2026-11 November 2026", posts["nov"],
|
||||
}},
|
||||
{shopping.ByYear, []string{
|
||||
"# 2027 2027", posts["jan"],
|
||||
"# 2026 2026", posts["dec"], posts["d07"], posts["nov"],
|
||||
}},
|
||||
} {
|
||||
got, err := e.svc.Archive(t.Context(), tc.by)
|
||||
if err != nil {
|
||||
t.Fatalf("Archive(%s): %v", tc.by, err)
|
||||
}
|
||||
if lines := periodLines(got); !reflect.DeepEqual(lines, tc.want) {
|
||||
t.Errorf("Archive(%s) =\n %s\nwant\n %s", tc.by, strings.Join(lines, "\n "), strings.Join(tc.want, "\n "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveEmptyAndInvalidPeriod(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.zoned(t, "America/New_York", "2026-10-19T12:00:00Z")
|
||||
got, err := e.svc.Archive(t.Context(), shopping.ByMonth)
|
||||
if err != nil || got == nil || len(got) != 0 {
|
||||
t.Errorf("Archive of no Supply runs = %#v, %v; want empty", got, err)
|
||||
}
|
||||
for _, by := range []string{"", "day", "Week", "months"} {
|
||||
if _, err := e.svc.Archive(t.Context(), by); !errors.Is(err, shopping.ErrInvalidPeriod) {
|
||||
t.Errorf("Archive(%q) = %v, want ErrInvalidPeriod", by, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package shopping
|
||||
|
||||
import "time"
|
||||
|
||||
// SetClock makes now the clock s ends and dates weeks on.
|
||||
func SetClock(s *Service, now func() time.Time) { s.now = now }
|
||||
@@ -8,10 +8,6 @@
|
||||
// (see Generate). The one model call is the Supply run's title, written by
|
||||
// archive_title in a KindTitleSupplyRun job when the list is generated
|
||||
// untitled (see Generate).
|
||||
//
|
||||
// Freeze moves a week's Supply run into the Supply archive once its week
|
||||
// has ended, and Archive reads the archive back as posts grouped by week,
|
||||
// month or year.
|
||||
package shopping
|
||||
|
||||
import (
|
||||
@@ -23,8 +19,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
|
||||
@@ -76,14 +70,6 @@ var ErrListNotFound = errors.New("the week has no Supply run")
|
||||
// ErrItemNotFound is a change to an item on no Supply run.
|
||||
var ErrItemNotFound = errors.New("no Supply run has that item")
|
||||
|
||||
// ErrWeekNotEnded is a freeze of a Supply run whose week has not ended in
|
||||
// the household's time zone.
|
||||
var ErrWeekNotEnded = errors.New("the week has not ended")
|
||||
|
||||
// ErrInvalidPeriod is an Archive grouping other than ByWeek, ByMonth and
|
||||
// ByYear.
|
||||
var ErrInvalidPeriod = errors.New("by must be week, month or year")
|
||||
|
||||
// Titler runs archive_title on in; scope names the week, its plan revision
|
||||
// and its dinners.
|
||||
// In production it is skills.TitleArchive with the household's
|
||||
@@ -96,14 +82,6 @@ type Service struct {
|
||||
title Titler
|
||||
titleJob *jobs.Kind[TitleJob]
|
||||
logger *slog.Logger
|
||||
// now is the clock weeks are ended and current on; tests replace it.
|
||||
now func() time.Time
|
||||
// titling orders the title writes against Freeze. A title job holds it
|
||||
// shared from reading the list to storing its title, and Freeze holds
|
||||
// it exclusively from reading the list to freezing it, so a freeze
|
||||
// waits for the title jobs in flight to store their titles or fail,
|
||||
// and no title job runs archive_title while a freeze is under way.
|
||||
titling sync.RWMutex
|
||||
}
|
||||
|
||||
// NewService returns a service over st that titles Supply runs with title
|
||||
@@ -120,7 +98,7 @@ func NewService(st *store.Store, title Titler, runner *jobs.Runner, logger *slog
|
||||
case logger == nil:
|
||||
return nil, errors.New("shopping: nil logger")
|
||||
}
|
||||
s := &Service{st: st, title: title, logger: logger, now: time.Now}
|
||||
s := &Service{st: st, title: title, logger: logger}
|
||||
kind, err := jobs.Register(runner, KindTitleSupplyRun, s.runTitleJob, ReasonTitleFailed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -367,19 +345,10 @@ func (s *Service) runTitleJob(ctx context.Context, p TitleJob, _ func(titleResul
|
||||
return titleResult{ListID: p.ListID, Title: title}, nil
|
||||
}
|
||||
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title,
|
||||
// as storeTitle does, while no freeze is under way: it waits for one to
|
||||
// end first, and a freeze waits for it to end.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
s.titling.RLock()
|
||||
defer s.titling.RUnlock()
|
||||
return s.storeTitle(ctx, p)
|
||||
}
|
||||
|
||||
// storeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// writeTitle runs archive_title for p's Supply run and stores its title.
|
||||
// When the list was titled by another job, before this one ran or
|
||||
// meanwhile, that title stands and is returned. The caller holds titling.
|
||||
func (s *Service) storeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
// meanwhile, that title stands and is returned.
|
||||
func (s *Service) writeTitle(ctx context.Context, p TitleJob) (string, error) {
|
||||
q := db.New(s.st.Writer)
|
||||
title, err := s.currentTitle(ctx, q, p)
|
||||
if err != nil || title != "" {
|
||||
@@ -454,18 +423,13 @@ func (s *Service) Get(ctx context.Context, weekStart store.LocalDate) (l List, e
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read list: %w", err)
|
||||
}
|
||||
return readList(ctx, q, list)
|
||||
}
|
||||
|
||||
// readList reads the groups and items of list through q.
|
||||
func readList(ctx context.Context, q *db.Queries, list db.ShoppingList) (List, error) {
|
||||
rows, err := q.ListShoppingGroupDinners(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read groups of %s: %w", list.WeekStart, err)
|
||||
return List{}, fmt.Errorf("shopping: read groups: %w", err)
|
||||
}
|
||||
items, err := q.ListShoppingItems(ctx, list.ID)
|
||||
if err != nil {
|
||||
return List{}, fmt.Errorf("shopping: read items of %s: %w", list.WeekStart, err)
|
||||
return List{}, fmt.Errorf("shopping: read items: %w", err)
|
||||
}
|
||||
byGroup := make(map[string][]Item, len(rows))
|
||||
for _, r := range items {
|
||||
|
||||
@@ -54,9 +54,6 @@ type env struct {
|
||||
model *llm.Fake
|
||||
// beforeTitle, when set, runs as archive_title is asked for a title.
|
||||
beforeTitle func()
|
||||
// afterTitle, when set, runs once archive_title has returned, before
|
||||
// its title is stored.
|
||||
afterTitle func()
|
||||
// beforeModel, when set, runs as the model is sent a request.
|
||||
beforeModel func(llm.Request)
|
||||
// log holds what the service logged.
|
||||
@@ -111,11 +108,7 @@ func newEnv(t *testing.T) *env {
|
||||
if e.beforeTitle != nil {
|
||||
e.beforeTitle()
|
||||
}
|
||||
out, err := skills.TitleArchive(ctx, cfg, scope, in)
|
||||
if e.afterTitle != nil {
|
||||
e.afterTitle()
|
||||
}
|
||||
return out, err
|
||||
return skills.TitleArchive(ctx, cfg, scope, in)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(e.log, nil))
|
||||
e.runner, err = jobs.NewRunner(st, logger, 2)
|
||||
|
||||
@@ -88,17 +88,3 @@ WHERE id = ?;
|
||||
UPDATE shopping_lists
|
||||
SET title = ?, updated_at = ?
|
||||
WHERE id = ? AND title IS NULL AND status = 'current' AND frozen_at IS NULL;
|
||||
|
||||
-- name: FreezeShoppingList :execrows
|
||||
-- Freezes list id into the Supply archive at frozen_at, unless it is
|
||||
-- frozen already.
|
||||
UPDATE shopping_lists
|
||||
SET status = 'archived', frozen_at = ?, updated_at = ?
|
||||
WHERE id = ? AND frozen_at IS NULL;
|
||||
|
||||
-- name: ListShoppingListsThrough :many
|
||||
-- The Supply runs of the weeks starting on or before week_start, newest
|
||||
-- week first.
|
||||
SELECT * FROM shopping_lists
|
||||
WHERE week_start <= ?
|
||||
ORDER BY week_start DESC;
|
||||
|
||||
@@ -8,7 +8,6 @@ import App, { routes } from './App.tsx'
|
||||
import { AI_SETTINGS, HOUSEHOLD_SETTINGS, RECIPE_SEARCH_SETTINGS, SETTINGS_API } from './test/ApiProviders.tsx'
|
||||
import { InDataRouter } from './test/dataRouter.tsx'
|
||||
import { dinnerBody } from './test/dinners.ts'
|
||||
import { defaultWeek } from './plan/week.ts'
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
@@ -32,10 +31,7 @@ function renderAppAt(path: string, location = fakeLocation(path)) {
|
||||
const SCREENS = {
|
||||
'/onboarding': true,
|
||||
'/plan': true,
|
||||
'/plan/shopping': true,
|
||||
'/plan/shopping/archive': true,
|
||||
'/discover': true,
|
||||
'/cook-from-pantry': true,
|
||||
'/settings': true,
|
||||
'/settings/ai': true,
|
||||
'/settings/household': true,
|
||||
@@ -109,41 +105,6 @@ describe('App routes', () => {
|
||||
expect(await screen.findByText('••••••••3f9a')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/plan/shopping renders the Supply run inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
const week = defaultWeek(HOUSEHOLD_SETTINGS, new Date())
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
[`/api/v1/shopping/${week}`]: () =>
|
||||
jsonResponse(200, {
|
||||
weekStart: week,
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 1,
|
||||
groups: [{ dinner: { id: 'd1', name: 'Beef tacos' }, date: week, day: 'Monday', items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }] }],
|
||||
}),
|
||||
})
|
||||
renderAt('/plan/shopping')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('/plan/shopping/archive renders the Supply archive inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
...SIGNED_IN,
|
||||
'/api/v1/shopping/archive?by=week': () => jsonResponse(200, { by: 'week', periods: [] }),
|
||||
})
|
||||
renderAt('/plan/shopping/archive')
|
||||
const heading = await screen.findByRole('heading', { level: 1, name: 'Supply archive' })
|
||||
expect(screen.getByRole('main')).toContainElement(heading)
|
||||
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('/discover renders the Side quests deck inside the shell', async () => {
|
||||
mockViewport(390)
|
||||
stubFetchByPath({
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Route, Routes } from 'react-router'
|
||||
import { ApiProvider, createQueryClient, type ApiClient } from './api/index.ts'
|
||||
import { AuthBootstrap } from './AuthBootstrap.tsx'
|
||||
import { DiscoverPage } from './discover/DiscoverPage.tsx'
|
||||
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
|
||||
import { ONBOARDING_PATH, OnboardingGate } from './onboarding/OnboardingGate.tsx'
|
||||
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
|
||||
import { PlaceholderPage } from './pages/PlaceholderPage.tsx'
|
||||
@@ -17,9 +16,6 @@ import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
|
||||
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
|
||||
import { SettingsPage } from './settings/SettingsPage.tsx'
|
||||
import { AppShell } from './shell/AppShell.tsx'
|
||||
import { SupplyRunSync } from './shopping/api.ts'
|
||||
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
|
||||
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
|
||||
|
||||
// Every route the app serves, with the heading its placeholder page shows.
|
||||
export const routes = [
|
||||
@@ -43,10 +39,7 @@ export const routes = [
|
||||
// The screens built so far; every other route shows its placeholder page.
|
||||
const screens: Partial<Record<(typeof routes)[number]['path'], ReactNode>> = {
|
||||
'/plan': <PlanPage />,
|
||||
'/plan/shopping': <SupplyRunPage />,
|
||||
'/plan/shopping/archive': <SupplyArchivePage />,
|
||||
'/discover': <DiscoverPage />,
|
||||
'/cook-from-pantry': <CookFromPantryPage />,
|
||||
'/settings': <SettingsPage />,
|
||||
'/settings/ai': <AISettingsPage />,
|
||||
'/settings/household': <HouseholdSettingsPage />,
|
||||
@@ -93,7 +86,6 @@ export default function App({ api, serviceWorker }: AppProps) {
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthBootstrap>
|
||||
<SupplyRunSync />
|
||||
<AppRoutes />
|
||||
</AuthBootstrap>
|
||||
<OfflinePill />
|
||||
|
||||
@@ -20,7 +20,7 @@ export const meQueryKey = [ME_PATH] as const
|
||||
export function AuthBootstrap({ children }: { children: ReactNode }) {
|
||||
const api = useApi()
|
||||
const forbidden = useSyncExternalStore(api.subscribe, api.forbidden)
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: () => api.loadMe(), staleTime: Infinity, networkMode: 'always' })
|
||||
const me = useQuery({ queryKey: meQueryKey, queryFn: api.loadMe, staleTime: Infinity, networkMode: 'always' })
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const savedPages = me.isError && me.error instanceof TypeError && api.session.verified()
|
||||
const { refetch } = me
|
||||
|
||||
@@ -125,66 +125,6 @@ describe('apiFetch', () => {
|
||||
expect(api.forbidden()).toBe(false)
|
||||
})
|
||||
|
||||
it('shares a csrf refresh between requests, but one whose signal aborts stops waiting and the next refreshes afresh', async () => {
|
||||
// The first refresh answers only when answerFirst is called; every other
|
||||
// request answers at once.
|
||||
let answerFirst!: (response: Response) => void
|
||||
const mes: (AbortSignal | null | undefined)[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
if (String(input) === '/api/v1/me') {
|
||||
mes.push(init?.signal)
|
||||
if (mes.length === 2) return new Promise((resolve) => (answerFirst = resolve))
|
||||
return Promise.resolve(jsonResponse(200, { ...ME, csrfToken: `token-${mes.length}` }))
|
||||
}
|
||||
const token = new Headers(init?.headers).get(CSRF_HEADER)
|
||||
return Promise.resolve(token === 'token-1' ? errorResponse(403, 'csrf') : jsonResponse(201, { token }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
|
||||
const unbounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {} })
|
||||
const timedOut = new AbortController()
|
||||
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
|
||||
await vi.waitFor(() => expect(mes).toHaveLength(2))
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
// A request without a signal still waits for it, so it is not aborted.
|
||||
expect(mes[1]!.aborted).toBe(false)
|
||||
|
||||
// A later csrf 403 does not wait on the refresh still in flight.
|
||||
await expect(
|
||||
api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: new AbortController().signal }),
|
||||
).resolves.toEqual({ token: 'token-3' })
|
||||
expect(mes).toHaveLength(3)
|
||||
|
||||
answerFirst(jsonResponse(200, { ...ME, csrfToken: 'token-2' }))
|
||||
await expect(unbounded).resolves.toEqual({ token: 'token-2' })
|
||||
expect(mes).toHaveLength(3)
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('aborts a csrf refresh once the only request waiting for it aborts, without counting it as offline', async () => {
|
||||
const mes: AbortSignal[] = []
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
if (String(input) !== '/api/v1/me') return Promise.resolve(errorResponse(403, 'csrf'))
|
||||
if (mes.push(init!.signal!) === 1) return Promise.resolve(jsonResponse(200, ME))
|
||||
return new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const network = createNetworkStatus(window)
|
||||
const api = createApiClient(fakeLocation(), network, createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
|
||||
const timedOut = new AbortController()
|
||||
const bounded = api.apiFetch('/api/v1/jobs', { method: 'POST', body: {}, signal: timedOut.signal })
|
||||
await vi.waitFor(() => expect(mes).toHaveLength(2))
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(bounded).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
expect(mes[1]!.aborted).toBe(true)
|
||||
expect(network.offline()).toBe(false)
|
||||
})
|
||||
|
||||
it('marks any other 403 as not in this household and notifies subscribers', async () => {
|
||||
const fetchMock = stubFetch(errorResponse(403, 'forbidden'))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
@@ -213,18 +153,6 @@ describe('apiFetch', () => {
|
||||
})
|
||||
|
||||
describe('loadMe', () => {
|
||||
it('aborts /me with its signal', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((_, init) => new Promise((_, reject) => init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason as Error)))),
|
||||
)
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
const timedOut = new AbortController()
|
||||
const me = api.loadMe(timedOut.signal)
|
||||
timedOut.abort(new DOMException('no answer in time', 'TimeoutError'))
|
||||
await expect(me).rejects.toMatchObject({ name: 'TimeoutError' })
|
||||
})
|
||||
|
||||
it('rejects a /me response without a CSRF token', async () => {
|
||||
stubFetch(jsonResponse(200, { ...ME, csrfToken: '' }))
|
||||
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
|
||||
+15
-59
@@ -41,10 +41,6 @@ export type ApiRequest = {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
// Sent as JSON. A GET must not have one.
|
||||
body?: unknown
|
||||
// Aborts the request, including the /me refresh a csrf 403 makes it wait
|
||||
// for; it then rejects with the signal's reason. No request has a deadline
|
||||
// unless its caller aborts it.
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ApiRead is a GET's decoded response body, and whether the service worker
|
||||
@@ -59,9 +55,8 @@ export type ApiClient = {
|
||||
// apiRead is apiFetch's GET that also says whether the body is the service
|
||||
// worker's cached copy rather than the server's answer.
|
||||
apiRead<T>(path: string): Promise<ApiRead<T>>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token; signal aborts
|
||||
// it, as it does an apiFetch.
|
||||
loadMe(signal?: AbortSignal): Promise<Me>
|
||||
// loadMe fetches GET /api/v1/me and caches its CSRF token.
|
||||
loadMe(): Promise<Me>
|
||||
// forbidden reports whether a request was refused because the signed-in
|
||||
// account is not in this household; subscribe is notified when it changes.
|
||||
forbidden(): boolean
|
||||
@@ -75,20 +70,15 @@ export type ApiClient = {
|
||||
session: VerifiedSession
|
||||
}
|
||||
|
||||
// Refresh is the /me request refreshMe shares: controller aborts it, waiting
|
||||
// counts the requests with a signal still waiting for it, and unbounded says
|
||||
// whether one without a signal is.
|
||||
type Refresh = { me: Promise<Me>; controller: AbortController; waiting: number; unbounded: boolean }
|
||||
|
||||
// createApiClient builds the app's API client. location is window.location in
|
||||
// the app: a 401 sends it to /auth/login with the current path to return to.
|
||||
export function createApiClient(location: ApiLocation, network: NetworkStatus, session: VerifiedSession): ApiClient {
|
||||
let csrfToken: string | null = null
|
||||
let refreshing: Refresh | null = null
|
||||
let refreshing: Promise<Me> | null = null
|
||||
let forbidden = false
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
async function send(method: string, path: string, body: unknown, signal: AbortSignal | undefined): Promise<Response> {
|
||||
async function send(method: string, path: string, body: unknown): Promise<Response> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (method !== 'GET') {
|
||||
@@ -107,7 +97,7 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
}
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json, signal })
|
||||
response = await fetch(path, { method, headers, credentials: 'same-origin', body: json })
|
||||
} catch (error) {
|
||||
// fetch rejects with a TypeError when the network fails; an abort is a
|
||||
// DOMException and says nothing about the network.
|
||||
@@ -122,46 +112,12 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
}
|
||||
|
||||
// refreshMe shares one in-flight /me request between every request that
|
||||
// got a csrf 403 at the same time. A request whose signal aborts stops
|
||||
// waiting for it at once, and it is no longer shared: the next csrf 403
|
||||
// starts a fresh /me rather than wait on one that may never answer. Once
|
||||
// every request with a signal waiting for it has stopped, and none without
|
||||
// one waits, it is aborted.
|
||||
function refreshMe(signal: AbortSignal | undefined): Promise<Me> {
|
||||
if (refreshing === null) {
|
||||
const controller = new AbortController()
|
||||
const refresh: Refresh = { me: loadMe(controller.signal), controller, waiting: 0, unbounded: false }
|
||||
const done = () => {
|
||||
if (refreshing === refresh) refreshing = null
|
||||
}
|
||||
refresh.me.then(done, done)
|
||||
refreshing = refresh
|
||||
}
|
||||
const refresh = refreshing
|
||||
if (signal === undefined) {
|
||||
refresh.unbounded = true
|
||||
return refresh.me
|
||||
}
|
||||
refresh.waiting++
|
||||
return new Promise((resolve, reject) => {
|
||||
const stop = () => {
|
||||
if (refreshing === refresh) refreshing = null
|
||||
if (--refresh.waiting === 0 && !refresh.unbounded) refresh.controller.abort(signal.reason)
|
||||
reject(signal.reason as Error)
|
||||
}
|
||||
if (signal.aborted) return stop()
|
||||
signal.addEventListener('abort', stop, { once: true })
|
||||
refresh.me.then(
|
||||
(me) => {
|
||||
signal.removeEventListener('abort', stop)
|
||||
resolve(me)
|
||||
},
|
||||
(error: unknown) => {
|
||||
signal.removeEventListener('abort', stop)
|
||||
reject(error as Error)
|
||||
},
|
||||
)
|
||||
// got a csrf 403 at the same time.
|
||||
function refreshMe(): Promise<Me> {
|
||||
refreshing ??= loadMe().finally(() => {
|
||||
refreshing = null
|
||||
})
|
||||
return refreshing
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, request: ApiRequest = {}): Promise<T> {
|
||||
@@ -182,14 +138,14 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
if (method === 'GET' && request.body !== undefined) {
|
||||
throw new Error(`GET ${path}: a GET request cannot carry a body`)
|
||||
}
|
||||
let response = await send(method, url, request.body, request.signal)
|
||||
let response = await send(method, url, request.body)
|
||||
if (response.status === 403) {
|
||||
const error = await parseForbidden(method, path, response)
|
||||
// Only a non-GET carries the token, so only it can be refused for it.
|
||||
if (error.code !== 'csrf' || method === 'GET') throw error
|
||||
// The session's token changed: refresh it once and retry once.
|
||||
await refreshMe(request.signal)
|
||||
response = await send(method, url, request.body, request.signal)
|
||||
await refreshMe()
|
||||
response = await send(method, url, request.body)
|
||||
if (response.status === 403) throw await parseForbidden(method, path, response)
|
||||
}
|
||||
return { body: await decode<T>(method, path, response), cached: response.headers.has(cacheFallbackHeader) }
|
||||
@@ -220,8 +176,8 @@ export function createApiClient(location: ApiLocation, network: NetworkStatus, s
|
||||
return (await response.json()) as T
|
||||
}
|
||||
|
||||
async function loadMe(signal?: AbortSignal): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH, { signal })
|
||||
async function loadMe(): Promise<Me> {
|
||||
const me = await apiFetch<unknown>(ME_PATH)
|
||||
if (
|
||||
!isRecord(me) ||
|
||||
typeof me.name !== 'string' ||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
/* Cook from what I have, handoff mock 1i. */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
max-width: 560px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* The ingredient chip field: chips, then the trailing input, wrapping. */
|
||||
.field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 26px;
|
||||
background: var(--color-surface);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.field:focus-within {
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: contents;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.chip {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.entry {
|
||||
flex: 1 1 120px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 8px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Any time | Quick only shares the row with Find ideas, as in the mock. */
|
||||
.when {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.when > button {
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.find {
|
||||
padding: 0 18px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 14px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 17px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* An action done: what it did, in place of its button. */
|
||||
.done {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 44px;
|
||||
padding: 0 4px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.failure {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.noPlan {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px 10px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* "Plan this week": a 44 px tall grape link to the Draft board. */
|
||||
.planLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 44px;
|
||||
font-weight: 800;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.planLink:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.skeletonCard {
|
||||
height: 128px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
@@ -1,512 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
const FIND = 'POST /api/v1/ideas/from-ingredients'
|
||||
|
||||
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
|
||||
|
||||
// idea is a grounded idea as the job's result carries it.
|
||||
function idea(name: string, summary: string, minutes: number | null = 30) {
|
||||
return {
|
||||
name,
|
||||
uses: INGREDIENTS,
|
||||
missing: ['1 lime'],
|
||||
summary,
|
||||
method: 'stovetop',
|
||||
minutes_total: minutes,
|
||||
difficulty: 'easy',
|
||||
quick: minutes !== null && minutes <= 30,
|
||||
recipe_id: `r-${name}`,
|
||||
photo_url: '',
|
||||
source_url: 'https://recipes.example/x',
|
||||
site_name: 'Recipes',
|
||||
}
|
||||
}
|
||||
|
||||
// The ideas of mock 1i.
|
||||
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
|
||||
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
|
||||
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
|
||||
|
||||
type JobBody = { status: string; result: unknown; error: string | null }
|
||||
|
||||
// running is the ideas_from_ingredients job's state at stage with ideas.
|
||||
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
|
||||
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
|
||||
}
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
|
||||
// dinner on Thursday.
|
||||
function tonightPlan() {
|
||||
return {
|
||||
id: 'plan-1',
|
||||
week_start: '2026-10-05',
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: DAYS.map((date, i) => ({
|
||||
date,
|
||||
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
|
||||
is_new: i === 3,
|
||||
start_by: null,
|
||||
})),
|
||||
updated_at: '2026-10-08T15:00:00Z',
|
||||
}
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me; any
|
||||
// other request fails the test. jobs holds each job's state, which every
|
||||
// poll of it answers, so a test moves a job on by changing it.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const jobs: Record<string, JobBody> = {}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
|
||||
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
|
||||
if (job !== null && !Object.hasOwn(responders, key)) {
|
||||
const id = job[1]!
|
||||
const body = jobs[id]
|
||||
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
|
||||
}
|
||||
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, jobs }
|
||||
}
|
||||
|
||||
// calls are the requests sent as "METHOD path", in order.
|
||||
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
|
||||
}
|
||||
|
||||
function bodyOf(call: Parameters<typeof fetch>) {
|
||||
return JSON.parse(String(call[1]?.body)) as unknown
|
||||
}
|
||||
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/cook-from-pantry']}>
|
||||
<Routes>
|
||||
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
|
||||
<Route path="/plan" element={<p>Plan screen</p>} />
|
||||
<Route path="/" element={<p>Home screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function input() {
|
||||
return screen.getByRole('textbox', { name: 'Add ingredient' })
|
||||
}
|
||||
|
||||
function chips() {
|
||||
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
|
||||
.queryAllByRole('listitem')
|
||||
.map((item) => item.textContent)
|
||||
}
|
||||
|
||||
function findButton() {
|
||||
return screen.getByRole('button', { name: 'Find ideas' })
|
||||
}
|
||||
|
||||
// enter types each ingredient and presses Enter.
|
||||
function enter(...ingredients: string[]) {
|
||||
for (const ingredient of ingredients) {
|
||||
fireEvent.change(input(), { target: { value: ingredient } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
}
|
||||
}
|
||||
|
||||
function card(name: string) {
|
||||
return screen.getByRole('article', { name })
|
||||
}
|
||||
|
||||
const SLOW = { timeout: 3000 }
|
||||
|
||||
// searched lists the mock's ingredients, presses Find ideas and waits for
|
||||
// the job's first poll.
|
||||
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('CookFromPantryPage', () => {
|
||||
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
|
||||
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
|
||||
expect(input().closest(`.${styles.field}`)).not.toBeNull()
|
||||
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
|
||||
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
|
||||
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
|
||||
expect(findButton()).toBeDisabled()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
|
||||
expect(screen.getByText('Home screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('chip entry', () => {
|
||||
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
|
||||
enter('Chicken thighs')
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
expect(input()).toHaveValue('')
|
||||
expect(findButton()).toBeEnabled()
|
||||
|
||||
fireEvent.change(input(), { target: { value: 'Rice,' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
|
||||
expect(input()).toHaveValue('')
|
||||
|
||||
// Pasted text is split at its commas; what follows the last is still
|
||||
// being typed.
|
||||
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
|
||||
expect(input()).toHaveValue(' Gar')
|
||||
|
||||
// Enter on nothing adds nothing.
|
||||
fireEvent.change(input(), { target: { value: ' ' } })
|
||||
fireEvent.keyDown(input(), { key: 'Enter' })
|
||||
expect(chips()).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Chicken thighs', 'Rice', 'Spinach')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
|
||||
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
|
||||
expect(input()).toHaveFocus()
|
||||
|
||||
fireEvent.keyDown(input(), { key: 'Backspace' })
|
||||
expect(chips()).toEqual(['Chicken thighs'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
|
||||
expect(chips()).toEqual([])
|
||||
expect(findButton()).toBeDisabled()
|
||||
})
|
||||
|
||||
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
|
||||
stubServer({})
|
||||
await renderPage()
|
||||
enter('Rice', ' rice ')
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
|
||||
|
||||
enter('x'.repeat(81))
|
||||
expect(chips()).toEqual(['Rice'])
|
||||
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
|
||||
|
||||
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
enter('One too many')
|
||||
expect(chips()).toHaveLength(20)
|
||||
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
|
||||
})
|
||||
})
|
||||
|
||||
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('running', 'suggesting', [])
|
||||
await renderPage()
|
||||
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.change(input(), { target: { value: 'lime' } })
|
||||
fireEvent.click(findButton())
|
||||
// What is still typed is listed before the search.
|
||||
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
|
||||
|
||||
// Suggesting: five skeleton cards, no idea yet.
|
||||
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
|
||||
// Grounding: the idea grounded so far, and skeletons for the rest; its
|
||||
// actions wait for the job to end.
|
||||
jobs.j1 = state('running', 'grounding', [CURRY])
|
||||
await screen.findByRole('article', { name: CURRY.name }, SLOW)
|
||||
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
|
||||
|
||||
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
|
||||
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
|
||||
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
|
||||
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
|
||||
await screen.findByText('3 ideas', {}, SLOW)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
|
||||
const curry = within(card(CURRY.name))
|
||||
// The server's summary line, as it is.
|
||||
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
|
||||
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
|
||||
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('leaves the time off an idea whose recipe does not say it', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const fried = await screen.findByRole('article', { name: 'Fried rice' })
|
||||
expect(within(fried).queryByText(/min/)).toBeNull()
|
||||
})
|
||||
|
||||
describe('Any time | Quick only', () => {
|
||||
it('sends quick_only, and asks again when changed after a search', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
|
||||
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
|
||||
await renderPage()
|
||||
|
||||
// Before a search it only sets what Find ideas asks for.
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(0)
|
||||
|
||||
await searched(fetchMock, 'j1')
|
||||
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
|
||||
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
await screen.findByText('1 idea')
|
||||
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
|
||||
})
|
||||
|
||||
it('refuses a result that is not for the filter asked for', async () => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY], true)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Add to Quest log', () => {
|
||||
it('adds the idea and says so on its card', async () => {
|
||||
const added = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
await screen.findByText('2 ideas')
|
||||
|
||||
const bowls = within(card(BOWLS.name))
|
||||
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
|
||||
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
|
||||
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
|
||||
// The other card is untouched.
|
||||
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('shows its failure on its card and lets it be pressed again', async () => {
|
||||
let fail = true
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
|
||||
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
|
||||
|
||||
fail = false
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
|
||||
expect(curry.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cook tonight', () => {
|
||||
it("puts the idea on tonight's plan", async () => {
|
||||
const planned = Promise.withResolvers<Response>()
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
|
||||
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
|
||||
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
|
||||
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('offers Plan this week, not an error, when the week has no plan', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
const link = await curry.findByRole('link', { name: 'Plan this week' })
|
||||
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
fireEvent.click(link)
|
||||
expect(screen.getByText('Plan screen')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows any other failure on its card', async () => {
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
|
||||
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
|
||||
|
||||
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
|
||||
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
|
||||
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
|
||||
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('errors', () => {
|
||||
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
|
||||
})
|
||||
jobs.j1 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
enter(...INGREDIENTS)
|
||||
fireEvent.click(findButton())
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
|
||||
})
|
||||
|
||||
it('shows a failed job with no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['search_not_configured', 'Recipe search is not set up.'],
|
||||
['search_failed', "The recipe search couldn't be reached."],
|
||||
])('explains %s', async (code, message) => {
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, code)
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
})
|
||||
|
||||
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
|
||||
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
|
||||
expect(screen.queryAllByRole('article')).toHaveLength(0)
|
||||
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
|
||||
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
|
||||
let n = 0
|
||||
const { fetchMock, jobs } = stubServer({
|
||||
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
|
||||
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
jobs.j2 = state('succeeded', 'done', [CURRY])
|
||||
await renderPage()
|
||||
await searched(fetchMock)
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await screen.findByRole('article', { name: CURRY.name })
|
||||
expect(calls(fetchMock, FIND)).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,312 +0,0 @@
|
||||
import { Check, ChevronLeft } from 'lucide-react'
|
||||
import { useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
||||
import { Link, useNavigate } from 'react-router'
|
||||
import { ApiError, useJob } from '../api/index.ts'
|
||||
import { duration } from '../plan/PlanPage.tsx'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { Chip } from '../ui/Chip.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { TextInput } from '../ui/TextInput.tsx'
|
||||
import {
|
||||
FROM_INGREDIENTS_JOB_KIND,
|
||||
IDEAS_KEPT,
|
||||
ideasJobFailure,
|
||||
ideasJobLost,
|
||||
INGREDIENT_LIMIT,
|
||||
MAX_INGREDIENTS,
|
||||
parseIdeasResult,
|
||||
useAddIdeaToQuestLog,
|
||||
useCookIdeaTonight,
|
||||
useFindIdeas,
|
||||
type Idea,
|
||||
type IdeaRef,
|
||||
type IdeaStage,
|
||||
type Search,
|
||||
} from './api.ts'
|
||||
import styles from './CookFromPantryPage.module.css'
|
||||
|
||||
type When = 'any' | 'quick'
|
||||
|
||||
const WHEN = [
|
||||
{ value: 'any', label: 'Any time' },
|
||||
{ value: 'quick', label: 'Quick only' },
|
||||
] as const satisfies readonly { value: When; label: string }[]
|
||||
|
||||
// findingLines says what a running search is doing, by its stage.
|
||||
const findingLines: Record<Exclude<IdeaStage, 'done'>, string> = {
|
||||
suggesting: 'Thinking up ideas…',
|
||||
grounding: 'Finding recipes for them…',
|
||||
}
|
||||
|
||||
// fold is an ingredient as the chips compare it, so "Rice" repeats " rice".
|
||||
function fold(ingredient: string): string {
|
||||
return ingredient.trim().replace(/\s+/g, ' ').toLowerCase()
|
||||
}
|
||||
|
||||
// IdeaCard is one idea (mock 1i): its name and time, the server's line on
|
||||
// what it uses and what is missing, and its two actions, each showing its
|
||||
// own progress and failure. ref is the idea in its job, null while the job
|
||||
// runs: the server takes an idea only once the job has succeeded, as the
|
||||
// ideas may still be reordered until then.
|
||||
function IdeaCard({ idea, ideaRef }: { idea: Idea; ideaRef: IdeaRef | null }) {
|
||||
const questLog = useAddIdeaToQuestLog()
|
||||
const tonight = useCookIdeaTonight()
|
||||
const noPlan = tonight.error instanceof ApiError && tonight.error.code === 'no_plan'
|
||||
return (
|
||||
<Card tone="muted" className={styles.card} role="article" aria-label={idea.name}>
|
||||
<div className={styles.cardHead}>
|
||||
<h2 className={styles.name}>{idea.name}</h2>
|
||||
{idea.minutes_total !== null && <span className={styles.time}>{duration(idea.minutes_total)}</span>}
|
||||
</div>
|
||||
<p className={styles.summary}>{idea.summary}</p>
|
||||
<div className={styles.actions}>
|
||||
{tonight.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> On tonight's plan
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || tonight.isPending}
|
||||
onClick={() => tonight.mutate(ideaRef!)}
|
||||
>
|
||||
{tonight.isPending ? 'Planning…' : 'Cook tonight'}
|
||||
</Button>
|
||||
)}
|
||||
{questLog.isSuccess ? (
|
||||
<span className={styles.done} role="status">
|
||||
<Icon icon={Check} /> In the Quest log
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
disabled={ideaRef === null || questLog.isPending}
|
||||
onClick={() => questLog.mutate(ideaRef!)}
|
||||
>
|
||||
{questLog.isPending ? 'Adding…' : 'Add to Quest log'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{noPlan ? (
|
||||
<div className={styles.noPlan} role="status">
|
||||
<span>This week has no plan yet, so there's no tonight to put it on.</span>
|
||||
<Link to="/plan" className={styles.planLink}>
|
||||
Plan this week
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
tonight.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't put it on tonight's plan. {tonight.error.message}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
{questLog.isError && (
|
||||
<p className={styles.failure} role="alert">
|
||||
Couldn't add it to the Quest log. {questLog.error.message}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// Skeletons stand in for the count ideas still to come.
|
||||
function Skeletons({ count }: { count: number }) {
|
||||
return Array.from({ length: count }, (_, i) => <Skeleton key={i} data-testid="skeleton-card" className={styles.skeletonCard} />)
|
||||
}
|
||||
|
||||
// CookFromPantryPage is mock 1i, Cook from what I have: the ingredients on
|
||||
// hand as chips (Enter or a comma adds one), Any time | Quick only, and
|
||||
// Find ideas, which starts an ideas_from_ingredients job and shows its
|
||||
// ideas as the server grounds them, skeleton cards holding the places of
|
||||
// those still to come. Changing Any time | Quick only after a search asks
|
||||
// again: the server, not the screen, says what is quick.
|
||||
export function CookFromPantryPage() {
|
||||
const navigate = useNavigate()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [ingredients, setIngredients] = useState<string[]>([])
|
||||
const [draft, setDraft] = useState('')
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
const [when, setWhen] = useState<When>('any')
|
||||
// The search last asked for, which Retry asks again, and its job.
|
||||
const [asked, setAsked] = useState<Search | null>(null)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
const findIdeas = useFindIdeas()
|
||||
const job = useJob<unknown>(FROM_INGREDIENTS_JOB_KIND, jobId)
|
||||
|
||||
// add lists entries after the chips there are, saying why it leaves out
|
||||
// any it cannot list: the server would refuse a blank, overlong or
|
||||
// repeated ingredient, or one past the most it takes.
|
||||
function add(list: string[], entries: string[]): string[] {
|
||||
const next = [...list]
|
||||
for (const entry of entries.map((e) => e.trim().replace(/\s+/g, ' '))) {
|
||||
if (entry === '') continue
|
||||
if (next.some((ingredient) => fold(ingredient) === fold(entry))) {
|
||||
setNotice(`${entry} is listed already.`)
|
||||
} else if (entry.length > INGREDIENT_LIMIT) {
|
||||
setNotice(`An ingredient can be at most ${INGREDIENT_LIMIT} characters.`)
|
||||
} else if (next.length === MAX_INGREDIENTS) {
|
||||
setNotice(`List at most ${MAX_INGREDIENTS} ingredients.`)
|
||||
} else {
|
||||
next.push(entry)
|
||||
}
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function onDraftChange(value: string) {
|
||||
setNotice(null)
|
||||
// A comma ends an ingredient, also within pasted text.
|
||||
const parts = value.split(',')
|
||||
const rest = parts.pop()!
|
||||
if (parts.length > 0) setIngredients(add(ingredients, parts))
|
||||
setDraft(rest)
|
||||
}
|
||||
|
||||
function onDraftKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
setNotice(null)
|
||||
setIngredients(add(ingredients, [draft]))
|
||||
setDraft('')
|
||||
} else if (event.key === 'Backspace' && draft === '' && ingredients.length > 0) {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.slice(0, -1))
|
||||
}
|
||||
}
|
||||
|
||||
// search asks for ideas. Only the latest search's job is followed: mutate
|
||||
// calls back for the latest call only.
|
||||
function search(next: Search) {
|
||||
setAsked(next)
|
||||
setJobId(null)
|
||||
findIdeas.mutate(next, { onSuccess: setJobId })
|
||||
}
|
||||
|
||||
// find lists what is still typed, then searches the chips.
|
||||
function find() {
|
||||
setNotice(null)
|
||||
const list = add(ingredients, [draft])
|
||||
setIngredients(list)
|
||||
setDraft('')
|
||||
if (list.length > 0) search({ ingredients: list, quickOnly: when === 'quick' })
|
||||
}
|
||||
|
||||
function onWhenChange(value: When) {
|
||||
setWhen(value)
|
||||
if (asked !== null && ingredients.length > 0) search({ ingredients, quickOnly: value === 'quick' })
|
||||
}
|
||||
|
||||
const retry = () => search(asked!)
|
||||
|
||||
// The job's result as far as it has gone, or why it cannot be read.
|
||||
const found = useMemo(() => {
|
||||
if (job.result === null || asked === null) return { result: null, error: null }
|
||||
try {
|
||||
return { result: parseIdeasResult(`GET /api/v1/jobs/${jobId}`, asked.quickOnly, job.result), error: null }
|
||||
} catch (error) {
|
||||
return { result: null, error: error as Error }
|
||||
}
|
||||
}, [job.result, jobId, asked])
|
||||
|
||||
let results = null
|
||||
if (findIdeas.isPending) {
|
||||
results = (
|
||||
<section aria-busy="true" aria-label="Ideas loading" className={styles.results}>
|
||||
<p className={styles.count}>{findingLines.suggesting}</p>
|
||||
<Skeletons count={IDEAS_KEPT} />
|
||||
</section>
|
||||
)
|
||||
} else if (findIdeas.isError) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't start looking for ideas. {findIdeas.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null && job.status === 'failed') {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't find ideas. {ideasJobFailure(job.error!.message)}</ErrorBanner>
|
||||
} else if (found.error !== null) {
|
||||
results = <ErrorBanner onRetry={retry}>Couldn't read the ideas. {found.error.message}</ErrorBanner>
|
||||
} else if (jobId !== null) {
|
||||
const ideas = found.result?.ideas ?? []
|
||||
const done = job.status === 'succeeded'
|
||||
// A job not found will not be found by asking again: Retry searches
|
||||
// again. Any other failed poll is asked again.
|
||||
const lost = ideasJobLost(job.error)
|
||||
results = (
|
||||
<>
|
||||
{job.error !== null && (
|
||||
<ErrorBanner onRetry={lost ? retry : job.poll}>Couldn't check on the ideas. {job.error.message}</ErrorBanner>
|
||||
)}
|
||||
<section aria-busy={!done} aria-label="Ideas" className={styles.results}>
|
||||
<p className={styles.count}>
|
||||
{done
|
||||
? `${ideas.length} ${ideas.length === 1 ? 'idea' : 'ideas'}`
|
||||
: findingLines[found.result?.stage === 'grounding' ? 'grounding' : 'suggesting']}
|
||||
</p>
|
||||
{ideas.map((idea, index) => (
|
||||
<IdeaCard key={`${jobId}:${index}`} idea={idea} ideaRef={done ? { jobId, index } : null} />
|
||||
))}
|
||||
{!done && <Skeletons count={Math.max(IDEAS_KEPT - ideas.length, 0)} />}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<IconButton label="Back to Home" onClick={() => void navigate('/')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
<h1 className={styles.title}>Cook from what I have</h1>
|
||||
</header>
|
||||
{/* A tap anywhere on the field types in it. */}
|
||||
<div className={styles.field} onClick={() => inputRef.current?.focus()}>
|
||||
<ul className={styles.chips} aria-label="Ingredients on hand">
|
||||
{ingredients.map((ingredient, i) => (
|
||||
<li key={ingredient}>
|
||||
<Chip
|
||||
label={ingredient}
|
||||
className={styles.chip}
|
||||
onRemove={() => {
|
||||
setNotice(null)
|
||||
setIngredients(ingredients.filter((_, j) => j !== i))
|
||||
inputRef.current?.focus()
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className={styles.entry}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
label="Add ingredient"
|
||||
labelHidden
|
||||
placeholder="Add ingredient…"
|
||||
enterKeyHint="enter"
|
||||
autoComplete="off"
|
||||
className={styles.input}
|
||||
value={draft}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onKeyDown={onDraftKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{notice !== null && (
|
||||
<p className={styles.notice} role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className={styles.controls}>
|
||||
<Segmented label="Which dinners" options={WHEN} value={when} onValueChange={onWhenChange} className={styles.when} />
|
||||
<Button variant="dark" className={styles.find} disabled={ingredients.length < 1 || findIdeas.isPending} onClick={find}>
|
||||
Find ideas
|
||||
</Button>
|
||||
</div>
|
||||
{results}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiError, useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { parseDinner } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { parsePlan, planKey } from '../plan/api.ts'
|
||||
|
||||
export const IDEAS_PATH = '/api/v1/ideas'
|
||||
export const FROM_INGREDIENTS_PATH = `${IDEAS_PATH}/from-ingredients`
|
||||
|
||||
// FROM_INGREDIENTS_JOB_KIND is the kind of the job finding ideas from the
|
||||
// ingredients on hand.
|
||||
export const FROM_INGREDIENTS_JOB_KIND = 'ideas_from_ingredients'
|
||||
|
||||
// IDEAS_KEPT is the most ideas a job keeps (skills.IdeasKept), so the most
|
||||
// cards a search fills.
|
||||
export const IDEAS_KEPT = 5
|
||||
|
||||
// MAX_INGREDIENTS is the most ingredients the household may list, and
|
||||
// INGREDIENT_LIMIT the most characters one may have
|
||||
// (skills.MaxIngredients, skills.IngredientLimit).
|
||||
export const MAX_INGREDIENTS = 20
|
||||
export const INGREDIENT_LIMIT = 80
|
||||
|
||||
// IDEA_STAGES are a job's stages: asking the AI for ideas, grounding them
|
||||
// in recipes, and done.
|
||||
export const IDEA_STAGES = ['suggesting', 'grounding', 'done'] as const
|
||||
export type IdeaStage = (typeof IDEA_STAGES)[number]
|
||||
|
||||
// Idea is a grounded idea as a result card shows it. summary is the
|
||||
// server's "Uses all 5 · missing: 1 lime" line, shown as it is;
|
||||
// minutes_total is the grounded recipe's, null when it does not say.
|
||||
export type Idea = {
|
||||
name: string
|
||||
uses: string[]
|
||||
missing: string[]
|
||||
summary: string
|
||||
minutes_total: number | null
|
||||
quick: boolean
|
||||
}
|
||||
|
||||
// IdeasResult is an ideas_from_ingredients job's result, published as it
|
||||
// goes: the ideas grounded so far, in the order the server shows them.
|
||||
export type IdeasResult = {
|
||||
stage: IdeaStage
|
||||
ingredients: string[]
|
||||
quick_only: boolean
|
||||
ideas: Idea[]
|
||||
dropped: number
|
||||
}
|
||||
|
||||
// IDEAS_JOB_FAILURES says why an ideas job failed, by its failure code.
|
||||
// no_ideas_found is no fault to fix, but the search still came back empty.
|
||||
const IDEAS_JOB_FAILURES: Record<string, string> = {
|
||||
no_ideas_found: 'No ideas came back with a recipe to match.',
|
||||
ai_failed: "The AI assistant couldn't come up with ideas.",
|
||||
search_not_configured: 'Recipe search is not set up. An admin can set it up in Settings.',
|
||||
search_failed: "The recipe search couldn't be reached.",
|
||||
internal_error: 'Something went wrong on the server.',
|
||||
}
|
||||
|
||||
// ideasJobFailure says why an ideas job failed; an unknown code is shown
|
||||
// as it is.
|
||||
export function ideasJobFailure(code: string): string {
|
||||
return IDEAS_JOB_FAILURES[code] ?? code
|
||||
}
|
||||
|
||||
// ideasJobLost says a poll of an ideas job failed because the server no
|
||||
// longer has the job (404), so asking for it again will not find it: the
|
||||
// search must be started again.
|
||||
export function ideasJobLost(error: Error | null): boolean {
|
||||
return error instanceof ApiError && error.status === 404
|
||||
}
|
||||
|
||||
// Search is what Find ideas asks for.
|
||||
export type Search = { ingredients: string[]; quickOnly: boolean }
|
||||
|
||||
function isStrings(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === 'string')
|
||||
}
|
||||
|
||||
function parseIdea(where: string, body: unknown): Idea {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.name !== 'string' ||
|
||||
!isStrings(body.uses) ||
|
||||
!isStrings(body.missing) ||
|
||||
typeof body.summary !== 'string' ||
|
||||
typeof body.quick !== 'boolean' ||
|
||||
(body.minutes_total !== null && !Number.isInteger(body.minutes_total))
|
||||
) {
|
||||
throw new Error(`${where}: result has an idea that is not a grounded idea`)
|
||||
}
|
||||
return {
|
||||
name: body.name,
|
||||
uses: body.uses,
|
||||
missing: body.missing,
|
||||
summary: body.summary,
|
||||
minutes_total: body.minutes_total as number | null,
|
||||
quick: body.quick,
|
||||
}
|
||||
}
|
||||
|
||||
// parseIdeasResult checks an ideas_from_ingredients job's result, which
|
||||
// useJob passes on unread, answers the search asked for.
|
||||
export function parseIdeasResult(where: string, quickOnly: boolean, body: unknown): IdeasResult {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.stage !== 'string' ||
|
||||
!(IDEA_STAGES as readonly string[]).includes(body.stage) ||
|
||||
!isStrings(body.ingredients) ||
|
||||
body.quick_only !== quickOnly ||
|
||||
!Array.isArray(body.ideas) ||
|
||||
typeof body.dropped !== 'number'
|
||||
) {
|
||||
throw new Error(`${where}: result is not ideas from ingredients`)
|
||||
}
|
||||
return {
|
||||
stage: body.stage as IdeaStage,
|
||||
ingredients: body.ingredients,
|
||||
quick_only: quickOnly,
|
||||
ideas: body.ideas.map((idea) => parseIdea(where, idea)),
|
||||
dropped: body.dropped,
|
||||
}
|
||||
}
|
||||
|
||||
// useFindIdeas starts finding ideas from the ingredients on hand (POST
|
||||
// /api/v1/ideas/from-ingredients) and resolves with the id of the job,
|
||||
// which GET /api/v1/jobs/{id} reports on.
|
||||
export function useFindIdeas() {
|
||||
const { apiFetch } = useApi()
|
||||
return useMutation({
|
||||
mutationFn: async ({ ingredients, quickOnly }: Search): Promise<string> => {
|
||||
const body = await apiFetch<unknown>(FROM_INGREDIENTS_PATH, {
|
||||
method: 'POST',
|
||||
body: { ingredients, quick_only: quickOnly },
|
||||
})
|
||||
if (!isRecord(body) || typeof body.jobId !== 'string') throw new Error(`POST ${FROM_INGREDIENTS_PATH}: response has no jobId`)
|
||||
return body.jobId
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// IdeaRef is idea index of the succeeded ideas job jobId.
|
||||
export type IdeaRef = { jobId: string; index: number }
|
||||
|
||||
function ideaPath({ jobId, index }: IdeaRef, action: 'quest-log' | 'cook-tonight'): string {
|
||||
return `${IDEAS_PATH}/${encodeURIComponent(jobId)}/items/${index}/${action}`
|
||||
}
|
||||
|
||||
// useAddIdeaToQuestLog adds an idea to the Quest log (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/quest-log) and resolves with the
|
||||
// dinner's name. Asking again answers the same dinner. The Quest log is
|
||||
// read again after it.
|
||||
export function useAddIdeaToQuestLog() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
const path = ideaPath(idea, 'quest-log')
|
||||
return parseDinner(`POST ${path}`, await apiFetch<unknown>(path, { method: 'POST' })).name
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
}
|
||||
|
||||
// useCookIdeaTonight puts an idea on today's plan (POST
|
||||
// /api/v1/ideas/{jobId}/items/{i}/cook-tonight) and resolves with the
|
||||
// dinner's name. The plan it answers with is cached as its week's plan,
|
||||
// and the Quest log is read again. With no plan for the current week the
|
||||
// server answers 409 no_plan, the mutation's ApiError, and changes nothing.
|
||||
export function useCookIdeaTonight() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (idea: IdeaRef): Promise<string> => {
|
||||
const path = ideaPath(idea, 'cook-tonight')
|
||||
const where = `POST ${path}`
|
||||
const body = await apiFetch<unknown>(path, { method: 'POST' })
|
||||
if (!isRecord(body) || !isRecord(body.plan) || typeof body.plan.week_start !== 'string') {
|
||||
throw new Error(`${where}: response has no plan`)
|
||||
}
|
||||
const dinner = parseDinner(where, body.dinner)
|
||||
const plan = parsePlan(where, body.plan.week_start, body.plan, false)
|
||||
queryClient.setQueryData(planKey(plan.week_start), plan)
|
||||
return dinner.name
|
||||
},
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
}
|
||||
@@ -258,8 +258,8 @@
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* An action with nothing behind it yet (Send to Google Chat until Feature
|
||||
19): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
/* An action with nothing behind it yet (the shopping list until Feature
|
||||
17): it keeps focus, so hovering, tapping or focusing it shows the
|
||||
tooltip. */
|
||||
.unavailable {
|
||||
position: relative;
|
||||
@@ -414,31 +414,6 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* What became of the week's Supply run after a push. */
|
||||
.pushed {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* The way to the week's Supply run under the Draft board on mobile. */
|
||||
.shoppingLink {
|
||||
display: inline-flex;
|
||||
align-self: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* A push failure's way to the Google Calendar settings. */
|
||||
.settingsLink {
|
||||
color: inherit;
|
||||
|
||||
+17
-442
@@ -1,10 +1,9 @@
|
||||
import { QueryClientProvider, type QueryClient } from '@tanstack/react-query'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { Link, MemoryRouter, Route, Routes, useLocation } from 'react-router'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
@@ -53,33 +52,9 @@ function planBody(status: string, names = NAMES) {
|
||||
}
|
||||
|
||||
// syncedNow is the plan of the week of Oct 5 as a push answers it: synced
|
||||
// at the tests' noon, with what became of the week's Supply run, made
|
||||
// unless told otherwise.
|
||||
function syncedNow(supplyRun: { status: string; error: { code: string; message: string; retryable: boolean } | null } = GENERATED) {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z', supply_run: supplyRun }
|
||||
}
|
||||
|
||||
// The Supply run outcomes a push answers with, as the server sends them.
|
||||
const GENERATED = { status: 'generated', error: null }
|
||||
const ARCHIVED = {
|
||||
status: 'archived',
|
||||
error: { code: 'archived', message: 'the Supply run is in the Supply archive', retryable: false },
|
||||
}
|
||||
const PLAN_CHANGED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'plan_changed',
|
||||
message: 'the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again',
|
||||
retryable: true,
|
||||
},
|
||||
}
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
// at the tests' noon.
|
||||
function syncedNow() {
|
||||
return { ...planBody('synced'), calendar_synced_at: '2026-10-07T16:00:00Z' }
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
@@ -125,12 +100,8 @@ const lists = {
|
||||
jsonResponse(200, { dinners: [dinner('d0', 'Beef tacos', 'easy', 30), dinner('r1', 'Fish tacos', 'medium', 35)] }),
|
||||
}
|
||||
|
||||
// SUPPLY_RUN is the Supply run of a week, as GET /api/v1/shopping/{weekStart}.
|
||||
const SUPPLY_RUN = /^GET \/api\/v1\/shopping\/\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings, and no Supply run (404) for a week given none;
|
||||
// any other request fails the test.
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
@@ -139,7 +110,7 @@ function stubServer(responders: Record<string, Responder>) {
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : SUPPLY_RUN.test(key) ? () => errorResponse(404, 'not_found') : undefined
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
@@ -160,67 +131,21 @@ function planReads(fetchMock: Mock<typeof fetch>) {
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Plan page, over queryClient, with shopping as the Supply run screen.
|
||||
async function renderPage(queryClient: QueryClient = createQueryClient(), shopping = <Opened />) {
|
||||
// the Plan page.
|
||||
async function renderPage() {
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={['/plan']}>
|
||||
<Routes>
|
||||
<Route path="/plan" element={<PlanPage />} />
|
||||
<Route path="/plan/shopping" element={shopping} />
|
||||
</Routes>
|
||||
<PlanPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Opened stands in for the screen a link opened, saying where it is, with
|
||||
// a link back to the Plan.
|
||||
function Opened() {
|
||||
const { pathname, search } = useLocation()
|
||||
return (
|
||||
<>
|
||||
<p>Opened {pathname + search}</p>
|
||||
<Link to="/plan">Back to the Plan</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// pushesKept counts the pushes of the week of Oct 5 the mutation cache of
|
||||
// queryClient keeps.
|
||||
function pushesKept(queryClient: QueryClient) {
|
||||
return queryClient.getMutationCache().findAll({ mutationKey: ['plans', '2026-10-05', 'push'] }).length
|
||||
}
|
||||
|
||||
// SupplyRunScreen is the Supply run screen, with a link back to the Plan.
|
||||
function SupplyRunScreen() {
|
||||
return (
|
||||
<>
|
||||
<SupplyRunPage />
|
||||
<Link to="/plan">Back to the Plan</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// supplyRun is the Supply run of the week of Oct 5 with needed of its 32
|
||||
// items still to buy.
|
||||
function supplyRun(needed: number) {
|
||||
return {
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: needed,
|
||||
total_count: 32,
|
||||
groups: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
@@ -467,236 +392,6 @@ describe('PlanPage', () => {
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
})
|
||||
|
||||
it("reads the week's Supply run again once a push has made it, and says it was made", async () => {
|
||||
let run = () => errorResponse(404, 'not_found')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => {
|
||||
run = () => jsonResponse(200, supplyRun(28))
|
||||
return jsonResponse(200, syncedNow(GENERATED))
|
||||
},
|
||||
'GET /api/v1/shopping/2026-10-05': () => run(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
// The list made is read at once, not after the cached 404 goes stale.
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says a Supply run in the Supply archive was left as it was', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(ARCHIVED)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent("This week's Supply run is in the Supply archive, so it was left as it was.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says no Supply run was made when the plan changed after it was pushed, reads the plan again, and pushes again on Retry', async () => {
|
||||
let plan = planBody('draft')
|
||||
let push = () => {
|
||||
plan = planBody('changed', ['Lasagna', ...NAMES.slice(1)])
|
||||
return jsonResponse(200, syncedNow(PLAN_CHANGED))
|
||||
}
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, plan),
|
||||
[`POST ${PUSH}`]: () => push(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent(
|
||||
'Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push it again.',
|
||||
)
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
// The plan edited meanwhile is the one shown, not the one pushed.
|
||||
expect(await screen.findByText('Lasagna')).toBeInTheDocument()
|
||||
expect(screen.getByText('Changes not pushed to Google Calendar')).toBeInTheDocument()
|
||||
expect(planReads(fetchMock)).toBe(2)
|
||||
|
||||
push = () => jsonResponse(200, syncedNow(GENERATED))
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([`${PUSH} undefined`, `${PUSH} undefined`])
|
||||
})
|
||||
|
||||
it('says the Supply run could not be made after the push, and makes the shopping list again', async () => {
|
||||
let generate = () => errorResponse(500, 'internal')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => generate(),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
// Calendar has the plan, but the push is not reported a quiet success.
|
||||
expect(await screen.findByText('Synced to Google Calendar just now')).toBeInTheDocument()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(
|
||||
await screen.findByText("Pushed to Calendar, but this week's Supply run couldn't be made. Couldn't make the shopping list. internal message"),
|
||||
).toBeInTheDocument()
|
||||
|
||||
generate = () => jsonResponse(200, { ...supplyRun(28), skipped: [] })
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(posts(fetchMock)).toEqual([
|
||||
`${PUSH} undefined`,
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
'/api/v1/shopping/2026-10-05/generate undefined',
|
||||
])
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made, after leaving the Plan and coming back', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
// A Supply run never made is still said after leaving the Plan and
|
||||
// coming back.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('status')).toHaveTextContent("Made this week's Supply run from the plan.")
|
||||
|
||||
// Once made, it is not said again to be unmade.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list · 28' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says the Supply run could not be made when making the shopping list fails after leaving the week', async () => {
|
||||
const made = deferred()
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
|
||||
|
||||
// The week is left while the shopping list is being made, which fails.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
await act(async () => made.send(errorResponse(500, 'internal')))
|
||||
|
||||
// Coming back, the Supply run is still said to be unmade, and can be
|
||||
// made again.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made after leaving the week', async () => {
|
||||
const made = deferred()
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => made.response,
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
fireEvent.click(within(await screen.findByRole('alert')).getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Making the shopping list…')
|
||||
|
||||
// The push is kept while the week is left, until the list is made: it
|
||||
// is then settled, not merely swept with the week.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
expect(pushesKept(queryClient)).toBe(1)
|
||||
await act(async () => made.send(jsonResponse(200, { ...supplyRun(28), skipped: [] })))
|
||||
expect(pushesKept(queryClient)).toBe(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('no longer says the Supply run could not be made once the shopping list is made on the Supply run screen', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => jsonResponse(200, { ...supplyRun(28), skipped: [] }),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
// The list is made on the Supply run screen, not from the Plan's alert.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument())
|
||||
expect(pushesKept(queryClient)).toBe(0)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 28' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Make the shopping list' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('still says the Supply run could not be made when making it on the Supply run screen fails', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`POST ${PUSH}`]: () => jsonResponse(200, syncedNow(SUPPLY_RUN_FAILED)),
|
||||
'POST /api/v1/shopping/2026-10-05/generate': () => errorResponse(500, 'internal'),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient, <SupplyRunScreen />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Looks good — push to Calendar' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Shopping list' }))
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list.")
|
||||
expect(pushesKept(queryClient)).toBe(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Back to the Plan' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Pushed to Calendar, but this week's Supply run couldn't be made.")
|
||||
expect(within(alert).getByRole('button', { name: 'Make the shopping list' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('pushes nothing again until the changed plan is read and shown', async () => {
|
||||
let read: () => Response | Promise<Response> = () => jsonResponse(200, planBody('draft'))
|
||||
let push = () => errorResponse(409, 'plan_changed')
|
||||
@@ -1034,85 +729,6 @@ describe('PlanPage', () => {
|
||||
expect(screen.queryByTestId('skeleton-tile')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("links to the week's Supply run under the Draft board, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(19)),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).color).toBe('var(--color-accent-700)')
|
||||
// It follows the push button and the sync status.
|
||||
const push = screen.getByRole('button', { name: 'Looks good — push to Calendar' })
|
||||
expect(push.compareDocumentPosition(shopping) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("shows a failed read of the week's shopping list with Retry, the link still opening the Supply run", async () => {
|
||||
let failing = true
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
// Retry reads it again.
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 19' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input) === '/api/v1/shopping/2026-10-05')).toHaveLength(2)
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("drops the count when reading the week's shopping list again fails, showing the failure with Retry", async () => {
|
||||
let failing = false
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(19))),
|
||||
})
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
|
||||
|
||||
// Coming back to the page once the Supply run is stale reads it again,
|
||||
// and that read fails.
|
||||
failing = true
|
||||
vi.setSystemTime(new Date('2026-10-07T16:01:00Z'))
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(screen.queryByRole('link', { name: 'Shopping list · 19' })).not.toBeInTheDocument()
|
||||
// Retry reads it again, and the count is back.
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByRole('link', { name: 'Shopping list · 19' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('links to the Supply run with no count while the week has no shopping list', async () => {
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Beef tacos')).toBeInTheDocument()
|
||||
await waitFor(() => expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain('/api/v1/shopping/2026-10-05'))
|
||||
const shopping = screen.getByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
// No list is not a failure.
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('moves between weeks with the previous and next week buttons', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
@@ -1410,11 +1026,11 @@ describe.each([
|
||||
expect(screen.getByRole('button', { name: 'Next week' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'This week' }) !== null).toBe(layout === 'desktop')
|
||||
|
||||
// Actions: the shopping list, with no count while the week has no
|
||||
// Supply run, and Rebuild week outlined; Push to Calendar primary.
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list' })
|
||||
expect(shopping).not.toHaveAttribute('aria-disabled')
|
||||
expect(shopping).not.toHaveAccessibleDescription()
|
||||
// Actions: the shopping list unavailable, saying why; Rebuild week
|
||||
// outlined and Push to Calendar primary, both working.
|
||||
const shopping = screen.getByRole('button', { name: 'Shopping list' })
|
||||
expect(shopping).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(shopping).toHaveAccessibleDescription('The shopping list arrives once the Supply run is built.')
|
||||
const push = screen.getByRole('button', { name: 'Push to Calendar' })
|
||||
expect(push).toBeEnabled()
|
||||
expect(push).not.toHaveAttribute('aria-disabled')
|
||||
@@ -1422,6 +1038,7 @@ describe.each([
|
||||
const rebuild = screen.getByRole('button', { name: 'Rebuild week' })
|
||||
expect(rebuild).toBeEnabled()
|
||||
expect(declaredStyle(rebuild).borderColor).toBe('var(--color-neutral-300)')
|
||||
fireEvent.click(shopping)
|
||||
expect(posts(fetchMock)).toEqual([])
|
||||
// Mobile's own pieces are not shown.
|
||||
expect(screen.queryByRole('button', { name: 'Looks good — push to Calendar' })).not.toBeInTheDocument()
|
||||
@@ -1481,48 +1098,6 @@ describe.each([
|
||||
expect(status).toHaveTextContent('Synced to Google Calendar 1 hr ago')
|
||||
})
|
||||
|
||||
it("shows a failed read of the week's shopping list with Retry, the action still opening the Supply run", async () => {
|
||||
let failing = true
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
'GET /api/v1/shopping/2026-10-05': () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun(28))),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load this week's shopping list. internal message")
|
||||
expect(screen.getByText('Spaghetti bolognese')).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
failing = false
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
fireEvent.click(shopping)
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("opens the week's Supply run from the Shopping list action, saying how many items are still to buy", async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, supplyRun(28)),
|
||||
...lists,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const shopping = await screen.findByRole('link', { name: 'Shopping list · 28' })
|
||||
expect(shopping).toHaveAttribute('href', '/plan/shopping?week=2026-10-05')
|
||||
expect(declaredStyle(shopping).borderColor).toBe('var(--color-neutral-300)')
|
||||
// Another week's action opens that week's, which has no list yet.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Oct 12 – 18' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Shopping list' })).toHaveAttribute('href', '/plan/shopping?week=2026-10-12')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
fireEvent.click(await screen.findByRole('link', { name: 'Shopping list · 28' }))
|
||||
expect(await screen.findByText('Opened /plan/shopping?week=2026-10-05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says changes to a pushed plan are not on Google Calendar', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('changed')), ...lists })
|
||||
await renderPage()
|
||||
|
||||
+17
-89
@@ -7,10 +7,8 @@ import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button, type ButtonVariant } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
@@ -31,7 +29,6 @@ import {
|
||||
type PlanDay,
|
||||
type PlanDinner,
|
||||
type PlanStatus,
|
||||
type PushedSupplyRun,
|
||||
type Swap,
|
||||
} from './api.ts'
|
||||
import { DragAnnouncer, DROP_DATE, DropBoard, PlanDragProvider, useDropTarget } from './drag.tsx'
|
||||
@@ -82,6 +79,10 @@ const settingsFailures: Record<string, { reason: string; fix: string }> = {
|
||||
},
|
||||
}
|
||||
|
||||
// SHOPPING_HINT is why the shopping list is unavailable.
|
||||
// TODO(Feature 17): open the Supply run, with its count, once it is built.
|
||||
const SHOPPING_HINT = 'The shopping list arrives once the Supply run is built.'
|
||||
|
||||
// syncStatuses is the plan manager's line on whether the plan is on Calendar.
|
||||
const syncStatuses: Record<PlanStatus, string> = {
|
||||
draft: 'Not pushed to Google Calendar yet',
|
||||
@@ -89,13 +90,6 @@ const syncStatuses: Record<PlanStatus, string> = {
|
||||
changed: 'Changes not pushed to Google Calendar',
|
||||
}
|
||||
|
||||
// supplyRunOutcomes says what became of the week's Supply run after a push
|
||||
// that made it or left it as it was.
|
||||
const supplyRunOutcomes: Record<Exclude<PushedSupplyRun['status'], 'failed'>, string> = {
|
||||
generated: "Made this week's Supply run from the plan.",
|
||||
archived: "This week's Supply run is in the Supply archive, so it was left as it was.",
|
||||
}
|
||||
|
||||
// duration is minutes as the plan manager's cards say it: "45 min", "6 hr",
|
||||
// "1 hr 30 min", with no-break spaces so a narrow card wraps the line
|
||||
// between the method and the duration, never inside the duration.
|
||||
@@ -201,22 +195,6 @@ function DayColumn({ day, timezone, swapping, onSwap }: { day: PlanDay; timezone
|
||||
)
|
||||
}
|
||||
|
||||
// ShoppingList opens the week's Supply run: an outline action in the plan
|
||||
// manager's header, a grape link under the Draft board on mobile. Once the
|
||||
// week has a Supply run it says how many items are still to buy, "Shopping
|
||||
// list · 28"; until then, while that is read, or when reading it failed, it
|
||||
// says no count, and the Supply run screen offers to make the list or shows
|
||||
// the failure.
|
||||
function ShoppingList({ weekStart, neededCount, layout }: { weekStart: string; neededCount: number | undefined; layout: Breakpoint }) {
|
||||
const className = layout === 'mobile' ? styles.shoppingLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
|
||||
return (
|
||||
<Link to={supplyRunScreen(weekStart)} className={className}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
{neededCount !== undefined ? `Shopping list · ${neededCount}` : 'Shopping list'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonTiles stands in for the seven days while the plan loads or the AI
|
||||
// drafts it, as two columns of tiles on mobile and seven columns wider.
|
||||
function SkeletonTiles({ label, layout, children }: { label: string; layout: Breakpoint; children?: string }) {
|
||||
@@ -239,16 +217,13 @@ function SkeletonTiles({ label, layout, children }: { label: string; layout: Bre
|
||||
// the plan is read again. Rebuilding a plan pushed to Calendar asks first,
|
||||
// and sends confirm. A failed draft is shown with Retry; no plan is made up.
|
||||
//
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Shopping list,
|
||||
// Rebuild week and Push to Calendar are actions in the header, over the
|
||||
// plan's sync status and the 7-column grid. On mobile the Shopping list
|
||||
// link sits under the push button.
|
||||
// From tablet up it is the plan manager (mocks 2c, 2d): Rebuild week, Push to
|
||||
// Calendar and the unavailable Shopping list are actions in the header,
|
||||
// over the plan's sync status and the 7-column grid.
|
||||
//
|
||||
// Push to Calendar, on either layout, sends the plan shown and shows it as
|
||||
// synced once the server answers; a failure says why, linking to the Google
|
||||
// Calendar settings when only they can fix it. What became of the week's
|
||||
// Supply run is said too: made, left as it was in the Supply archive, or
|
||||
// not made, with the way to make it.
|
||||
// Calendar settings when only they can fix it.
|
||||
function WeekBoard({
|
||||
weekStart,
|
||||
timezone,
|
||||
@@ -268,9 +243,6 @@ function WeekBoard({
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const swap = useSwapDay(weekStart)
|
||||
const push = usePushToCalendar(weekStart)
|
||||
const run = useSupplyRun(weekStart)
|
||||
// Makes the week's Supply run again after a push could not make it.
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
const pushing = push.pending
|
||||
// The date of the day being swapped, while the swap sheet is open.
|
||||
const [swappingDate, setSwappingDate] = useState<string | null>(null)
|
||||
@@ -340,7 +312,6 @@ function WeekBoard({
|
||||
if (!canPush) return
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
push.mutate()
|
||||
}
|
||||
|
||||
@@ -352,7 +323,6 @@ function WeekBoard({
|
||||
setJobId(null)
|
||||
swap.dismiss()
|
||||
push.reset()
|
||||
generate.reset()
|
||||
mutate(
|
||||
{ weekStart, confirm },
|
||||
{
|
||||
@@ -371,33 +341,8 @@ function WeekBoard({
|
||||
else start(false)
|
||||
}
|
||||
|
||||
const retryPush = cached ? () => void plan.refetch() : pushToCalendar
|
||||
// makeShoppingList makes the week's Supply run again after a push could
|
||||
// not make it. Once made, here or on the Supply run screen, the push is
|
||||
// settled (useGenerateSupplyRun). A failure is the mutation's state, which
|
||||
// generate.error reports.
|
||||
function makeShoppingList() {
|
||||
generate.mutate()
|
||||
}
|
||||
// A push's Supply run made again on the Plan stands for the push's, which
|
||||
// is settled.
|
||||
const supplyRun: PushedSupplyRun | null = generate.isSuccess ? { status: 'generated', error: null } : push.supplyRun
|
||||
const failure = push.error !== null ? (
|
||||
<PushFailure error={push.error} cached={cached} onRetry={retryPush} />
|
||||
) : supplyRun?.status === 'failed' ? (
|
||||
supplyRun.error.code === 'plan_changed' ? (
|
||||
<ErrorBanner onRetry={retryPush}>
|
||||
Pushed to Calendar, but the plan changed before its Supply run was made, so no shopping list was made from it; push
|
||||
it again.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={generate.isPending ? undefined : makeShoppingList} retryLabel="Make the shopping list">
|
||||
Pushed to Calendar, but this week's Supply run couldn't be made.
|
||||
{generate.isPending
|
||||
? ' Making the shopping list…'
|
||||
: generate.isError && ` Couldn't make the shopping list. ${generate.error.message}`}
|
||||
</ErrorBanner>
|
||||
)
|
||||
<PushFailure error={push.error} cached={cached} onRetry={cached ? () => void plan.refetch() : pushToCalendar} />
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
@@ -415,27 +360,11 @@ function WeekBoard({
|
||||
) : (
|
||||
<ErrorBanner onRetry={job.poll}>Couldn't check on the draft. {job.error.message}</ErrorBanner>
|
||||
)
|
||||
const pushed = supplyRun !== null && supplyRun.status !== 'failed' && (
|
||||
<p role="status" className={styles.pushed}>
|
||||
{supplyRunOutcomes[supplyRun.status]}
|
||||
</p>
|
||||
)
|
||||
// A failed read of the week's Supply run is shown, with Retry, after the
|
||||
// Draft board's Shopping list link or under the plan manager's board; the
|
||||
// Shopping list action still opens the Supply run. The count is that of a
|
||||
// successful read only: a failed refetch keeps the earlier Supply run in
|
||||
// run.data, and its count is then no longer current.
|
||||
const neededCount = run.isSuccess ? run.data?.needed_count : undefined
|
||||
const shoppingList = <ShoppingList weekStart={weekStart} neededCount={neededCount} layout={layout} />
|
||||
const supplyRunFailure = run.isError && (
|
||||
<ErrorBanner onRetry={() => void run.refetch()}>Couldn't load this week's shopping list. {run.error.message}</ErrorBanner>
|
||||
)
|
||||
|
||||
// swapIn sends the swap chosen in the sheet and closes it.
|
||||
function swapIn(chosen: Swap) {
|
||||
setSwappingDate(null)
|
||||
push.reset()
|
||||
generate.reset()
|
||||
swap.mutate(chosen)
|
||||
}
|
||||
// dropIn swaps in a dinner dragged onto a day. A drag is not picked up,
|
||||
@@ -533,8 +462,6 @@ function WeekBoard({
|
||||
{pushing ? 'Pushing to Calendar…' : 'Looks good — push to Calendar'}
|
||||
</Button>
|
||||
<SyncStatus plan={plan.data} className={styles.mobileStatus} />
|
||||
{shoppingList}
|
||||
{supplyRunFailure}
|
||||
{sheet}
|
||||
</>
|
||||
)
|
||||
@@ -566,7 +493,10 @@ function WeekBoard({
|
||||
<div className={styles.actionBar}>
|
||||
{plan.data ? <SyncStatus plan={plan.data} /> : <span className={styles.status} />}
|
||||
<div className={styles.actions}>
|
||||
{shoppingList}
|
||||
<Unavailable hint={SHOPPING_HINT} variant="outline" below className={styles.action}>
|
||||
<Icon icon={ShoppingCart} size="inline" />
|
||||
Shopping list
|
||||
</Unavailable>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={styles.action}
|
||||
@@ -607,8 +537,6 @@ function WeekBoard({
|
||||
{header}
|
||||
{body}
|
||||
{failure}
|
||||
{pushed}
|
||||
{layout !== 'mobile' && supplyRunFailure}
|
||||
<Confirm
|
||||
open={confirming}
|
||||
title="Rebuild this week?"
|
||||
@@ -672,10 +600,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
)
|
||||
}
|
||||
|
||||
// Unavailable is an action with nothing behind it yet (Send to Google
|
||||
// Chat): it stays focusable, so a tap or focus shows the tooltip saying
|
||||
// why, above it or, at the top of the page, below.
|
||||
export function Unavailable({
|
||||
// Unavailable is an action with nothing behind it yet (the shopping list):
|
||||
// it stays focusable, so a tap or focus shows the tooltip saying why, above
|
||||
// it or, at the top of the page, below.
|
||||
function Unavailable({
|
||||
hint,
|
||||
variant = 'primary',
|
||||
below = false,
|
||||
|
||||
@@ -10,21 +10,6 @@ import { planKey, usePlan, usePushToCalendar, useSwapDay, type Plan, type PushTo
|
||||
|
||||
const WEEK = '2026-10-05'
|
||||
const PLAN = `/api/v1/plans/${WEEK}`
|
||||
|
||||
// GENERATED is a push answer's Supply run when the push made it.
|
||||
const GENERATED = { supply_run: { status: 'generated', error: null } }
|
||||
|
||||
// SUPPLY_RUN_FAILED is a push answer's Supply run when it could not be made.
|
||||
const SUPPLY_RUN_FAILED = {
|
||||
supply_run: {
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'supply_run_failed',
|
||||
message: 'the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run',
|
||||
retryable: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const MONDAY = `${PLAN}/days/2026-10-05`
|
||||
const FRIDAY = `${PLAN}/days/2026-10-09`
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
@@ -333,7 +318,7 @@ describe('week mutations', () => {
|
||||
|
||||
// The push succeeds while the third week is shown: it is left behind,
|
||||
// and the week's pending swap is not.
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT, ...GENERATED })))
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...planBody(), week_start: NEXT })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual([]))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap pending', 'swap success'])
|
||||
|
||||
@@ -353,43 +338,6 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => jsonResponse(200, { ...planBody(), week_start: NEXT }),
|
||||
[`POST /api/v1/plans/${NEXT}/push-calendar`]: () => nextPush.reply,
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), push: usePushToCalendar(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: NEXT },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// The push is pushed to Calendar while another week is shown, but its
|
||||
// Supply run is not made: it stays, though it succeeded.
|
||||
act(() => hook.result.current.push.mutate())
|
||||
hook.rerender({ week: WEEK })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
const synced = { ...planBody(), week_start: NEXT, status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => nextPush.send(jsonResponse(200, { ...synced, ...SUPPLY_RUN_FAILED })))
|
||||
await waitFor(() => expect(weekMutations(queryClient, NEXT)).toEqual(['push success']))
|
||||
|
||||
// Coming back mounts the week's board again, as the Plan's does.
|
||||
hook.unmount()
|
||||
const back = renderHook(() => usePushToCalendar(NEXT), { wrapper })
|
||||
expect(back.result.current.error).toBeNull()
|
||||
expect(back.result.current.supplyRun).toEqual(SUPPLY_RUN_FAILED.supply_run)
|
||||
})
|
||||
|
||||
it('leaves behind what settles once Plan is left for another route, and keeps what is pending', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
@@ -446,7 +394,7 @@ describe('week mutations', () => {
|
||||
// The push succeeds: the plan as synced is recorded, and the push is
|
||||
// left behind.
|
||||
const synced = { ...planBody(), status: 'synced', calendar_synced_at: '2026-10-08T06:00:00Z' }
|
||||
await act(async () => push.send(jsonResponse(200, { ...synced, ...GENERATED })))
|
||||
await act(async () => push.send(jsonResponse(200, synced)))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual([]))
|
||||
expect(queryClient.getQueryData<Plan>(planKey(WEEK))).toMatchObject({ status: 'synced', calendar_synced_at: new Date('2026-10-08T06:00:00Z') })
|
||||
})
|
||||
|
||||
+12
-123
@@ -5,7 +5,6 @@ import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
import { QUEST_LOG_KEY } from '../discover/api.ts'
|
||||
import { noteGoogleFailure } from '../settings/calendarApi.ts'
|
||||
import { supplyRunKey } from '../shopping/api.ts'
|
||||
|
||||
export const PLANS_PATH = '/api/v1/plans'
|
||||
|
||||
@@ -93,9 +92,7 @@ function syncedAt(where: string, value: unknown): Date | null {
|
||||
return at
|
||||
}
|
||||
|
||||
// parsePlan checks a body the plans API sends is the plan of the week
|
||||
// starting on weekStart; cached says the service worker answered it.
|
||||
export function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
function parsePlan(where: string, weekStart: string, body: unknown, cached: boolean): Plan {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
@@ -117,56 +114,6 @@ export function parsePlan(where: string, weekStart: string, body: unknown, cache
|
||||
}
|
||||
}
|
||||
|
||||
// SupplyRunError is the API error a Push to Calendar's Supply run carries
|
||||
// when it was not generated.
|
||||
export type SupplyRunError<C extends string> = { code: C; message: string; retryable: boolean }
|
||||
|
||||
// PushedSupplyRun says whether a Push to Calendar made the week's Supply
|
||||
// run: generated when it did; archived when the week's Supply run is in the
|
||||
// Supply archive and was left as it was; or failed when it was not made.
|
||||
// A failed one's error is plan_changed when the plan was edited after it
|
||||
// was pushed, and pushing again makes it, or supply_run_failed when it
|
||||
// could not be made, and making the shopping list again does.
|
||||
export type PushedSupplyRun =
|
||||
| { status: 'generated'; error: null }
|
||||
| { status: 'archived'; error: SupplyRunError<'archived'> }
|
||||
| { status: 'failed'; error: SupplyRunError<'plan_changed' | 'supply_run_failed'> }
|
||||
|
||||
// PushedPlan is the POST /api/v1/plans/{weekStart}/push-calendar answer:
|
||||
// the plan as synced, and what became of the week's Supply run.
|
||||
export type PushedPlan = { plan: Plan; supply_run: PushedSupplyRun }
|
||||
|
||||
const PUSHED_SUPPLY_RUN_STATUSES = ['generated', 'archived', 'failed'] as const
|
||||
const FAILED_SUPPLY_RUN_CODES = ['plan_changed', 'supply_run_failed'] as const
|
||||
|
||||
function parseSupplyRunError<C extends string>(where: string, codes: readonly C[], body: unknown): SupplyRunError<C> {
|
||||
if (!isRecord(body) || typeof body.message !== 'string' || typeof body.retryable !== 'boolean') {
|
||||
throw new Error(`${where}: supply_run has an error that is not an API error`)
|
||||
}
|
||||
return { code: oneOf(where, 'supply_run.error.code', codes, body.code), message: body.message, retryable: body.retryable }
|
||||
}
|
||||
|
||||
function parsePushedSupplyRun(where: string, body: unknown): PushedSupplyRun {
|
||||
if (!isRecord(body)) throw new Error(`${where}: response has no supply_run`)
|
||||
const status = oneOf(where, 'supply_run.status', PUSHED_SUPPLY_RUN_STATUSES, body.status)
|
||||
switch (status) {
|
||||
case 'generated':
|
||||
if (body.error !== null) throw new Error(`${where}: a generated supply_run has an error`)
|
||||
return { status, error: null }
|
||||
case 'archived':
|
||||
return { status, error: parseSupplyRunError(where, ['archived'] as const, body.error) }
|
||||
case 'failed':
|
||||
return { status, error: parseSupplyRunError(where, FAILED_SUPPLY_RUN_CODES, body.error) }
|
||||
}
|
||||
}
|
||||
|
||||
function parsePushedPlan(where: string, weekStart: string, body: unknown): PushedPlan {
|
||||
return {
|
||||
plan: parsePlan(where, weekStart, body, false),
|
||||
supply_run: parsePushedSupplyRun(where, isRecord(body) ? body.supply_run : undefined),
|
||||
}
|
||||
}
|
||||
|
||||
// parseDraftResult checks a draft_week job's result, which useJob passes on
|
||||
// unread, is the draft of the week starting on weekStart.
|
||||
export function parseDraftResult(where: string, weekStart: string, body: unknown): DraftResult {
|
||||
@@ -231,19 +178,6 @@ function sameDay(a: PlanDay, b: PlanDay): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// failed says whether a swap or push failed: a push whose Supply run was
|
||||
// not made failed too, though Calendar has the plan.
|
||||
function failed(mutation: Mutation): boolean {
|
||||
return mutation.state.status === 'error' || unmade(mutation)
|
||||
}
|
||||
|
||||
// unmade says whether a push succeeded but its Supply run was not made: the
|
||||
// week is on Calendar with no shopping list until one is made.
|
||||
function unmade(mutation: Mutation): boolean {
|
||||
const data = mutation.state.data as PushedPlan | undefined
|
||||
return mutation.state.status === 'success' && weekMutation(mutation)?.kind === 'push' && data?.supply_run.status === 'failed'
|
||||
}
|
||||
|
||||
// weekMutation is the kind and week of a swap or push of a week the mutation
|
||||
// cache keeps for it, or null for any other mutation.
|
||||
function weekMutation(mutation: Mutation): { kind: 'swap' | 'push'; week: string } | null {
|
||||
@@ -269,13 +203,9 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||
// earlier pending one must see, stay across leaving its week and coming
|
||||
// back. When one settles, the same is done, except that a week's latest,
|
||||
// when it failed, stays until a week is next shown, so that coming straight
|
||||
// back to it shows the failure. A week's latest push whose Supply run was
|
||||
// not made stays whatever is shown: the week has no shopping list until one
|
||||
// is made, which settles the push, or a push sent since replaces it, and
|
||||
// making it may still be under way when the week is left. The cache holds
|
||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
||||
// week whose Supply run was not made, and one failure of each week whose
|
||||
// last pending one settled since a week was last shown.
|
||||
// back to it shows the failure. The cache holds at most the weeks shown's,
|
||||
// those of weeks left pending, and one failure of each week whose last
|
||||
// pending one settled since a week was last shown.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
@@ -295,7 +225,7 @@ function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
if (settled && mutation === latest && mutation.state.status === 'error') continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -433,12 +363,10 @@ function pushKey(weekStart: string) {
|
||||
}
|
||||
|
||||
// PushToCalendar is the push of one week, as the mutation cache holds it:
|
||||
// whether it is pending, why the latest failed, if it failed, and what
|
||||
// became of the week's Supply run, if it succeeded.
|
||||
// whether it is pending, and why the latest failed, if it failed.
|
||||
export type PushToCalendar = {
|
||||
pending: boolean
|
||||
error: Error | null
|
||||
supplyRun: PushedSupplyRun | null
|
||||
mutate: () => void
|
||||
reset: () => void
|
||||
}
|
||||
@@ -452,19 +380,10 @@ export type PushToCalendar = {
|
||||
// the one shown. One Google refused the grant for reads the Google Calendar
|
||||
// connection again, which is now reconnect required.
|
||||
//
|
||||
// Once pushed, the server makes the week's Supply run from the plan pushed
|
||||
// and answers with its outcome, which supplyRun reports: a failed one is
|
||||
// never a quiet success. The week's Supply run is read again, whatever the
|
||||
// outcome, so the list shown is the server's. A Supply run not made because
|
||||
// the plan was edited after it was pushed reads the plan again too, as a
|
||||
// push refused for that does.
|
||||
//
|
||||
// The week's pushes live in the mutation cache, not in the component, as its
|
||||
// swaps do, so leaving the week and coming back keeps one pending. reset
|
||||
// leaves settled pushes behind; it is refused while one is pending. A push
|
||||
// whose Supply run was not made is left behind once the list is made
|
||||
// (settleUnmadePushes). Settled pushes of a week no longer shown are left
|
||||
// behind too, but for the latest when its Supply run was not made
|
||||
// leaves settled pushes behind; it is refused while one is pending. Settled
|
||||
// pushes of a week no longer shown are left behind too
|
||||
// (keepWeekMutations).
|
||||
export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
const { apiFetch } = useApi()
|
||||
@@ -476,17 +395,12 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
// The week's pushes, in the order sent.
|
||||
const pushes = useMutationState({
|
||||
filters: { mutationKey },
|
||||
select: (mutation) => ({
|
||||
mutationId: mutation.mutationId,
|
||||
status: mutation.state.status,
|
||||
error: mutation.state.error,
|
||||
data: mutation.state.data as PushedPlan | undefined,
|
||||
}),
|
||||
select: (mutation) => ({ status: mutation.state.status, error: mutation.state.error }),
|
||||
})
|
||||
const latest = pushes.at(-1)
|
||||
|
||||
function mutate() {
|
||||
const mutation = mutations.build<PushedPlan, Error, void, unknown>(queryClient, {
|
||||
const mutation = mutations.build<Plan, Error, void, unknown>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until reset or settled in a week not shown (keepWeekMutations):
|
||||
// a push pending in a week left must still be found when it is shown
|
||||
@@ -494,15 +408,9 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
gcTime: Infinity,
|
||||
mutationFn: async () => {
|
||||
const path = `${planPath(weekStart)}/push-calendar`
|
||||
return parsePushedPlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: async ({ plan, supply_run }) => {
|
||||
queryClient.setQueryData<Plan>(key, plan)
|
||||
void queryClient.invalidateQueries({ queryKey: supplyRunKey(weekStart) })
|
||||
if (supply_run.error?.code === 'plan_changed') {
|
||||
await queryClient.invalidateQueries({ queryKey: key, refetchType: 'all' })
|
||||
}
|
||||
return parsePlan(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }), false)
|
||||
},
|
||||
onSuccess: (plan) => queryClient.setQueryData<Plan>(key, plan),
|
||||
onError: async (error) => {
|
||||
noteGoogleFailure(queryClient, error)
|
||||
if (error instanceof ApiError && error.code === 'plan_changed') {
|
||||
@@ -525,26 +433,7 @@ export function usePushToCalendar(weekStart: string): PushToCalendar {
|
||||
return {
|
||||
pending: pushes.some((push) => push.status === 'pending'),
|
||||
error: latest?.status === 'error' ? latest.error! : null,
|
||||
supplyRun: latest?.status === 'success' ? latest.data!.supply_run : null,
|
||||
mutate,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
|
||||
// settleUnmadePushes is called as the Supply run of the week starting on
|
||||
// weekStart starts being made, from the Plan or the Supply run screen
|
||||
// (useGenerateSupplyRun). The function it answers with is called once the
|
||||
// list is made: it leaves behind the week's pushes sent before whose Supply
|
||||
// run was not made, so the Plan does not say again that it is unmade, even
|
||||
// if the Plan was left meanwhile. A push sent since is kept, and a list not
|
||||
// made leaves them as they are.
|
||||
export function settleUnmadePushes(queryClient: QueryClient, weekStart: string): () => void {
|
||||
const mutations = queryClient.getMutationCache()
|
||||
const mutationKey = pushKey(weekStart)
|
||||
const sent = new Set(mutations.findAll({ mutationKey }))
|
||||
return () => {
|
||||
for (const mutation of mutations.findAll({ mutationKey })) {
|
||||
if (sent.has(mutation) && unmade(mutation)) mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
/* Supply archive · weekly posts (mock 1m, and the right panel of 2f). */
|
||||
.archive {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
max-width: 560px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 24px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.periods {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.period {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.posts {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 16px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.post:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* The current week's post: grape-100 with a grape-300 ring. */
|
||||
.current {
|
||||
background: var(--color-accent-100);
|
||||
box-shadow: inset 0 0 0 2px var(--color-accent-300);
|
||||
}
|
||||
|
||||
/* The week label as the API writes it, capitalised here. */
|
||||
.kicker {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.current .kicker {
|
||||
color: var(--color-accent-700);
|
||||
}
|
||||
|
||||
.postTitle {
|
||||
font-family: var(--font-heading);
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-800);
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--color-bg);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.older {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.skeletonPost {
|
||||
height: 112px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
/* Desktop (mock 2f): the week's list at the left, the archive in a 340 px
|
||||
panel at the right. */
|
||||
.split {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.list {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: none;
|
||||
box-sizing: border-box;
|
||||
width: 340px;
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--color-divider);
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter, Route, Routes } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import type { ArchivePost } from './api.ts'
|
||||
import { PAGE, SupplyArchivePage } from './SupplyArchivePage.tsx'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
|
||||
const ARCHIVE = (by: string) => `/api/v1/shopping/archive?by=${by}`
|
||||
|
||||
// post is one Supply archive post as GET /api/v1/shopping/archive sends it.
|
||||
function post(weekStart: string, label: string, overrides: Partial<ArchivePost> = {}): ArchivePost {
|
||||
return {
|
||||
weekStart,
|
||||
label,
|
||||
title: null,
|
||||
dinnerCount: 7,
|
||||
itemCount: 23,
|
||||
onHandCount: 4,
|
||||
dinners: ['Beef tacos', 'Tikka masala', 'Pizza', 'Pot roast', 'Orzo', 'Chili', 'Salmon'],
|
||||
current: false,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// The posts of mock 1m: this week's titled post, then the weeks across the
|
||||
// September–October boundary and the 2025–2026 one, the last untitled.
|
||||
const WEEK_41 = post('2026-10-05', 'Week 41 · Oct 5 – 11', { title: 'Tacos, tikka & a salmon finale', current: true })
|
||||
const WEEK_40 = post('2026-09-28', 'Week 40 · Sep 28 – Oct 4', { title: 'Pizza, pot roast & the great orzo trial', itemCount: 26 })
|
||||
const WEEK_39 = post('2026-09-21', 'Week 39 · Sep 21 – 27', { title: 'Chili season opens', dinnerCount: 1, itemCount: 1, onHandCount: 0, dinners: ['Chili'] })
|
||||
const WEEK_1 = post('2025-12-29', 'Week 1 · Dec 29 – Jan 4')
|
||||
|
||||
// The archive as the server groups it: by week each period is one ISO week
|
||||
// headed by its label; by month and year a week belongs to the period of its
|
||||
// Monday, so week 40 is September's and week 1 is 2025's.
|
||||
const BY = {
|
||||
week: (
|
||||
[
|
||||
['2026-W41', WEEK_41],
|
||||
['2026-W40', WEEK_40],
|
||||
['2026-W39', WEEK_39],
|
||||
['2026-W01', WEEK_1],
|
||||
] as const
|
||||
).map(([key, p]) => ({ key, heading: p.label, posts: [p] })),
|
||||
month: [
|
||||
{ key: '2026-10', heading: 'October 2026', posts: [WEEK_41] },
|
||||
{ key: '2026-09', heading: 'September 2026', posts: [WEEK_40, WEEK_39] },
|
||||
{ key: '2025-12', heading: 'December 2025', posts: [WEEK_1] },
|
||||
],
|
||||
year: [
|
||||
{ key: '2026', heading: '2026', posts: [WEEK_41, WEEK_40, WEEK_39] },
|
||||
{ key: '2025', heading: '2025', posts: [WEEK_1] },
|
||||
],
|
||||
}
|
||||
|
||||
function archive(by: keyof typeof BY) {
|
||||
return () => jsonResponse(200, { by, periods: BY[by] })
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token any write would need, so a
|
||||
// missing PATCH is not for want of it) and renders the Supply archive at path.
|
||||
async function renderPage(path = '/plan/shopping/archive') {
|
||||
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<Routes>
|
||||
<Route path="/plan/shopping/archive" element={<SupplyArchivePage />} />
|
||||
<Route path="/plan/shopping" element={<p>Supply run screen</p>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// postCard is the post titled title, a link.
|
||||
function postCard(title: string) {
|
||||
return screen.getByText(title).closest('a')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
describe('SupplyArchivePage', () => {
|
||||
it('renders mock 1m by week: one post per week with its kicker, title, counts and dinner chips', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
expect(screen.getByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: 'Week' })).toBeChecked()
|
||||
const card = await screen.findByText('Pizza, pot roast & the great orzo trial')
|
||||
const post40 = card.closest('a')!
|
||||
expect(within(post40).getByText('Week 40 · Sep 28 – Oct 4')).toHaveClass(styles.kicker!)
|
||||
expect(post40).toHaveTextContent('7 dinners · 26 items · 4 already on hand')
|
||||
expect(within(post40).getByText('Beef tacos')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).getByText('Tikka masala')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).getByText('+5')).toHaveClass(styles.chip!)
|
||||
expect(within(post40).queryByText('Pizza')).not.toBeInTheDocument()
|
||||
expect(postCard('Chili season opens')).toHaveTextContent('1 dinner · 1 item · 0 already on hand')
|
||||
expect(within(postCard('Chili season opens')).queryByText(/^\+/)).not.toBeInTheDocument()
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/plan/shopping/archive?week=2026-10-05',
|
||||
'/plan/shopping/archive?week=2026-09-28',
|
||||
'/plan/shopping/archive?week=2026-09-21',
|
||||
'/plan/shopping/archive?week=2025-12-29',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders by week the periods and headings the server sends, as sent', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(BY.week.map((period) => period.heading))
|
||||
expect(screen.getByRole('heading', { level: 2, name: WEEK_40.label })).toHaveClass(styles.heading!)
|
||||
for (const period of BY.week) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('capitalises the week label kicker in CSS from the label as the API writes it', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(within(postCard('Chili season opens')).getByText('Week 39 · Sep 21 – 27')).toHaveClass(styles.kicker!)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Month', 'month', ['October 2026', 'September 2026', 'December 2025']],
|
||||
['Year', 'year', ['2026', '2025']],
|
||||
] as const)('groups by %s under the headings the server sends, across the month and year boundaries', async (label, by, headings) => {
|
||||
const fetchMock = stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week'), [`GET ${ARCHIVE(by)}`]: archive(by) })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: label }))
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(headings))
|
||||
expect(screen.getByRole('heading', { level: 2, name: headings[0] })).toHaveClass(styles.heading!)
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input))).toContain(ARCHIVE(by))
|
||||
for (const period of BY[by]) {
|
||||
const section = screen.getByRole('region', { name: period.heading })
|
||||
expect(within(section).getAllByRole('link').map((link) => link.getAttribute('href'))).toEqual(
|
||||
period.posts.map((p) => `/plan/shopping/archive?week=${p.weekStart}`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('highlights the current week’s post, by the API’s current, and no other', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
const current = (await screen.findByText('Tacos, tikka & a salmon finale')).closest('a')!
|
||||
expect(current).toHaveClass(styles.current!)
|
||||
expect(within(current).getByText('Week 41 · Oct 5 – 11 · Current')).toBeInTheDocument()
|
||||
for (const title of ['Pizza, pot roast & the great orzo trial', 'Chili season opens']) {
|
||||
expect(postCard(title)).not.toHaveClass(styles.current!)
|
||||
expect(postCard(title)).not.toHaveTextContent('Current')
|
||||
}
|
||||
})
|
||||
|
||||
it('follows current from the API, not today’s date', async () => {
|
||||
const past = { ...WEEK_40, current: true }
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [{ key: 'w', heading: past.label, posts: [{ ...WEEK_41, current: false }, past] }] }) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByText('Week 40 · Sep 28 – Oct 4 · Current')).toBeInTheDocument()
|
||||
expect(postCard('Tacos, tikka & a salmon finale')).not.toHaveClass(styles.current!)
|
||||
})
|
||||
|
||||
it('titles an untitled post with its week label', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('Chili season opens')
|
||||
const untitled = screen.getByRole('link', { name: /Week 1 · Dec 29 – Jan 4/ })
|
||||
const label = within(untitled).getAllByText('Week 1 · Dec 29 – Jan 4')
|
||||
expect(label.map((el) => el.className)).toEqual([styles.kicker, styles.postTitle])
|
||||
})
|
||||
|
||||
it(`shows ${PAGE} posts and "Load older" shows the next ${PAGE}, none twice and none skipped`, async () => {
|
||||
const weeks = Array.from({ length: 2 * PAGE + 3 }, (_, i) => {
|
||||
const monday = new Date(Date.UTC(2026, 9, 5 - 7 * i)).toISOString().slice(0, 10)
|
||||
return post(monday, `Week of ${monday}`, { title: `Post ${i}` })
|
||||
})
|
||||
// By month, so the cut falls inside a period and its heading is shown once.
|
||||
const periods = [
|
||||
{ key: 'a', heading: 'Newer', posts: weeks.slice(0, PAGE + 2) },
|
||||
{ key: 'b', heading: 'Older', posts: weeks.slice(PAGE + 2) },
|
||||
]
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
[`GET ${ARCHIVE('month')}`]: () => jsonResponse(200, { by: 'month', periods }),
|
||||
})
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Month' }))
|
||||
|
||||
const titles = () => screen.getAllByRole('link').map((link) => within(link).getByText(/^Post /).textContent)
|
||||
await screen.findByText('Post 0')
|
||||
expect(titles()).toEqual(weeks.slice(0, PAGE).map((p) => p.title))
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
|
||||
expect(titles()).toEqual(weeks.slice(0, 2 * PAGE).map((p) => p.title))
|
||||
expect(screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)).toEqual(['Newer', 'Older'])
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Load older' }))
|
||||
expect(titles()).toEqual(weeks.map((p) => p.title))
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers no "Load older" when every post is shown', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
await screen.findByText('Chili season opens')
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows skeleton posts while the archive loads', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => new Promise<Response>(() => {}) })
|
||||
await renderPage()
|
||||
expect(screen.getByLabelText('Supply archive loading')).toHaveAttribute('aria-busy', 'true')
|
||||
expect(screen.getAllByTestId('skeleton-post')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('says so when nothing is archived yet', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'week', periods: [] }) })
|
||||
await renderPage()
|
||||
expect(await screen.findByText('Nothing archived yet')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Load older' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reports a failed load and retries it', async () => {
|
||||
let reads = 0
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: () => (++reads === 1 ? errorResponse(500, 'internal') : archive('week')()),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load the Supply archive.")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('Chili season opens')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
})
|
||||
|
||||
it('reports a response that is not the archive by the period asked for', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: () => jsonResponse(200, { by: 'month', periods: [] }) })
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(`GET ${ARCHIVE('week')}: response is not the Supply archive by week`)
|
||||
})
|
||||
|
||||
it('goes back to the Supply run', async () => {
|
||||
stubServer({ [`GET ${ARCHIVE('week')}`]: archive('week') })
|
||||
await renderPage()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Back to the Supply run' }))
|
||||
expect(await screen.findByText('Supply run screen')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// The frozen Supply run of week 40, as GET /api/v1/shopping/{weekStart}
|
||||
// sends it.
|
||||
const FROZEN = {
|
||||
weekStart: '2026-09-28',
|
||||
label: 'Week 40 · Sep 28 – Oct 4',
|
||||
title: 'Pizza, pot roast & the great orzo trial',
|
||||
status: 'archived',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 2,
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd1', name: 'Friday pizza' },
|
||||
date: '2026-10-02',
|
||||
items: [
|
||||
{ id: 'i1', name: 'Pizza dough', qty: '2 balls', needed: true },
|
||||
{ id: 'i2', name: 'Mozzarella', qty: '1 lb', needed: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// The current week's Supply run, week 41, still current: the API's archive
|
||||
// includes it, highlighted.
|
||||
const CURRENT = {
|
||||
...FROZEN,
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: 'Tacos, tikka & a salmon finale',
|
||||
status: 'current',
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd2', name: 'Beef tacos' },
|
||||
date: '2026-10-05',
|
||||
items: [
|
||||
{ id: 'i3', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i4', name: 'Limes', qty: '3', needed: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('SupplyArchivePage frozen post', () => {
|
||||
it('opens a tapped post’s frozen list read-only, tagged Archived, sending no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByText('Pizza, pot roast & the great orzo trial'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { level: 1, name: /Shopping list/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('Pizza dough')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Pizza dough'))
|
||||
fireEvent.click(screen.getByText('Mozzarella'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
|
||||
// The list links back to the archive.
|
||||
fireEvent.click(screen.getByRole('link', { name: 'Supply archive' }))
|
||||
expect(await screen.findByRole('heading', { level: 1, name: 'Supply archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the current week’s post read-only too, tagged Archived, with no checkbox, nothing to send and no PATCH', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByText('Tacos, tikka & a salmon finale'))
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText("This week's list as it stands, read-only in the Supply archive.")).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
fireEvent.click(screen.getByText('Limes'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
})
|
||||
|
||||
it('on desktop opens the current week’s post read-only, and with no post open shows this week’s Supply run to change', async () => {
|
||||
mockViewport(1440)
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-10-05': () => jsonResponse(200, CURRENT),
|
||||
})
|
||||
const view = await renderPage('/plan/shopping/archive?week=2026-10-05')
|
||||
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('Corn tortillas')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByText('Send to Google Chat')).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Corn tortillas'))
|
||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method !== undefined && init.method !== 'GET')).toEqual([])
|
||||
view.unmount()
|
||||
|
||||
await renderPage()
|
||||
expect(await screen.findByRole('checkbox', { name: 'Corn tortillas' })).toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('on desktop (mock 2f) shows the archive as a panel beside the open post’s list', async () => {
|
||||
mockViewport(1440)
|
||||
stubServer({
|
||||
[`GET ${ARCHIVE('week')}`]: archive('week'),
|
||||
'GET /api/v1/shopping/2026-09-28': () => jsonResponse(200, FROZEN),
|
||||
})
|
||||
await renderPage('/plan/shopping/archive?week=2026-09-28')
|
||||
|
||||
const panel = screen.getByRole('complementary', { name: 'Supply archive' })
|
||||
expect(await within(panel).findByText('Chili season opens')).toBeInTheDocument()
|
||||
expect(within(panel).queryByRole('button', { name: 'Back to the Supply run' })).not.toBeInTheDocument()
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(panel).not.toContainElement(screen.getByText('Pizza dough'))
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,183 +0,0 @@
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router'
|
||||
import { useBreakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { IconButton } from '../ui/IconButton.tsx'
|
||||
import { Segmented } from '../ui/Segmented.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { useSupplyArchive, WEEK_PARAM, type ArchiveGroup, type ArchivePeriod, type ArchivePost } from './api.ts'
|
||||
import styles from './SupplyArchivePage.module.css'
|
||||
import { SUPPLY_ARCHIVE, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
// PAGE is how many posts the Supply archive shows at first, and how many
|
||||
// more each "Load older" shows.
|
||||
export const PAGE = 8
|
||||
|
||||
// CHIPS is how many of a post's dinners it names; a "+N" chip counts the rest.
|
||||
const CHIPS = 2
|
||||
|
||||
const PERIODS = [
|
||||
{ value: 'week', label: 'Week' },
|
||||
{ value: 'month', label: 'Month' },
|
||||
{ value: 'year', label: 'Year' },
|
||||
] as const satisfies readonly { value: ArchivePeriod; label: string }[]
|
||||
|
||||
// archivePost is the Supply archive screen opened on the post of the week
|
||||
// starting on Monday weekStart.
|
||||
export function archivePost(weekStart: string): string {
|
||||
return `${SUPPLY_ARCHIVE}?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`
|
||||
}
|
||||
|
||||
// PostCard is one week's Supply run (mock 1m): the week label as its kicker,
|
||||
// the AI's title — the week label while it has none — the counts and its
|
||||
// first dinners as chips. The current week's post is ringed in grape. It
|
||||
// opens the week's list, read-only.
|
||||
function PostCard({ post }: { post: ArchivePost }) {
|
||||
const rest = post.dinners.length - CHIPS
|
||||
return (
|
||||
<Link to={archivePost(post.weekStart)} className={[styles.post, post.current && styles.current].filter(Boolean).join(' ')}>
|
||||
<span className={styles.kicker}>
|
||||
{post.label}
|
||||
{post.current && ' · Current'}
|
||||
</span>
|
||||
<span className={styles.postTitle}>{post.title ?? post.label}</span>
|
||||
<span className={styles.meta}>
|
||||
{plural(post.dinnerCount, 'dinner', 'dinners')} · {plural(post.itemCount, 'item', 'items')} · {post.onHandCount} already on
|
||||
hand
|
||||
</span>
|
||||
{post.dinners.length > 0 && (
|
||||
<span className={styles.chips}>
|
||||
{post.dinners.slice(0, CHIPS).map((name, i) => (
|
||||
<span key={i} className={styles.chip}>
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
{rest > 0 && <span className={styles.chip}>+{rest}</span>}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function Posts({ posts }: { posts: ArchivePost[] }) {
|
||||
return (
|
||||
<ul className={styles.posts}>
|
||||
{posts.map((post) => (
|
||||
<li key={post.weekStart}>
|
||||
<PostCard post={post} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// shownGroups is groups cut to their first shown posts, newest first, without
|
||||
// the periods left with none.
|
||||
function shownGroups(groups: ArchiveGroup[], shown: number): ArchiveGroup[] {
|
||||
const out: ArchiveGroup[] = []
|
||||
let left = shown
|
||||
for (const group of groups) {
|
||||
if (left === 0) break
|
||||
const posts = group.posts.slice(0, left)
|
||||
out.push({ ...group, posts })
|
||||
left -= posts.length
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ArchivePosts is the Supply archive as the server groups it by by: one post
|
||||
// per week, under the Bagel heading of each period the server sends — by
|
||||
// week, the week's label. It shows PAGE posts and "Load older" shows PAGE
|
||||
// more.
|
||||
function ArchivePosts({ by }: { by: ArchivePeriod }) {
|
||||
const archive = useSupplyArchive(by)
|
||||
const [shown, setShown] = useState(PAGE)
|
||||
|
||||
if (archive.isError) {
|
||||
return <ErrorBanner onRetry={() => void archive.refetch()}>Couldn't load the Supply archive. {archive.error.message}</ErrorBanner>
|
||||
}
|
||||
if (archive.isPending) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Supply archive loading" className={styles.posts}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-post" className={styles.skeletonPost} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const total = archive.data.reduce((n, group) => n + group.posts.length, 0)
|
||||
if (total === 0) {
|
||||
return <EmptyState title="Nothing archived yet">Each week's Supply run becomes a post here once it's made.</EmptyState>
|
||||
}
|
||||
const groups = shownGroups(archive.data, shown)
|
||||
return (
|
||||
<>
|
||||
{groups.map((group) => (
|
||||
<section key={group.key} aria-label={group.heading} className={styles.period}>
|
||||
<h2 className={styles.heading}>{group.heading}</h2>
|
||||
<Posts posts={group.posts} />
|
||||
</section>
|
||||
))}
|
||||
{shown < total && (
|
||||
<Button variant="outline" className={styles.older} onClick={() => setShown(shown + PAGE)}>
|
||||
Load older
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchive is the Supply archive (mock 1m, and the right panel of 2f):
|
||||
// the Week | Month | Year control over the posts. On mobile and tablet a
|
||||
// back button returns to the Supply run.
|
||||
function SupplyArchive({ panel }: { panel: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
const [by, setBy] = useState<ArchivePeriod>('week')
|
||||
return (
|
||||
<div className={styles.archive}>
|
||||
<header className={styles.header}>
|
||||
{!panel && (
|
||||
<IconButton label="Back to the Supply run" onClick={() => void navigate('/plan/shopping')}>
|
||||
<Icon icon={ChevronLeft} size="nav" />
|
||||
</IconButton>
|
||||
)}
|
||||
<h1 className={styles.title}>Supply archive</h1>
|
||||
</header>
|
||||
<Segmented label="Group the Supply archive by" options={PERIODS} value={by} onValueChange={setBy} className={styles.periods} />
|
||||
<ArchivePosts key={by} by={by} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyArchivePage is /plan/shopping/archive. A post opens it on its week
|
||||
// (?week=), whose list the Supply run screen shows read-only and tagged
|
||||
// Archived, whatever its status: even the current week's post is only
|
||||
// viewed here, never changed. On desktop (mock 2f) the archive is a panel at
|
||||
// the right of that list, this week's editable Supply run when no post is
|
||||
// open; on mobile and tablet it is the archive or, with a post open, the
|
||||
// post's list.
|
||||
export function SupplyArchivePage() {
|
||||
const layout = useBreakpoint()
|
||||
const [params] = useSearchParams()
|
||||
if (layout === 'desktop') {
|
||||
return (
|
||||
<div className={styles.split}>
|
||||
<div className={styles.list}>
|
||||
<SupplyRunPage readOnly={params.has(WEEK_PARAM)} />
|
||||
</div>
|
||||
<aside className={styles.panel} aria-label="Supply archive">
|
||||
<SupplyArchive panel />
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return params.has(WEEK_PARAM) ? <SupplyRunPage readOnly /> : <SupplyArchive panel={false} />
|
||||
}
|
||||
@@ -1,274 +0,0 @@
|
||||
/* Supply run · shopping list (mocks 1l, 2b, 2f). */
|
||||
.page {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
max-width: 440px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 30px;
|
||||
font-weight: 400;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Still to buy / all items: at the right of the title on mobile, beside it
|
||||
from tablet up, where the actions take the right. */
|
||||
.count {
|
||||
margin: 0 0 0 auto;
|
||||
font-family: var(--font-body);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tablet .count,
|
||||
.desktop .count {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.helper {
|
||||
margin: 6px 0 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action {
|
||||
min-height: 44px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* One card per dinner: stacked on mobile, three masonry columns from
|
||||
tablet up. */
|
||||
.groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tablet .groups,
|
||||
.desktop .groups {
|
||||
display: block;
|
||||
column-count: 3;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.group {
|
||||
box-sizing: border-box;
|
||||
padding: 16px 18px 10px;
|
||||
border-radius: 24px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.tablet .group,
|
||||
.desktop .group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.groupHeader {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.dinner {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.day {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 4px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* The checkbox's label fills the row, so the whole row up to the quantity
|
||||
is the hit area. */
|
||||
.check {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.readOnly {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* An archived item's mark: the checkbox's look, not interactive. */
|
||||
.box {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
flex: none;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2px solid var(--color-neutral-400);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.item:not(.have) .box {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-family: var(--font-body);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.have .name {
|
||||
text-decoration: line-through;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
/* Toggled while the server cannot be reached: the change is saved here and
|
||||
sent once it can be. */
|
||||
.willSync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: 8px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
text-decoration: none;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.qty {
|
||||
flex: none;
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
color: var(--color-neutral-700);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.have .qty {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-2-800);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.archiveLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 44px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-accent-700);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.skeletonTitle {
|
||||
width: 220px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.skeletonGroup {
|
||||
height: 160px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.tablet .skeletonGroup,
|
||||
.desktop .skeletonGroup {
|
||||
margin-bottom: 16px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
@@ -1,491 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { sentAt, SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
|
||||
const GENERATE = `${THIS_WEEK}/generate`
|
||||
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
|
||||
|
||||
type Item = { id: string; name: string; qty: string | null; needed: boolean }
|
||||
|
||||
// The week of Oct 5's Supply run, as mock 1l starts it: Monday's tacos and
|
||||
// Wednesday's tikka masala, each with one item the household has.
|
||||
const GROUPS: { dinner: { id: string; name: string }; date: string; day: string; items: Item[] }[] = [
|
||||
{
|
||||
dinner: { id: 'd0', name: 'Beef tacos' },
|
||||
date: '2026-10-05',
|
||||
day: 'Monday',
|
||||
items: [
|
||||
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
|
||||
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
dinner: { id: 'd2', name: 'Crock pot tikka masala' },
|
||||
date: '2026-10-07',
|
||||
day: 'Wednesday',
|
||||
items: [
|
||||
{ id: 'i4', name: 'Chicken thighs', qty: '2 lb', needed: true },
|
||||
{ id: 'i5', name: 'Tikka masala paste', qty: '1 jar', needed: true },
|
||||
{ id: 'i6', name: 'Basmati rice', qty: null, needed: false },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// supplyRun is the week's Supply run as GET /api/v1/shopping/{weekStart}
|
||||
// sends it, counted from its items.
|
||||
function supplyRun(overrides: Record<string, unknown> = {}, weekStart = '2026-10-05', label = 'Week 41 · Oct 5 – 11') {
|
||||
const items = GROUPS.flatMap((group) => group.items)
|
||||
return {
|
||||
weekStart,
|
||||
label,
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
needed_count: items.filter((item) => item.needed).length,
|
||||
total_count: items.length,
|
||||
groups: GROUPS,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// deferred is a response the test sends when it chooses.
|
||||
function deferred() {
|
||||
let send!: (response: Response) => void
|
||||
const response = new Promise<Response>((resolve) => {
|
||||
send = resolve
|
||||
})
|
||||
return { response, send }
|
||||
}
|
||||
|
||||
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
|
||||
|
||||
// stubServer answers "METHOD path" requests from responders, with /me and
|
||||
// the household settings; any other request fails the test.
|
||||
function stubServer(responders: Record<string, Responder>) {
|
||||
const all: Record<string, Responder> = {
|
||||
'GET /api/v1/me': () => jsonResponse(200, ME),
|
||||
'GET /api/v1/settings/household': () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
const respond = Object.hasOwn(all, key) ? all[key] : undefined
|
||||
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
return Promise.resolve(respond(init))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
// writes lists the PATCHes and POSTs sent, as "METHOD path body".
|
||||
function writes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => init?.method === 'PATCH' || init?.method === 'POST')
|
||||
.map(([input, init]) => `${init!.method} ${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
// renderPage loads /me (for the CSRF token every write needs) and renders
|
||||
// the Supply run at path.
|
||||
async function renderPage(path = '/plan/shopping') {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
return render(
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={createQueryClient()}>
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
<SupplyRunPage />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// Wednesday Oct 7, noon in New York: before Friday's 09:00 plan_draft, so
|
||||
// the page opens on this week.
|
||||
beforeEach(() => {
|
||||
mockViewport(390)
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
function checkbox(name: string) {
|
||||
return screen.getByRole('checkbox', { name })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByText(name).closest('li')!
|
||||
}
|
||||
|
||||
describe('SupplyRunPage', () => {
|
||||
it('renders mock 1l: kicker, title, the count to buy of all items and the helper', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
const title = await screen.findByRole('heading', { level: 1, name: 'Shopping list' })
|
||||
expect(declaredStyle(title).fontFamily).toBe('var(--font-heading)')
|
||||
// The kicker is the week's dates, set in capitals by the stylesheet.
|
||||
const kicker = screen.getByText('Supply run · Oct 5 – 11')
|
||||
expect(declaredStyle(kicker).textTransform).toBe('uppercase')
|
||||
expect(declaredStyle(kicker).color).toBe('var(--color-accent-700)')
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.getByText("Uncheck anything you already have — it won't be counted.")).toBeInTheDocument()
|
||||
expect(screen.queryByText('Archived')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows one surface card per dinner with its Bagel name and day', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
const tacos = await screen.findByRole('region', { name: 'Beef tacos' })
|
||||
expect(screen.getAllByRole('region')).toHaveLength(2)
|
||||
expect(declaredStyle(tacos).borderRadius).toBe('24px')
|
||||
expect(declaredStyle(tacos).background).toBe('var(--color-surface)')
|
||||
const name = within(tacos).getByRole('heading', { level: 2, name: 'Beef tacos' })
|
||||
expect(declaredStyle(name).fontFamily).toBe('var(--font-heading)')
|
||||
expect(declaredStyle(name).fontSize).toBe('16px')
|
||||
expect(declaredStyle(within(tacos).getByText('MON')).fontSize).toBe('11px')
|
||||
expect(within(screen.getByRole('region', { name: 'Crock pot tikka masala' })).getByText('WED')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('checks the items to buy; one the household has is unchecked, struck through and "have it"', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('checkbox', { name: 'Ground beef' })).toBeChecked()
|
||||
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Ground beef')).textDecoration).toBe('')
|
||||
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).queryByText('1 packet')).not.toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
|
||||
expect(screen.getAllByText('have it')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('toggles an item at once, then shows the counts the server answers with', async () => {
|
||||
const answer = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => answer.response,
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
// At once, before the server answers: checked, no longer "have it", and counted.
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('1 packet')).toBeInTheDocument()
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
|
||||
const call = fetchMock.mock.calls.find(([, init]) => init?.method === 'PATCH')!
|
||||
expect(new Headers(call[1]?.headers).get('X-MealQuest-CSRF')).toBe(ME.csrfToken)
|
||||
|
||||
// The server's counts are the ones shown.
|
||||
answer.send(jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 7 }))
|
||||
expect(await screen.findByText('5 / 7')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
})
|
||||
|
||||
it('marks an item "have it" by unchecking it', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i1')}`]: () => jsonResponse(200, { id: 'i1', needed: false, needed_count: 3, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Ground beef' }))
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(within(row('Ground beef')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('3 / 6')).toBeInTheDocument()
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i1')} {"needed":false}`]))
|
||||
})
|
||||
|
||||
it('puts a refused toggle back, item and count, and says why with Retry', async () => {
|
||||
const answer = deferred()
|
||||
let patches = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => {
|
||||
patches++
|
||||
return patches === 1 ? answer.response : jsonResponse(200, { id: 'i3', needed: true, needed_count: 5, total_count: 6 })
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
|
||||
answer.send(errorResponse(500, 'internal'))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent('Couldn\'t mark Taco seasoning to buy. internal message')
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('5 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`, `PATCH ${ITEM('i3')} {"needed":true}`])
|
||||
})
|
||||
|
||||
it('sends toggles one at a time and keeps the count true when one between them fails', async () => {
|
||||
const first = deferred()
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => first.response,
|
||||
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
fireEvent.click(checkbox('Basmati rice'))
|
||||
expect(screen.getByText('6 / 6')).toBeInTheDocument()
|
||||
// The second waits for the first.
|
||||
await waitFor(() => expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`]))
|
||||
|
||||
first.send(errorResponse(500, 'internal'))
|
||||
await waitFor(() => expect(writes(fetchMock)).toHaveLength(2))
|
||||
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
})
|
||||
|
||||
it('keeps a refused toggle shown when a later toggle of another item succeeds, until it is retried', async () => {
|
||||
let patches = 0
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()),
|
||||
[`PATCH ${ITEM('i3')}`]: () => {
|
||||
patches++
|
||||
return patches === 1 ? errorResponse(500, 'internal') : jsonResponse(200, { id: 'i3', needed: true, needed_count: 6, total_count: 6 })
|
||||
},
|
||||
[`PATCH ${ITEM('i6')}`]: () => jsonResponse(200, { id: 'i6', needed: true, needed_count: 5, total_count: 6 }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(checkbox('Basmati rice'))
|
||||
await waitFor(() => expect(screen.getByText('5 / 6')).toBeInTheDocument())
|
||||
expect(checkbox('Basmati rice')).toBeChecked()
|
||||
// The refusal is still shown: nothing has superseded it.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. internal message")
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await waitFor(() => expect(screen.getByText('6 / 6')).toBeInTheDocument())
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
})
|
||||
|
||||
it('reads the list again when a toggle is refused because the week was archived, and shows it read-only', async () => {
|
||||
let reads = 0
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
return jsonResponse(200, supplyRun(reads === 1 ? {} : { status: 'archived' }))
|
||||
},
|
||||
[`PATCH ${ITEM('i3')}`]: () => errorResponse(409, 'archived'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(reads).toBe(2)
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
// It says why, with nothing to retry.
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(within(alert).queryByRole('button')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Taco seasoning'))
|
||||
expect(writes(fetchMock)).toEqual([`PATCH ${ITEM('i3')} {"needed":true}`])
|
||||
})
|
||||
|
||||
it('shows the status line once the list was sent to Google Chat', async () => {
|
||||
// Saturday Oct 10, 8:00 am in New York.
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
|
||||
await renderPage()
|
||||
|
||||
const status = await screen.findByText('Sent to Google Chat · Sat 8:00 am')
|
||||
expect(declaredStyle(status).color).toBe('var(--color-accent-2-800)')
|
||||
})
|
||||
|
||||
it('has no status line until the list is sent', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
expect(screen.queryByText(/Sent to Google Chat/)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('stacks the cards on mobile, with a link to the Supply archive and no header actions', async () => {
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun()) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
const groups = screen.getByTestId('supply-run-groups')
|
||||
expect(declaredStyle(groups).flexDirection).toBe('column')
|
||||
expect(declaredStyle(groups).columnCount).toBe('')
|
||||
expect(screen.getByRole('link', { name: 'Supply archive' })).toHaveAttribute('href', '/plan/shopping/archive')
|
||||
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Archive' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['tablet (mock 2b)', 1180],
|
||||
['desktop (mock 2f)', 1440],
|
||||
])('lays the cards out in 3 masonry columns on %s, with Archive and Send to Google Chat', async (_layout, width) => {
|
||||
mockViewport(width)
|
||||
const fetchMock = stubServer({ [`GET ${THIS_WEEK}`]: () => jsonResponse(200, supplyRun({ sent_to_chat_at: '2026-10-10T12:00:00Z' })) })
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('4 / 6')
|
||||
const groups = screen.getByTestId('supply-run-groups')
|
||||
expect(declaredStyle(groups).columnCount).toBe('3')
|
||||
expect(declaredStyle(screen.getByRole('region', { name: 'Beef tacos' })).breakInside).toBe('avoid')
|
||||
|
||||
const archive = screen.getByRole('link', { name: 'Archive' })
|
||||
expect(archive).toHaveAttribute('href', '/plan/shopping/archive')
|
||||
expect(archive).toHaveClass(buttonStyles.outline!)
|
||||
|
||||
// Send to Google Chat waits for Feature 19: primary, unavailable, and
|
||||
// its tooltip says so. Pressing it sends nothing.
|
||||
const send = screen.getByRole('button', { name: 'Send to Google Chat' })
|
||||
expect(send).toHaveClass(buttonStyles.primary!)
|
||||
expect(send).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(send).toHaveAccessibleDescription('Sending the shopping list to Google Chat arrives in a later update.')
|
||||
fireEvent.click(send)
|
||||
expect(writes(fetchMock)).toEqual([])
|
||||
|
||||
expect(screen.getByText('Sent to Google Chat · Sat 8:00 am')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: 'Supply archive' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders an archived list read-only, tagged Archived', async () => {
|
||||
const fetchMock = stubServer({
|
||||
'GET /api/v1/shopping/2026-09-28': () =>
|
||||
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
|
||||
})
|
||||
await renderPage('/plan/shopping?week=2026-09-28')
|
||||
|
||||
expect(await screen.findByText('Supply run · Sep 28 – Oct 4')).toBeInTheDocument()
|
||||
expect(screen.getByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('This list is in the Supply archive, as it was when the week ended.')).toBeInTheDocument()
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
// The marks still show what was bought and what was on hand.
|
||||
expect(within(row('Ground beef')).getByText('1 lb')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(declaredStyle(screen.getByText('Taco seasoning')).textDecoration).toBe('line-through')
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Taco seasoning'))
|
||||
expect(screen.getByText('4 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([])
|
||||
})
|
||||
|
||||
it('offers no Send to Google Chat for an archived list on tablet', async () => {
|
||||
mockViewport(1180)
|
||||
stubServer({
|
||||
'GET /api/v1/shopping/2026-09-28': () =>
|
||||
jsonResponse(200, supplyRun({ status: 'archived' }, '2026-09-28', 'Week 40 · Sep 28 – Oct 4')),
|
||||
})
|
||||
await renderPage('/plan/shopping?week=2026-09-28')
|
||||
|
||||
await screen.findByText('Archived')
|
||||
expect(screen.queryAllByRole('checkbox')).toEqual([])
|
||||
expect(screen.queryByRole('button', { name: 'Send to Google Chat' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'Archive' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('refuses a week that is not a Monday', async () => {
|
||||
const fetchMock = stubServer({})
|
||||
await renderPage('/plan/shopping?week=2026-09-29')
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('“2026-09-29” is not the Monday of a week, as YYYY-MM-DD.')
|
||||
expect(fetchMock.mock.calls.filter(([input]) => String(input).startsWith('/api/v1/shopping'))).toEqual([])
|
||||
})
|
||||
|
||||
it('shows skeleton cards while the list loads', async () => {
|
||||
const answer = deferred()
|
||||
stubServer({ [`GET ${THIS_WEEK}`]: () => answer.response })
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('region', { name: 'Shopping list loading' })).toHaveAttribute('aria-busy', 'true')
|
||||
await waitFor(() => expect(screen.getAllByTestId('skeleton-group')).toHaveLength(3))
|
||||
answer.send(jsonResponse(200, supplyRun()))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('skeleton-group')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when the list cannot load, and Retry reads it again', async () => {
|
||||
let reads = 0
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => {
|
||||
reads++
|
||||
return reads === 1 ? errorResponse(500, 'internal') : jsonResponse(200, supplyRun())
|
||||
},
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't load the shopping list. internal message")
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('offers to make the list when the week has none, and shows the list made', async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => jsonResponse(200, { ...supplyRun(), skipped: [] }),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByText('4 / 6')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([`POST ${GENERATE} undefined`])
|
||||
})
|
||||
|
||||
it('says why the list could not be made', async () => {
|
||||
stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`POST ${GENERATE}`]: () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Make the shopping list' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't make the shopping list. not_found message")
|
||||
expect(screen.getByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sentAt', () => {
|
||||
it('says the day and time in the household zone', () => {
|
||||
expect(sentAt(new Date('2026-10-10T12:00:00Z'), 'America/New_York')).toBe('Sat 8:00 am')
|
||||
expect(sentAt(new Date('2026-10-11T03:30:00Z'), 'America/New_York')).toBe('Sat 11:30 pm')
|
||||
})
|
||||
})
|
||||
@@ -1,289 +0,0 @@
|
||||
import { Archive, Check, CloudUpload, MessageSquare } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router'
|
||||
import { Unavailable } from '../plan/PlanPage.tsx'
|
||||
import { defaultWeek, localNow, weekdayKicker, weekLabel, weekStart as weekOf } from '../plan/week.ts'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { Checkbox } from '../ui/Checkbox.tsx'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
import { ErrorBanner } from '../ui/ErrorBanner.tsx'
|
||||
import { Icon } from '../ui/Icon.tsx'
|
||||
import { Skeleton } from '../ui/Skeleton.tsx'
|
||||
import { Tag } from '../ui/Tag.tsx'
|
||||
import {
|
||||
useGenerateSupplyRun,
|
||||
useSupplyRun,
|
||||
useToggles,
|
||||
WEEK_PARAM,
|
||||
type SupplyRunGroup,
|
||||
type SupplyRunItem,
|
||||
type Toggles,
|
||||
} from './api.ts'
|
||||
import { keep, savedSchedule, useSavedProblem } from './offline.ts'
|
||||
import styles from './SupplyRunPage.module.css'
|
||||
|
||||
// SUPPLY_ARCHIVE is the Supply archive screen.
|
||||
export const SUPPLY_ARCHIVE = '/plan/shopping/archive'
|
||||
|
||||
// CHAT_HINT is why Send to Google Chat is unavailable.
|
||||
// TODO(Feature 19): send the Supply run to Google Chat once it is built.
|
||||
const CHAT_HINT = 'Sending the shopping list to Google Chat arrives in a later update.'
|
||||
|
||||
// sentAt is when the Supply run was sent to Google Chat as its status line
|
||||
// says it, in the household's zone: "Sat 8:00 am".
|
||||
export function sentAt(at: Date, timezone: string): string {
|
||||
const day = new Intl.DateTimeFormat('en-US', { weekday: 'short', timeZone: timezone }).format(at)
|
||||
return `${day} ${formatClockTime(localNow(timezone, at).time)}`
|
||||
}
|
||||
|
||||
// ItemRow is one ingredient: on a current Supply run a checkbox, checked
|
||||
// (grape) while it is to buy; unchecked, the name is struck through and
|
||||
// "have it" stands in for the quantity. A read-only Supply run shows the
|
||||
// same marks. An item toggled while the server cannot be reached
|
||||
// says it will sync.
|
||||
function ItemRow({ item, toggles }: { item: SupplyRunItem; toggles: Toggles | null }) {
|
||||
const name = (
|
||||
<>
|
||||
<span className={styles.name}>{item.name}</span>
|
||||
{toggles?.willSync.has(item.id) && (
|
||||
<span className={styles.willSync}>
|
||||
<Icon icon={CloudUpload} size="inline" />
|
||||
will sync
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<li className={[styles.item, !item.needed && styles.have].filter(Boolean).join(' ')}>
|
||||
{toggles === null ? (
|
||||
<span className={styles.readOnly}>
|
||||
<span className={styles.box} aria-hidden="true">
|
||||
{item.needed && <Icon icon={Check} size="inline" className={styles.mark} />}
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
) : (
|
||||
<Checkbox
|
||||
className={styles.check}
|
||||
label={name}
|
||||
checked={item.needed}
|
||||
onChange={() => toggles.toggle({ id: item.id, needed: !item.needed })}
|
||||
/>
|
||||
)}
|
||||
<span className={styles.qty}>{item.needed ? item.qty : 'have it'}</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// GroupCard is one dinner's items: its name in Bagel over its day.
|
||||
function GroupCard({ group, toggles }: { group: SupplyRunGroup; toggles: Toggles | null }) {
|
||||
return (
|
||||
<section className={styles.group} aria-label={group.dinner.name}>
|
||||
<header className={styles.groupHeader}>
|
||||
<h2 className={styles.dinner}>{group.dinner.name}</h2>
|
||||
<span className={styles.day}>{weekdayKicker(group.date)}</span>
|
||||
</header>
|
||||
<ul className={styles.items}>
|
||||
{group.items.map((item) => (
|
||||
<ItemRow key={item.id} item={item} toggles={toggles} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// SentStatus is the status line once the Supply run was sent to Google Chat.
|
||||
function SentStatus({ at, timezone }: { at: Date; timezone: string }) {
|
||||
return (
|
||||
<p className={styles.sent}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Sent to Google Chat · {sentAt(at, timezone)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ArchiveLink opens the Supply archive: an outline action from tablet up, a
|
||||
// grape link on mobile.
|
||||
function ArchiveLink({ layout }: { layout: Breakpoint }) {
|
||||
const className = layout === 'mobile' ? styles.archiveLink : [buttonStyles.button, buttonStyles.outline, styles.action].join(' ')
|
||||
return (
|
||||
<Link to={SUPPLY_ARCHIVE} className={className}>
|
||||
<Icon icon={Archive} size="inline" />
|
||||
{layout === 'mobile' ? 'Supply archive' : 'Archive'}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
// SupplyRunList is a week's Supply run (mocks 1l, 2b, 2f): the kicker with
|
||||
// the week's dates, the title with the count still to buy of all items, and
|
||||
// one card per dinner, stacked on mobile and in three masonry columns from
|
||||
// tablet up, where Archive and Send to Google Chat are actions in the
|
||||
// header. On a current Supply run each item toggles at once (useToggles); a
|
||||
// toggle refused puts the item back and says why. An archived one, or any
|
||||
// opened from the Supply archive (readOnly), is read-only and tagged
|
||||
// Archived, and a toggle it refused is not offered again.
|
||||
function SupplyRunList({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
const toggles = useToggles(weekStart)
|
||||
const run = toggles.shown
|
||||
const viewOnly = readOnly || run.status === 'archived'
|
||||
const wide = layout !== 'mobile'
|
||||
const failed = toggles.failed
|
||||
const failedItem = failed && run.groups.flatMap((group) => group.items).find((item) => item.id === failed.toggle.id)
|
||||
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<p className={styles.kicker}>Supply run · {weekLabel(weekStart).dates}</p>
|
||||
<div className={styles.titleRow}>
|
||||
<h1 className={styles.title}>Shopping list</h1>
|
||||
{viewOnly && <Tag tone="grape">Archived</Tag>}
|
||||
<p className={styles.count}>
|
||||
{run.needed_count} / {run.total_count}
|
||||
</p>
|
||||
</div>
|
||||
<p className={styles.helper}>
|
||||
{run.status === 'archived'
|
||||
? 'This list is in the Supply archive, as it was when the week ended.'
|
||||
: viewOnly
|
||||
? "This week's list as it stands, read-only in the Supply archive."
|
||||
: "Uncheck anything you already have — it won't be counted."}
|
||||
</p>
|
||||
</div>
|
||||
{wide && (
|
||||
<div className={styles.actions}>
|
||||
<ArchiveLink layout={layout} />
|
||||
{!viewOnly && (
|
||||
<Unavailable hint={CHAT_HINT} below className={styles.action}>
|
||||
<Icon icon={MessageSquare} size="inline" />
|
||||
Send to Google Chat
|
||||
</Unavailable>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
{failed !== null && (
|
||||
<ErrorBanner onRetry={viewOnly ? undefined : () => toggles.toggle(failed.toggle)}>
|
||||
Couldn't mark {failedItem ? failedItem.name : 'an item'} {failed.toggle.needed ? 'to buy' : '“have it”'}.{' '}
|
||||
{failed.error.message}
|
||||
</ErrorBanner>
|
||||
)}
|
||||
{wide && run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
|
||||
{run.groups.length === 0 ? (
|
||||
<EmptyState title="Nothing to buy">None of this week's dinners has a recipe with ingredients yet.</EmptyState>
|
||||
) : (
|
||||
<div className={styles.groups} data-testid="supply-run-groups">
|
||||
{run.groups.map((group) => (
|
||||
<GroupCard key={`${group.date} ${group.dinner.id}`} group={group} toggles={viewOnly ? null : toggles} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!wide && (
|
||||
<footer className={styles.footer}>
|
||||
{run.sent_to_chat_at !== null && <SentStatus at={run.sent_to_chat_at} timezone={timezone} />}
|
||||
<ArchiveLink layout={layout} />
|
||||
</footer>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// SkeletonGroups stands in for the dinner cards while the Supply run loads.
|
||||
function SkeletonGroups({ layout }: { layout: Breakpoint }) {
|
||||
return (
|
||||
<section aria-busy="true" aria-label="Shopping list loading" className={`${styles.page} ${styles[layout]}`}>
|
||||
<Skeleton className={styles.skeletonTitle} />
|
||||
<div className={styles.groups}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
<Skeleton key={i} data-testid="skeleton-group" className={styles.skeletonGroup} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
type WeekProps = { weekStart: string; timezone: string; layout: Breakpoint; readOnly: boolean }
|
||||
|
||||
// WeekSupplyRun is the Supply run of the week starting on weekStart, or,
|
||||
// when the week has none yet, the offer to make it from the week's plan —
|
||||
// not offered read-only.
|
||||
function WeekSupplyRun({ weekStart, timezone, layout, readOnly }: WeekProps) {
|
||||
const run = useSupplyRun(weekStart)
|
||||
const generate = useGenerateSupplyRun(weekStart)
|
||||
|
||||
// Offline, the Supply run saved on this device stays shown; the offline
|
||||
// pill says it is saved data.
|
||||
if (run.isError && !(run.data !== undefined && run.error instanceof TypeError)) {
|
||||
return <ErrorBanner onRetry={() => void run.refetch()}>Couldn't load the shopping list. {run.error.message}</ErrorBanner>
|
||||
}
|
||||
if (run.isPending || generate.isPending) return <SkeletonGroups layout={layout} />
|
||||
if (run.data === null) {
|
||||
return (
|
||||
<div className={`${styles.page} ${styles[layout]}`}>
|
||||
{readOnly ? (
|
||||
<EmptyState title="No shopping list">This week has no Supply run in the Supply archive.</EmptyState>
|
||||
) : (
|
||||
<EmptyState title="No shopping list yet" action={{ label: 'Make the shopping list', onClick: () => generate.mutate() }}>
|
||||
I'll gather the ingredients of this week's planned dinners into a Supply run.
|
||||
</EmptyState>
|
||||
)}
|
||||
{generate.isError && (
|
||||
<ErrorBanner onRetry={() => generate.mutate()}>Couldn't make the shopping list. {generate.error.message}</ErrorBanner>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <SupplyRunList weekStart={weekStart} timezone={timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// SupplyRunPage is the Supply run (mock 1l on mobile, 2b on tablet, 2f on
|
||||
// desktop) of the week the Plan opens on — next week once this week's
|
||||
// plan_draft time has come, otherwise this week — or of the week chosen by
|
||||
// ?week=. Opened from the Supply archive it is readOnly: tagged Archived,
|
||||
// with no checkbox and nothing to make or send, whatever the week. Offline,
|
||||
// where the settings cannot be read, it chooses the week by the schedule
|
||||
// saved on this device.
|
||||
export function SupplyRunPage({ readOnly = false }: { readOnly?: boolean }) {
|
||||
const problem = useSavedProblem()
|
||||
return (
|
||||
<>
|
||||
{problem !== null && (
|
||||
<ErrorBanner>Changes to the shopping list can't be kept on this device while offline. {problem.message}</ErrorBanner>
|
||||
)}
|
||||
<ChosenSupplyRun readOnly={readOnly} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ChosenSupplyRun({ readOnly }: { readOnly: boolean }) {
|
||||
const layout = useBreakpoint()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
// Read once, not on every render: a failed read reports a problem, which
|
||||
// renders this again.
|
||||
const [saved] = useState(() => keep(savedSchedule))
|
||||
|
||||
if (chosen !== null && !isMonday(chosen)) {
|
||||
return <ErrorBanner>{`“${chosen}” is not the Monday of a week, as YYYY-MM-DD.`}</ErrorBanner>
|
||||
}
|
||||
// fetch rejects with a TypeError only when the network fails.
|
||||
const schedule = settings.data ?? (settings.error instanceof TypeError ? saved : null)
|
||||
if (settings.isError && !schedule) {
|
||||
return <ErrorBanner onRetry={() => void settings.refetch()}>Couldn't load the household settings. {settings.error.message}</ErrorBanner>
|
||||
}
|
||||
if (!schedule) return <SkeletonGroups layout={layout} />
|
||||
const week = chosen ?? defaultWeek(schedule, new Date())
|
||||
return <WeekSupplyRun key={week} weekStart={week} timezone={schedule.timezone} layout={layout} readOnly={readOnly} />
|
||||
}
|
||||
|
||||
// isMonday says whether date is a real YYYY-MM-DD Monday.
|
||||
function isMonday(date: string): boolean {
|
||||
try {
|
||||
return weekOf(date) === date
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,579 +0,0 @@
|
||||
import {
|
||||
MutationObserver,
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type MutationObserverOptions,
|
||||
type QueryClient,
|
||||
} from '@tanstack/react-query'
|
||||
import { useEffect, useReducer, useSyncExternalStore } from 'react'
|
||||
import { ApiError, useApi, type ApiClient } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { meQueryKey } from '../AuthBootstrap.tsx'
|
||||
import { oneOf } from '../dinners/api.ts'
|
||||
import { settleUnmadePushes } from '../plan/api.ts'
|
||||
import type { NetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS_KEY, type HouseholdSettings } from '../settings/api.ts'
|
||||
import {
|
||||
dequeue,
|
||||
enqueue,
|
||||
keep,
|
||||
markWillSync,
|
||||
queued,
|
||||
SAVED_KEY,
|
||||
saveRun,
|
||||
savedRun,
|
||||
saveSchedule,
|
||||
synced,
|
||||
willSync,
|
||||
} from './offline.ts'
|
||||
|
||||
export const SHOPPING_PATH = '/api/v1/shopping'
|
||||
|
||||
// WEEK_PARAM names the search parameter choosing the week the Supply run
|
||||
// screen shows, its Monday as YYYY-MM-DD: /plan/shopping?week=2026-09-28.
|
||||
export const WEEK_PARAM = 'week'
|
||||
|
||||
// supplyRunScreen is the Supply run screen on the week starting on Monday
|
||||
// weekStart.
|
||||
export function supplyRunScreen(weekStart: string): string {
|
||||
return `/plan/shopping?${new URLSearchParams({ [WEEK_PARAM]: weekStart })}`
|
||||
}
|
||||
|
||||
// supplyRunPath is the Supply run of the week starting on Monday weekStart.
|
||||
export function supplyRunPath(weekStart: string): string {
|
||||
return `${SHOPPING_PATH}/${encodeURIComponent(weekStart)}`
|
||||
}
|
||||
|
||||
// supplyRunKey is the query of the Supply run of the week starting on
|
||||
// weekStart.
|
||||
export function supplyRunKey(weekStart: string) {
|
||||
return ['shopping', weekStart] as const
|
||||
}
|
||||
|
||||
export const SUPPLY_RUN_STATUSES = ['current', 'archived'] as const
|
||||
|
||||
// SupplyRunStatus is current until the week ends and the Supply run is
|
||||
// frozen into the Supply archive: archived, after which nothing on it
|
||||
// changes.
|
||||
export type SupplyRunStatus = (typeof SUPPLY_RUN_STATUSES)[number]
|
||||
|
||||
// SupplyRunItem is one ingredient: qty is null when the recipe gives none,
|
||||
// and needed is false once it is marked "have it".
|
||||
export type SupplyRunItem = { id: string; name: string; qty: string | null; needed: boolean }
|
||||
|
||||
// SupplyRunGroup is the items of one plan day's dinner: date is the plan
|
||||
// day, household-local YYYY-MM-DD.
|
||||
export type SupplyRunGroup = { dinner: { id: string; name: string }; date: string; items: SupplyRunItem[] }
|
||||
|
||||
// Counts is how many of a Supply run's items are still to buy (needed) of
|
||||
// all of them (total).
|
||||
export type Counts = { needed_count: number; total_count: number }
|
||||
|
||||
// SupplyRun is GET /api/v1/shopping/{weekStart}: label is the week label,
|
||||
// "Week 41 · Oct 5 – 11", title the AI's title once written, and
|
||||
// sent_to_chat_at when it was sent to Google Chat, if it was.
|
||||
export type SupplyRun = Counts & {
|
||||
weekStart: string
|
||||
label: string
|
||||
title: string | null
|
||||
status: SupplyRunStatus
|
||||
sent_to_chat_at: Date | null
|
||||
groups: SupplyRunGroup[]
|
||||
}
|
||||
|
||||
function count(where: string, field: string, value: unknown): number {
|
||||
if (!Number.isInteger(value) || (value as number) < 0) throw new Error(`${where}: ${field} ${JSON.stringify(value)} is not a count`)
|
||||
return value as number
|
||||
}
|
||||
|
||||
function parseCounts(where: string, body: Record<string, unknown>): Counts {
|
||||
const counts = { needed_count: count(where, 'needed_count', body.needed_count), total_count: count(where, 'total_count', body.total_count) }
|
||||
if (counts.needed_count > counts.total_count) throw new Error(`${where}: needed_count is more than total_count`)
|
||||
return counts
|
||||
}
|
||||
|
||||
function parseItem(where: string, body: unknown): SupplyRunItem {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.id !== 'string' ||
|
||||
typeof body.name !== 'string' ||
|
||||
(body.qty !== null && typeof body.qty !== 'string') ||
|
||||
typeof body.needed !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has an item that is not a Supply run item`)
|
||||
}
|
||||
return { id: body.id, name: body.name, qty: body.qty, needed: body.needed }
|
||||
}
|
||||
|
||||
function parseGroup(where: string, body: unknown): SupplyRunGroup {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!isRecord(body.dinner) ||
|
||||
typeof body.dinner.id !== 'string' ||
|
||||
typeof body.dinner.name !== 'string' ||
|
||||
typeof body.date !== 'string' ||
|
||||
!Array.isArray(body.items)
|
||||
) {
|
||||
throw new Error(`${where}: response has a group that is not a dinner's items`)
|
||||
}
|
||||
return {
|
||||
dinner: { id: body.dinner.id, name: body.dinner.name },
|
||||
date: body.date,
|
||||
items: body.items.map((item) => parseItem(where, item)),
|
||||
}
|
||||
}
|
||||
|
||||
// sentAt reads sent_to_chat_at, an RFC 3339 instant or null.
|
||||
function sentAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: sent_to_chat_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
return at
|
||||
}
|
||||
|
||||
export function parseSupplyRun(where: string, weekStart: string, body: unknown): SupplyRun {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
body.weekStart !== weekStart ||
|
||||
typeof body.label !== 'string' ||
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.groups)
|
||||
) {
|
||||
throw new Error(`${where}: response is not the Supply run of the week of ${weekStart}`)
|
||||
}
|
||||
return {
|
||||
weekStart,
|
||||
label: body.label,
|
||||
title: body.title,
|
||||
status: oneOf(where, 'status', SUPPLY_RUN_STATUSES, body.status),
|
||||
sent_to_chat_at: sentAt(where, body.sent_to_chat_at),
|
||||
...parseCounts(where, body),
|
||||
groups: body.groups.map((group) => parseGroup(where, group)),
|
||||
}
|
||||
}
|
||||
|
||||
// savedSupplyRun is the week's Supply run saved on this device, or undefined.
|
||||
function savedSupplyRun(weekStart: string): SupplyRun | undefined {
|
||||
const saved = savedRun(weekStart)
|
||||
return saved === undefined ? undefined : parseSupplyRun(`Saved ${SAVED_KEY}`, weekStart, saved)
|
||||
}
|
||||
|
||||
// useSupplyRun is the Supply run of the week starting on Monday weekStart
|
||||
// (GET /api/v1/shopping/{weekStart}), or null when the week has none (404).
|
||||
// It starts from the Supply run saved on this device, if there is one, so
|
||||
// the list opens offline; the saved one is always read again.
|
||||
export function useSupplyRun(weekStart: string) {
|
||||
const { apiRead } = useApi()
|
||||
return useQuery({
|
||||
queryKey: supplyRunKey(weekStart),
|
||||
initialData: () => keep(() => savedSupplyRun(weekStart)),
|
||||
initialDataUpdatedAt: 0,
|
||||
queryFn: async (): Promise<SupplyRun | null> => {
|
||||
const path = supplyRunPath(weekStart)
|
||||
let body: unknown
|
||||
try {
|
||||
body = (await apiRead<unknown>(path)).body
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 404) return null
|
||||
throw error
|
||||
}
|
||||
return parseSupplyRun(`GET ${path}`, weekStart, body)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// useGenerateSupplyRun makes the week's Supply run from its plan (POST
|
||||
// /api/v1/shopping/{weekStart}/generate); the Supply run it answers with is
|
||||
// the one shown. Once made, from whichever screen, a push of the week whose
|
||||
// Supply run was not made is settled (settleUnmadePushes).
|
||||
export function useGenerateSupplyRun(weekStart: string) {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
onMutate: () => settleUnmadePushes(queryClient, weekStart),
|
||||
mutationFn: async (): Promise<SupplyRun> => {
|
||||
const path = `${supplyRunPath(weekStart)}/generate`
|
||||
return parseSupplyRun(`POST ${path}`, weekStart, await apiFetch<unknown>(path, { method: 'POST' }))
|
||||
},
|
||||
onSuccess: (run, _, settle) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(supplyRunKey(weekStart), run)
|
||||
settle()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Toggle is one item marked to buy (needed) or "have it" (not needed).
|
||||
export type Toggle = { id: string; needed: boolean }
|
||||
|
||||
// KeyedToggle is a toggle as sent: key tells it apart from any other toggle
|
||||
// of the same item, here and in the queue saved on this device.
|
||||
type KeyedToggle = Toggle & { key: string }
|
||||
|
||||
// toggleKey keys the toggles of the week starting on weekStart.
|
||||
function toggleKey(weekStart: string) {
|
||||
return ['shopping', weekStart, 'toggle'] as const
|
||||
}
|
||||
|
||||
// TOGGLE_SCOPE is the one mutation scope of every toggle of every week, made
|
||||
// here or replayed from the queue saved on this device.
|
||||
const TOGGLE_SCOPE = 'shopping toggle'
|
||||
|
||||
// Toggles is the toggles of one week's Supply run: shown, the Supply run
|
||||
// with those still pending laid over it, the items whose toggles will sync
|
||||
// once the server can be reached, the latest failed toggle no later toggle
|
||||
// of its item has superseded, and toggle to make another.
|
||||
export type Toggles = {
|
||||
shown: SupplyRun
|
||||
willSync: ReadonlySet<string>
|
||||
failed: { toggle: Toggle; error: Error } | null
|
||||
toggle: (toggle: Toggle) => void
|
||||
}
|
||||
|
||||
// TOGGLE_DEADLINE is how long, in milliseconds, one try of a toggle — its
|
||||
// GET /api/v1/me, its PATCH and the /me refresh a csrf 403 to it makes — may
|
||||
// take before it counts as the server not answering. Only toggles have a
|
||||
// deadline: the API client sets none.
|
||||
export const TOGGLE_DEADLINE = 10_000
|
||||
|
||||
// RETRY_FIRST and RETRY_CEILING bound, in milliseconds, how long a toggle
|
||||
// waits while the server cannot be reached before it is tried again anyway:
|
||||
// the first wait, doubled after each failed try up to the ceiling.
|
||||
export const RETRY_FIRST = 1_000
|
||||
export const RETRY_CEILING = 30_000
|
||||
|
||||
// reachable resolves once network says the server can be reached — at once
|
||||
// when it says so now — or after delay milliseconds, whichever is first. The
|
||||
// server coming back says nothing by itself, and the browser sends no online
|
||||
// event when it never thought it was offline, so the toggle is tried again
|
||||
// after delay whatever network says.
|
||||
function reachable(network: NetworkStatus, delay: number): Promise<void> {
|
||||
if (!network.offline()) return Promise.resolve()
|
||||
return new Promise((resolve) => {
|
||||
const done = () => {
|
||||
clearTimeout(timer)
|
||||
stop()
|
||||
resolve()
|
||||
}
|
||||
const timer = setTimeout(done, delay)
|
||||
const stop = network.subscribe(() => {
|
||||
if (!network.offline()) done()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// toggleOptions is the mutation every toggle of the week's Supply run is,
|
||||
// made on the Supply run screen (useToggles) or replayed from the queue saved
|
||||
// on this device (useSupplyRunSync).
|
||||
//
|
||||
// Toggles are sent one at a time, in the order made, whatever their week
|
||||
// (one mutation scope, TOGGLE_SCOPE), so the counts each is answered with
|
||||
// include every toggle before it and none after, and a replayed toggle never
|
||||
// overlaps another replayed or made here. While the server cannot be
|
||||
// reached — the device is offline, or the toggle's PATCH failed with a
|
||||
// network error (fetch rejected with a TypeError), or a try ran past its
|
||||
// deadline (TOGGLE_DEADLINE) — a toggle waits, pending, and is tried again
|
||||
// once network says the server can be reached, or after a wait that doubles
|
||||
// from RETRY_FIRST up to RETRY_CEILING, whichever is first: those after it
|
||||
// wait behind it, in order. A try past its deadline is aborted and marks the
|
||||
// network failed, as a network error does, so the item says it will sync; the
|
||||
// server may have applied the aborted PATCH, and sending it again is safe for
|
||||
// the reason a replay is (see useSupplyRunSync). Any answer from the server
|
||||
// settles it: a refusal (a 400, 404, 409 or any other error response) or a
|
||||
// response that is not the item as toggled fails it, and it is not sent again. Before
|
||||
// a toggle is sent, GET /api/v1/me has answered, so it carries the session's
|
||||
// CSRF token even when the app opened offline.
|
||||
//
|
||||
// Every toggle is in the queue saved on this device (enqueue, by useToggles)
|
||||
// until it settles, so a reload replays those the server never answered.
|
||||
function toggleOptions(
|
||||
weekStart: string,
|
||||
api: ApiClient,
|
||||
queryClient: QueryClient,
|
||||
): MutationObserverOptions<Counts, Error, KeyedToggle> {
|
||||
const key = supplyRunKey(weekStart)
|
||||
const mutationKey = toggleKey(weekStart)
|
||||
return {
|
||||
mutationKey,
|
||||
scope: { id: TOGGLE_SCOPE },
|
||||
networkMode: 'always',
|
||||
retry: false,
|
||||
gcTime: Infinity,
|
||||
mutationFn: async ({ key, id, needed }: KeyedToggle): Promise<Counts> => {
|
||||
const path = `${SHOPPING_PATH}/items/${encodeURIComponent(id)}`
|
||||
const where = `PATCH ${path}`
|
||||
let body: unknown
|
||||
for (let failures = 0; ; failures++) {
|
||||
if (api.network.offline()) markWillSync(key)
|
||||
await reachable(api.network, Math.min(RETRY_FIRST * 2 ** failures, RETRY_CEILING))
|
||||
const deadline = new AbortController()
|
||||
const timer = setTimeout(() => deadline.abort(new DOMException(`${where}: no answer in time`, 'TimeoutError')), TOGGLE_DEADLINE)
|
||||
try {
|
||||
// Each try loads /me itself, under its deadline, rather than share
|
||||
// a /me already in flight that may never answer.
|
||||
if (queryClient.getQueryData(meQueryKey) === undefined) {
|
||||
queryClient.setQueryData(meQueryKey, await api.loadMe(deadline.signal))
|
||||
}
|
||||
body = await api.apiFetch<unknown>(path, { method: 'PATCH', body: { needed }, signal: deadline.signal })
|
||||
break
|
||||
} catch (error) {
|
||||
if (deadline.signal.aborted) {
|
||||
api.network.requestFailed()
|
||||
continue
|
||||
}
|
||||
// The API client marks the network offline as fetch rejects; a
|
||||
// TypeError it did not mark is not a network failure.
|
||||
if (!(error instanceof TypeError) || !api.network.offline()) throw error
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
if (!isRecord(body) || body.id !== id || body.needed !== needed) throw new Error(`${where}: response is not item ${id} as toggled`)
|
||||
return parseCounts(where, body)
|
||||
},
|
||||
// A read of the Supply run sent before the toggle must not land after
|
||||
// its answer, over it.
|
||||
onMutate: () => queryClient.cancelQueries({ queryKey: key }),
|
||||
onSuccess: (counts, { id, needed }) => {
|
||||
queryClient.setQueryData<SupplyRun | null>(
|
||||
key,
|
||||
(run) =>
|
||||
run && {
|
||||
...run,
|
||||
...counts,
|
||||
groups: run.groups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => (item.id === id ? { ...item, needed } : item)),
|
||||
})),
|
||||
},
|
||||
)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ApiError && error.code === 'archived') void queryClient.invalidateQueries({ queryKey: key })
|
||||
},
|
||||
// This toggle is still pending as it settles, and those after it wait
|
||||
// for it: the settled toggles of its item are the earlier ones. Settled,
|
||||
// it leaves the saved queue.
|
||||
onSettled: (_counts, _error, { key: settled, id }) => {
|
||||
const cache = queryClient.getMutationCache()
|
||||
for (const earlier of cache.findAll({ mutationKey, predicate: (m) => m.state.status !== 'pending' })) {
|
||||
if ((earlier.state.variables as KeyedToggle).id === id) cache.remove(earlier)
|
||||
}
|
||||
keep(() => dequeue(settled))
|
||||
synced(settled)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// useToggles marks items of the week's Supply run, already read, to buy or
|
||||
// "have it" (PATCH /api/v1/shopping/items/{id}; see toggleOptions). The
|
||||
// Supply run cached is the server's: a toggle's answer writes the item and
|
||||
// the counts it answers with there. Until then the toggle is pending and is
|
||||
// laid over the Supply run shown, so the item and the needed count change at
|
||||
// once; a refused toggle just stops being pending, which puts both back.
|
||||
// An item whose pending toggle was made, or waited, while the server could
|
||||
// not be reached will sync: it says so until that toggle settles, even once
|
||||
// the server can be reached again, and even when the screen is left and
|
||||
// opened again meanwhile (markWillSync keeps it with the page, not here).
|
||||
//
|
||||
// The Supply run and the toggles are read straight from the query and
|
||||
// mutation caches as each render begins, never from the caches' batched
|
||||
// notices, which can arrive apart: a toggle is in the mutation cache as
|
||||
// pending as soon as it is made, and its answer is in the query cache
|
||||
// before it stops being pending. Making a toggle renders again at once, so
|
||||
// the checkbox clicked never shows its old state; useSupplyRun and
|
||||
// useMutationState render again on every later change.
|
||||
//
|
||||
// A failed toggle stays failed, and shown, until a later toggle of its item
|
||||
// supersedes it, such as its Retry: a toggle of another item succeeding
|
||||
// does not hide it. So that a failure is never collected unseen, toggles are
|
||||
// kept (gcTime Infinity), and a settled toggle removes the earlier toggles
|
||||
// of its item it supersedes. A toggle refused because the week was frozen
|
||||
// into the Supply archive (409 archived), even one replayed after the device
|
||||
// was offline, reads the Supply run again, which then renders read-only.
|
||||
export function useToggles(weekStart: string): Toggles {
|
||||
const api = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
const key = supplyRunKey(weekStart)
|
||||
const mutationKey = toggleKey(weekStart)
|
||||
useSupplyRun(weekStart)
|
||||
useMutationState({ filters: { mutationKey } })
|
||||
const offline = useSyncExternalStore(api.network.subscribe, api.network.offline)
|
||||
const [, renderAgain] = useReducer((n: number) => n + 1, 0)
|
||||
const run = queryClient.getQueryData<SupplyRun | null>(key)
|
||||
if (!run) throw new Error(`The week of ${weekStart} has no Supply run read to toggle items of.`)
|
||||
const toggles = queryClient
|
||||
.getMutationCache()
|
||||
.findAll({ mutationKey })
|
||||
.map((mutation) => ({ status: mutation.state.status, toggle: mutation.state.variables as KeyedToggle, error: mutation.state.error }))
|
||||
const pending = toggles.filter((t) => t.status === 'pending').map((t) => t.toggle)
|
||||
// Every change of the network status renders again, so each pending
|
||||
// toggle seen while the server cannot be reached is marked.
|
||||
if (offline) pending.forEach((t) => markWillSync(t.key))
|
||||
const failed = toggles
|
||||
.filter((t, i) => t.status === 'error' && !toggles.slice(i + 1).some((later) => later.toggle.id === t.toggle.id))
|
||||
.at(-1)
|
||||
const { mutate } = useMutation(toggleOptions(weekStart, api, queryClient))
|
||||
return {
|
||||
shown: shown(run, pending),
|
||||
willSync: new Set(pending.filter((t) => willSync(t.key)).map((t) => t.id)),
|
||||
failed: failed === undefined ? null : { toggle: { id: failed.toggle.id, needed: failed.toggle.needed }, error: failed.error! },
|
||||
toggle: ({ id, needed }) => {
|
||||
const queuedToggle = { key: crypto.randomUUID(), weekStart, id, needed }
|
||||
keep(() => enqueue(queuedToggle))
|
||||
mutate({ key: queuedToggle.key, id, needed })
|
||||
renderAgain()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// useSupplyRunSync keeps, on this device, what the Supply run screen needs
|
||||
// to open offline — the household's schedule and each Supply run the server
|
||||
// sends (saveRun) — and replays, in the order made, the toggles saved in the
|
||||
// queue that are not already pending here: those of a page reloaded before
|
||||
// the server answered them.
|
||||
//
|
||||
// Replay is at-least-once, not exactly-once: a toggle leaves the saved queue
|
||||
// only once its answer settles here, so one the server applied just before a
|
||||
// reload is sent again. That duplicate is safe because a toggle sets needed
|
||||
// to a fixed value — the same PATCH applied twice leaves the item and the
|
||||
// counts as applying it once does — so no key is sent for the server to
|
||||
// deduplicate by.
|
||||
//
|
||||
// A replayed toggle's answer is written over the Supply run saved, so the
|
||||
// week's Supply run is cached from it first; it is still read again
|
||||
// (updatedAt 0) when the Supply run screen opens.
|
||||
export function useSupplyRunSync() {
|
||||
const api = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
useEffect(() => {
|
||||
const stop = queryClient.getQueryCache().subscribe((event) => {
|
||||
if (event.type !== 'updated' || event.action.type !== 'success') return
|
||||
const [scope, week] = event.query.queryKey
|
||||
const data: unknown = event.query.state.data
|
||||
if (scope === 'shopping' && typeof week === 'string' && event.query.queryKey.length === 2) {
|
||||
keep(() => saveRun(week, data as SupplyRun | null))
|
||||
} else if (scope === HOUSEHOLD_SETTINGS_KEY[0] && week === HOUSEHOLD_SETTINGS_KEY[1]) {
|
||||
keep(() => saveSchedule(data as HouseholdSettings))
|
||||
}
|
||||
})
|
||||
const pending = new Set(
|
||||
queryClient
|
||||
.getMutationCache()
|
||||
.findAll({ mutationKey: ['shopping'], predicate: (m) => m.state.status === 'pending' })
|
||||
.map((m) => (m.state.variables as KeyedToggle).key),
|
||||
)
|
||||
for (const { key, weekStart, id, needed } of keep(queued) ?? []) {
|
||||
if (pending.has(key)) continue
|
||||
const runKey = supplyRunKey(weekStart)
|
||||
if (queryClient.getQueryData(runKey) === undefined) {
|
||||
const saved = keep(() => savedSupplyRun(weekStart))
|
||||
if (saved !== undefined) queryClient.setQueryData(runKey, saved, { updatedAt: 0 })
|
||||
}
|
||||
// A refusal is the mutation's state, which the Supply run screen shows.
|
||||
new MutationObserver(queryClient, toggleOptions(weekStart, api, queryClient)).mutate({ key, id, needed }).catch(() => {})
|
||||
}
|
||||
return stop
|
||||
}, [api, queryClient])
|
||||
}
|
||||
|
||||
// SupplyRunSync runs useSupplyRunSync for the whole app.
|
||||
export function SupplyRunSync(): null {
|
||||
useSupplyRunSync()
|
||||
return null
|
||||
}
|
||||
|
||||
// shown is run as the household sees it with the pending toggles laid over
|
||||
// it: each item as its latest pending toggle marks it, and the needed count
|
||||
// moved to match.
|
||||
function shown(run: SupplyRun, pending: Toggle[]): SupplyRun {
|
||||
const marked = new Map(pending.map((toggle) => [toggle.id, toggle.needed]))
|
||||
let needed = run.needed_count
|
||||
const groups = run.groups.map((group) => ({
|
||||
...group,
|
||||
items: group.items.map((item) => {
|
||||
const mark = marked.get(item.id)
|
||||
if (mark === undefined || mark === item.needed) return item
|
||||
needed += mark ? 1 : -1
|
||||
return { ...item, needed: mark }
|
||||
}),
|
||||
}))
|
||||
return { ...run, needed_count: needed, groups }
|
||||
}
|
||||
|
||||
export const ARCHIVE_PERIODS = ['week', 'month', 'year'] as const
|
||||
|
||||
// ArchivePeriod is what the Supply archive is grouped by.
|
||||
export type ArchivePeriod = (typeof ARCHIVE_PERIODS)[number]
|
||||
|
||||
// ArchivePost is one week's Supply run in the Supply archive: label is the
|
||||
// week label, "Week 41 · Oct 5 – 11", which stands in for title while it is
|
||||
// null; onHandCount counts the items marked "have it", and dinners names the
|
||||
// dinners in day order. current is true for the household's current week.
|
||||
export type ArchivePost = {
|
||||
weekStart: string
|
||||
label: string
|
||||
title: string | null
|
||||
dinnerCount: number
|
||||
itemCount: number
|
||||
onHandCount: number
|
||||
dinners: string[]
|
||||
current: boolean
|
||||
}
|
||||
|
||||
// ArchiveGroup is one period of the Supply archive — a week, a month
|
||||
// ("October 2026") or a year ("2026") — and its posts, newest first.
|
||||
export type ArchiveGroup = { key: string; heading: string; posts: ArchivePost[] }
|
||||
|
||||
function parsePost(where: string, body: unknown): ArchivePost {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
typeof body.weekStart !== 'string' ||
|
||||
typeof body.label !== 'string' ||
|
||||
(body.title !== null && typeof body.title !== 'string') ||
|
||||
!Array.isArray(body.dinners) ||
|
||||
!body.dinners.every((name) => typeof name === 'string') ||
|
||||
typeof body.current !== 'boolean'
|
||||
) {
|
||||
throw new Error(`${where}: response has a post that is not a Supply archive post`)
|
||||
}
|
||||
return {
|
||||
weekStart: body.weekStart,
|
||||
label: body.label,
|
||||
title: body.title,
|
||||
dinnerCount: count(where, 'dinnerCount', body.dinnerCount),
|
||||
itemCount: count(where, 'itemCount', body.itemCount),
|
||||
onHandCount: count(where, 'onHandCount', body.onHandCount),
|
||||
dinners: body.dinners as string[],
|
||||
current: body.current,
|
||||
}
|
||||
}
|
||||
|
||||
function parseArchiveGroup(where: string, body: unknown): ArchiveGroup {
|
||||
if (!isRecord(body) || typeof body.key !== 'string' || typeof body.heading !== 'string' || !Array.isArray(body.posts)) {
|
||||
throw new Error(`${where}: response has a period that is not a Supply archive period`)
|
||||
}
|
||||
return { key: body.key, heading: body.heading, posts: body.posts.map((post) => parsePost(where, post)) }
|
||||
}
|
||||
|
||||
// useSupplyArchive is the Supply archive grouped by by, newest first (GET
|
||||
// /api/v1/shopping/archive?by=week|month|year): the server groups it in the
|
||||
// household's zone, so it is shown as answered, never grouped again here.
|
||||
export function useSupplyArchive(by: ArchivePeriod) {
|
||||
const { apiRead } = useApi()
|
||||
return useQuery({
|
||||
queryKey: ['supply archive', by] as const,
|
||||
queryFn: async (): Promise<ArchiveGroup[]> => {
|
||||
const path = `${SHOPPING_PATH}/archive?${new URLSearchParams({ by })}`
|
||||
const where = `GET ${path}`
|
||||
const { body } = await apiRead<unknown>(path)
|
||||
if (!isRecord(body) || body.by !== by || !Array.isArray(body.periods)) {
|
||||
throw new Error(`${where}: response is not the Supply archive by ${by}`)
|
||||
}
|
||||
return body.periods.map((period) => parseArchiveGroup(where, period))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
|
||||
// SAVED_KEY is the localStorage key holding, on this device, what the Supply
|
||||
// run screen needs to open offline — the household's schedule and the
|
||||
// Supply runs last read — and the toggles made that the server has not
|
||||
// answered yet, in the order they were made.
|
||||
export const SAVED_KEY = 'mealquest.supply-run'
|
||||
|
||||
// SAVED_WEEKS is how many weeks' Supply runs are kept: this week's and next
|
||||
// week's are the only ones still current.
|
||||
const SAVED_WEEKS = 2
|
||||
|
||||
// Schedule is the part of the household settings the Supply run screen
|
||||
// needs to choose the week and say times. The service worker never caches
|
||||
// the settings, so it is kept here for the screen to open offline.
|
||||
export type Schedule = { timezone: string; plan_draft_dow: number; plan_draft_time: string }
|
||||
|
||||
// QueuedToggle is a toggle the server has not answered yet: key tells it
|
||||
// apart from any other toggle of the same item.
|
||||
export type QueuedToggle = { key: string; weekStart: string; id: string; needed: boolean }
|
||||
|
||||
// Saved is the stored value: runs holds each week's Supply run as the server
|
||||
// last sent it, by its Monday.
|
||||
type Saved = { schedule: Schedule | null; runs: Record<string, unknown>; queue: QueuedToggle[] }
|
||||
|
||||
function isSchedule(value: unknown): value is Schedule {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.timezone === 'string' &&
|
||||
Number.isInteger(value.plan_draft_dow) &&
|
||||
typeof value.plan_draft_time === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function isQueuedToggle(value: unknown): value is QueuedToggle {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.weekStart === 'string' &&
|
||||
typeof value.id === 'string' &&
|
||||
typeof value.needed === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
// read is the stored value, empty when nothing is stored. A value that is not
|
||||
// one MealQuest stored is an error.
|
||||
function read(): Saved {
|
||||
const text = window.localStorage.getItem(SAVED_KEY)
|
||||
if (text === null) return { schedule: null, runs: {}, queue: [] }
|
||||
const saved: unknown = JSON.parse(text)
|
||||
if (
|
||||
!isRecord(saved) ||
|
||||
(saved.schedule !== null && !isSchedule(saved.schedule)) ||
|
||||
!isRecord(saved.runs) ||
|
||||
!Array.isArray(saved.queue) ||
|
||||
!saved.queue.every(isQueuedToggle)
|
||||
) {
|
||||
throw new Error(`The Supply run saved on this device (${SAVED_KEY}) is not one MealQuest saved.`)
|
||||
}
|
||||
return { schedule: saved.schedule as Schedule | null, runs: saved.runs, queue: saved.queue }
|
||||
}
|
||||
|
||||
function write(change: (saved: Saved) => Saved) {
|
||||
window.localStorage.setItem(SAVED_KEY, JSON.stringify(change(read())))
|
||||
}
|
||||
|
||||
// savedSchedule is the household's schedule as last read, or null.
|
||||
export function savedSchedule(): Schedule | null {
|
||||
return read().schedule
|
||||
}
|
||||
|
||||
export function saveSchedule({ timezone, plan_draft_dow, plan_draft_time }: Schedule) {
|
||||
write((saved) => ({ ...saved, schedule: { timezone, plan_draft_dow, plan_draft_time } }))
|
||||
}
|
||||
|
||||
// savedRun is the week's Supply run as the server last sent it, unparsed, or
|
||||
// undefined when none is saved.
|
||||
export function savedRun(weekStart: string): unknown {
|
||||
return read().runs[weekStart]
|
||||
}
|
||||
|
||||
// saveRun keeps the week's Supply run as the server sent it, with the latest
|
||||
// weeks'. A week with none (null) or one frozen into the Supply archive is
|
||||
// not kept: nothing on it can change.
|
||||
export function saveRun(weekStart: string, run: { status: string } | null) {
|
||||
write((saved) => {
|
||||
const runs = { ...saved.runs }
|
||||
delete runs[weekStart]
|
||||
if (run !== null && run.status !== 'archived') runs[weekStart] = run
|
||||
const latest = Object.keys(runs).sort().slice(-SAVED_WEEKS)
|
||||
return { ...saved, runs: Object.fromEntries(latest.map((week) => [week, runs[week]])) }
|
||||
})
|
||||
}
|
||||
|
||||
// queued is the toggles the server has not answered, in the order made.
|
||||
export function queued(): QueuedToggle[] {
|
||||
return read().queue
|
||||
}
|
||||
|
||||
export function enqueue(toggle: QueuedToggle) {
|
||||
write((saved) => ({ ...saved, queue: [...saved.queue, toggle] }))
|
||||
}
|
||||
|
||||
export function dequeue(key: string) {
|
||||
write((saved) => ({ ...saved, queue: saved.queue.filter((toggle) => toggle.key !== key) }))
|
||||
}
|
||||
|
||||
// The keys of the toggles that will sync: made, or waiting, while the server
|
||||
// could not be reached, and not settled yet. They live here, with the page,
|
||||
// as the toggles in the mutation cache do: the Supply run screen is left and
|
||||
// opened again while a toggle is still pending, and starts with nothing of
|
||||
// its own.
|
||||
const syncing = new Set<string>()
|
||||
|
||||
export function markWillSync(key: string) {
|
||||
syncing.add(key)
|
||||
}
|
||||
|
||||
export function willSync(key: string): boolean {
|
||||
return syncing.has(key)
|
||||
}
|
||||
|
||||
export function synced(key: string) {
|
||||
syncing.delete(key)
|
||||
}
|
||||
|
||||
// The latest failure to read or write the saved Supply run, which the Supply
|
||||
// run screen shows: a toggle that cannot be kept may be lost on a reload.
|
||||
let problem: Error | null = null
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
// keep runs one read or write of the saved Supply run, and returns its
|
||||
// result, or undefined when it failed: the failure is reported to the Supply
|
||||
// run screen. Listeners hear of it after the current render.
|
||||
export function keep<T>(action: () => T): T | undefined {
|
||||
try {
|
||||
return action()
|
||||
} catch (error) {
|
||||
problem = error instanceof Error ? error : new Error(String(error))
|
||||
queueMicrotask(() => listeners.forEach((listener) => listener()))
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
|
||||
// useSavedProblem is the latest failure to read or write the saved Supply
|
||||
// run, or null.
|
||||
export function useSavedProblem(): Error | null {
|
||||
return useSyncExternalStore(subscribe, () => problem)
|
||||
}
|
||||
@@ -1,616 +0,0 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { MemoryRouter } from 'react-router'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus, type NetworkWindow } from '../pwa/networkStatus.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { RETRY_FIRST, SupplyRunSync, TOGGLE_DEADLINE } from './api.ts'
|
||||
import { SAVED_KEY } from './offline.ts'
|
||||
import { SupplyRunPage } from './SupplyRunPage.tsx'
|
||||
|
||||
const THIS_WEEK = '/api/v1/shopping/2026-10-05'
|
||||
const ITEM = (id: string) => `/api/v1/shopping/items/${id}`
|
||||
|
||||
// Monday's tacos: two items to buy and one the household has.
|
||||
const ITEMS = [
|
||||
{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true },
|
||||
{ id: 'i2', name: 'Corn tortillas', qty: '12', needed: true },
|
||||
{ id: 'i3', name: 'Taco seasoning', qty: '1 packet', needed: false },
|
||||
]
|
||||
|
||||
// Next week's curry: one item to buy.
|
||||
const NEXT_ITEMS = [{ id: 'n1', name: 'Chicken thighs', qty: '2 lb', needed: true }]
|
||||
|
||||
// The items of each week, by its Monday.
|
||||
const WEEKS: Record<string, typeof ITEMS> = { '2026-10-05': ITEMS, '2026-10-12': NEXT_ITEMS }
|
||||
|
||||
function itemsOf(week: string) {
|
||||
const items = WEEKS[week]
|
||||
if (items === undefined) throw new Error(`no week ${week}`)
|
||||
return items
|
||||
}
|
||||
|
||||
// The browser's online flag; goOffline and goOnline change it and send the
|
||||
// page's window the event a browser sends.
|
||||
let onLine: boolean
|
||||
// The server can be reached: false, every request fails as fetch does when
|
||||
// the network fails, whatever onLine says.
|
||||
let reachable: boolean
|
||||
// The window of the page now open: a reload opens a new one, and the old
|
||||
// page hears nothing more.
|
||||
let win: NetworkWindow & EventTarget
|
||||
|
||||
function openWindow() {
|
||||
win = Object.assign(new EventTarget(), {
|
||||
navigator: {
|
||||
get onLine() {
|
||||
return onLine
|
||||
},
|
||||
},
|
||||
}) as unknown as NetworkWindow & EventTarget
|
||||
return win
|
||||
}
|
||||
|
||||
function goOffline() {
|
||||
onLine = false
|
||||
act(() => void win.dispatchEvent(new Event('offline')))
|
||||
}
|
||||
|
||||
function goOnline() {
|
||||
onLine = true
|
||||
reachable = true
|
||||
act(() => void win.dispatchEvent(new Event('online')))
|
||||
}
|
||||
|
||||
// stubServer is the server's Supply runs of this week and next: a PATCH
|
||||
// marks its item and answers with its week's counts, unless refuse answers
|
||||
// it instead; when hold is given, the item is marked at once but the answer
|
||||
// waits for the promise hold returns, or until the request is aborted; holdMe
|
||||
// holds GET /api/v1/me's answer the same way. This week's counts as archived
|
||||
// once archive is called.
|
||||
function stubServer(
|
||||
refuse: (id: string) => Response | undefined = () => undefined,
|
||||
hold?: () => Promise<void>,
|
||||
holdMe?: () => Promise<void>,
|
||||
) {
|
||||
const needed = new Map(Object.values(WEEKS).flatMap((items) => items.map((item) => [item.id, item.needed] as const)))
|
||||
let status = 'current'
|
||||
// How many PATCHes are unanswered now, and the most there have been at once.
|
||||
let inFlight = 0
|
||||
let mostInFlight = 0
|
||||
// How many GET /api/v1/me requests have been answered.
|
||||
let meAnswered = 0
|
||||
const counts = (week: string) => {
|
||||
const marks = itemsOf(week).map((item) => needed.get(item.id)!)
|
||||
return { needed_count: marks.filter(Boolean).length, total_count: marks.length }
|
||||
}
|
||||
const run = (week: string) => ({
|
||||
weekStart: week,
|
||||
label: week === '2026-10-05' ? 'Week 41 · Oct 5 – 11' : 'Week 42 · Oct 12 – 18',
|
||||
title: null,
|
||||
status: week === '2026-10-05' ? status : 'current',
|
||||
sent_to_chat_at: null,
|
||||
...counts(week),
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: week === '2026-10-05' ? 'd0' : 'd1', name: week === '2026-10-05' ? 'Beef tacos' : 'Chicken curry' },
|
||||
date: week,
|
||||
items: itemsOf(week).map((item) => ({ ...item, needed: needed.get(item.id)! })),
|
||||
},
|
||||
],
|
||||
})
|
||||
const fetchMock = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (!onLine || !reachable) throw new TypeError('Failed to fetch')
|
||||
const key = `${init?.method ?? 'GET'} ${String(input)}`
|
||||
if (key === 'GET /api/v1/me') {
|
||||
await answerAfter(init?.signal, holdMe)
|
||||
meAnswered++
|
||||
return jsonResponse(200, ME)
|
||||
}
|
||||
if (key === 'GET /api/v1/settings/household') return jsonResponse(200, HOUSEHOLD_SETTINGS)
|
||||
if (key === `GET ${THIS_WEEK}`) return jsonResponse(200, run('2026-10-05'))
|
||||
const week = Object.keys(WEEKS).find((w) => itemsOf(w).some((i) => key === `PATCH ${ITEM(i.id)}`))
|
||||
if (week === undefined) throw new Error(`unexpected fetch: ${key}`)
|
||||
const id = itemsOf(week).find((i) => key === `PATCH ${ITEM(i.id)}`)!.id
|
||||
const refusal = refuse(id)
|
||||
if (refusal !== undefined) return refusal
|
||||
const body = JSON.parse(String(init?.body)) as { needed: boolean }
|
||||
needed.set(id, body.needed)
|
||||
const answer = jsonResponse(200, { id, needed: body.needed, ...counts(week) })
|
||||
inFlight++
|
||||
mostInFlight = Math.max(mostInFlight, inFlight)
|
||||
try {
|
||||
await answerAfter(init?.signal, hold)
|
||||
} finally {
|
||||
inFlight--
|
||||
}
|
||||
return answer
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return {
|
||||
fetchMock,
|
||||
run,
|
||||
mostInFlight: () => mostInFlight,
|
||||
meAnswered: () => meAnswered,
|
||||
archive: () => {
|
||||
status = 'archived'
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// answerAfter resolves once the promise hold returns does, at once without
|
||||
// hold, or rejects with signal's reason once it aborts.
|
||||
function answerAfter(signal: AbortSignal | null | undefined, hold?: () => Promise<void>) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
signal?.addEventListener('abort', () => reject(signal.reason as Error))
|
||||
void (hold?.() ?? Promise.resolve()).then(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
// held is a hold for stubServer whose answers wait until release is called,
|
||||
// each one released in turn.
|
||||
function held() {
|
||||
const waiting: (() => void)[] = []
|
||||
return {
|
||||
hold: () => new Promise<void>((resolve) => waiting.push(resolve)),
|
||||
waiting: () => waiting.length,
|
||||
release: () => waiting.shift()!(),
|
||||
}
|
||||
}
|
||||
|
||||
// mes counts the GET /api/v1/me requests sent.
|
||||
function mes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([input, init]) => String(input) === '/api/v1/me' && (init?.method ?? 'GET') === 'GET').length
|
||||
}
|
||||
|
||||
// patches lists the PATCHes sent, as "path body".
|
||||
function patches(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([input, init]) => `${String(input)} ${String(init?.body)}`)
|
||||
}
|
||||
|
||||
// queue is the toggles saved on this device, as "id needed".
|
||||
function queue(): string[] {
|
||||
const saved = localStorage.getItem(SAVED_KEY)
|
||||
if (saved === null) return []
|
||||
return (JSON.parse(saved) as { queue: { id: string; needed: boolean }[] }).queue.map((t) => `${t.id} ${t.needed}`)
|
||||
}
|
||||
|
||||
// openPage opens the Supply run screen in a new window, as the app does:
|
||||
// with SupplyRunSync, and with /me loaded unless the server cannot be
|
||||
// reached. leave goes to another screen of the same page, and reopen comes
|
||||
// back to the Supply run screen.
|
||||
async function openPage() {
|
||||
const api = createApiClient(fakeLocation('/plan/shopping'), createNetworkStatus(openWindow()), createVerifiedSession(localStorage))
|
||||
if (onLine && reachable) await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const app = (onScreen: boolean) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={['/plan/shopping']}>
|
||||
<SupplyRunSync />
|
||||
{onScreen && <SupplyRunPage />}
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const view = render(app(true))
|
||||
return { ...view, leave: () => view.rerender(app(false)), reopen: () => view.rerender(app(true)) }
|
||||
}
|
||||
|
||||
// reload ends the page open and its timers, as a reload does: a toggle it
|
||||
// was waiting to try again is never tried, by it, again.
|
||||
function reload(page: { unmount: () => void }) {
|
||||
page.unmount()
|
||||
vi.clearAllTimers()
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
}
|
||||
|
||||
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
|
||||
let timers: MockInstance<typeof setTimeout>
|
||||
|
||||
// retrySecondTry jumps past the wait before a toggle's second try — RETRY_FIRST
|
||||
// doubled by its failed first try — once that wait's timer is set, so the jump
|
||||
// never lands before the timer it is meant to fire.
|
||||
async function retrySecondTry() {
|
||||
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
|
||||
await advance(2 * RETRY_FIRST)
|
||||
}
|
||||
|
||||
function checkbox(name: string) {
|
||||
return screen.getByRole('checkbox', { name: new RegExp(`^${name}`) })
|
||||
}
|
||||
|
||||
function row(name: string) {
|
||||
return screen.getByText(name).closest('li')!
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
onLine = true
|
||||
reachable = true
|
||||
mockViewport(390)
|
||||
// setTimeout moves with real time, and a test can jump past a deadline or
|
||||
// a wait (advance).
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
timers = vi.spyOn(globalThis, 'setTimeout')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
})
|
||||
|
||||
describe('offline toggle queue', () => {
|
||||
it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
|
||||
// Applied here, counted, marked to sync and saved in order; nothing sent.
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Corn tortillas')).queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false', 'i3 false'])
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
`${ITEM('i3')} {"needed":false}`,
|
||||
])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).not.toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
// The browser still says online, but the server cannot be reached.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
|
||||
expect(checkbox('Corn tortillas')).not.toBeChecked()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i2 false'])
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
const first = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Reload while offline: the settings, /me and the list cannot be read.
|
||||
reload(first)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
})
|
||||
|
||||
it('discards a queued toggle the server refuses because the list was archived, puts the count back and says so', async () => {
|
||||
const server = stubServer(() => errorResponse(409, 'archived'))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
server.archive()
|
||||
goOnline()
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual([])
|
||||
// Sent once, never again.
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
|
||||
})
|
||||
|
||||
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
|
||||
const server = stubServer(() => errorResponse(400, 'invalid'))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
|
||||
expect(checkbox('Ground beef')).toBeChecked()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual([])
|
||||
|
||||
goOffline()
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('offline toggle queue, replay', () => {
|
||||
it('replays a queue spanning two weeks strictly in order, one PATCH at a time, a toggle made meanwhile after it', async () => {
|
||||
const answers = held()
|
||||
const server = stubServer(undefined, answers.hold)
|
||||
const queued = [
|
||||
{ key: 'k1', weekStart: '2026-10-05', id: 'i3', needed: true },
|
||||
{ key: 'k2', weekStart: '2026-10-12', id: 'n1', needed: false },
|
||||
{ key: 'k3', weekStart: '2026-10-05', id: 'i1', needed: false },
|
||||
]
|
||||
const runs = { '2026-10-05': server.run('2026-10-05'), '2026-10-12': server.run('2026-10-12') }
|
||||
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
|
||||
for (let sent = 1; sent <= 4; sent++) {
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
|
||||
// The next one is not sent before this one is answered.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
expect(patches(server.fetchMock)).toHaveLength(sent)
|
||||
act(() => answers.release())
|
||||
}
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('n1')} {"needed":false}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
`${ITEM('i2')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
|
||||
const answers = held()
|
||||
const server = stubServer(undefined, answers.hold)
|
||||
const first = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
// The server applied it; the page reloads before its answer arrives.
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
expect(queue()).toEqual(['i3 true'])
|
||||
reload(first)
|
||||
act(() => answers.release())
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
|
||||
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "will sync" on a queued toggle until its delayed replay is answered', async () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('aborts a PATCH that never answers at its deadline, keeps it pending and tries it again before the toggles after it', async () => {
|
||||
let tries = 0
|
||||
// The first PATCH never answers; every later one answers at once.
|
||||
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung PATCH is aborted; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('abandons a GET /api/v1/me that never answers at its deadline and loads it afresh before the toggles after it', async () => {
|
||||
let tries = 0
|
||||
// The page loaded /me; the first toggle loads it again, and that /me never
|
||||
// answers. Every later /me answers at once.
|
||||
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(2))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung /me is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toEqual([])
|
||||
|
||||
// The next try sends a fresh /me, which the server answers.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(mes(server.fetchMock)).toBe(3)
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('abandons the /me refresh of a csrf 403 that never answers at its deadline and refreshes afresh on the next try', async () => {
|
||||
let tries = 0
|
||||
// The session's token changed: each PATCH is refused for it until a /me
|
||||
// after the first refusal has answered. The first such refresh never
|
||||
// answers.
|
||||
let refusedAt: number | null = null
|
||||
const server = stubServer(
|
||||
() => {
|
||||
if (refusedAt !== null && server.meAnswered() > refusedAt) return undefined
|
||||
refusedAt ??= server.meAnswered()
|
||||
return errorResponse(403, 'csrf')
|
||||
},
|
||||
undefined,
|
||||
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
|
||||
)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
|
||||
// Past the deadline the hung refresh is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// The next try is refused again and sends a fresh refresh, which answers.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('tries a toggle again once the server is back, with no online event from the browser', async () => {
|
||||
const { fetchMock } = stubServer()
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
|
||||
// The browser says online throughout; the server cannot be reached, then can.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
reachable = true
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
// Nothing says the server is back: only the wait's timer tries it again.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
// The first try failed at GET /api/v1/me, before its PATCH.
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps "will sync" when the Supply run screen is left and opened again while the replay is pending', async () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
const page = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
page.leave()
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
page.reopen()
|
||||
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says the saved list is unreadable when it is corrupt offline, without rendering again and again', async () => {
|
||||
stubServer()
|
||||
onLine = false
|
||||
localStorage.setItem(SAVED_KEY, '{corrupt')
|
||||
const getItem = vi.spyOn(Storage.prototype, 'getItem')
|
||||
await openPage()
|
||||
|
||||
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
|
||||
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user