Compare commits

..
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 cf8046060a Name the dinners.Dinner responses that carry photo_backfill_eligible (F23-1)
CI / ci (pull_request) Successful in 9m49s
CI / ci (push) Successful in 9m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:19:07 -04:00
dev_agentandClaude Opus 5.5 886aa5e9c2 Narrow photo_backfill_eligible doc to the responses that carry it (F23-1)
CI / ci (push) Successful in 9m41s
CI / ci (pull_request) Successful in 9m40s
docs/ARCHITECTURE.md said every dinner response carries
photo_backfill_eligible, but GET /api/v1/dinners/{id} returns Detail,
which has no such field. Name the responses that carry it instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:06:38 -04:00
orchestrator-agent e8ec2dac22 Photo backfill UI: fix-recipe failures and trigger (Maestro 7a468d2d, Feature 23 task 2) (#206)
CI / ci (push) Successful in 9m44s
CI / ci (pull_request) Successful in 9m41s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 22:55:33 -04:00
orchestrator-agent 6127c5c2d1 Photo backfill for failed dinners without a photo (Maestro 36c40130, Feature 23 task 1) (#205)
CI / ci (push) Successful in 10m20s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-11 21:44:55 -04:00
24 changed files with 1623 additions and 778 deletions
+23
View File
@@ -259,6 +259,29 @@ automatic** (D-021).
unarchived dinner still `pending` with no `ground_dinner` job for its revision
queued or running is set `failed` with `interrupted`, so no dinner stays
pending with nothing left to ground it.
Photo backfill. `POST /api/v1/settings/photo-backfill` (CSRF-checked)
enqueues a `backfill_dinner_photos` job and answers 202 `{id}` without
waiting for it; while one is queued or running it enqueues nothing and
answers with that job's id, so two passes never overlap, across restarts
too. The kind is refused by `POST /api/v1/jobs`. The job
grounds, one at a time with `Ground` on its name, every unarchived dinner
that is `failed` with no photo and no pasted `recipe_url` — grounded,
`manual` and `pending` dinners are never listed, and a pasted link is
never replaced by a search. Success sets grounding_status `grounded` as
the `ground_dinner` job does (the recipe keeps its source URL and site);
failure leaves the dinner `failed` with the new `grounding_error`, for
the fix-recipe flow. One dinner's failure never stops the others; a
dinner renamed, archived or grounded by hand meanwhile is left as it is.
Its result is `{dinners: [{dinner_id, outcome, reason}]}`, outcome
`grounded`, `failed` (with its reason) or `skipped`. A dinner whose write
fails is `failed` with `grounding_error` and left as it was; the others
still run, then the job fails with `internal_error`, keeping that result.
The responses returning a `dinners.Dinner` representation (the list; the
create, update, archive, favorite and grounding responses; the idea
responses) carry `photo_backfill_eligible`, and
`GET /api/v1/dinners/{id}` does not; it says whether the backfill grounds
that dinner, computed by the same condition it lists dinners by, so a
client offers a search only where one runs.
`GET /api/v1/dinners/{id}/recipe-candidates`
searches for the dinner's name and returns `{candidates: [{candidate_id,
title, site_name, photo_url, minutes}]}`, at most 3, ranked as `Ground`
-50
View File
@@ -1,50 +0,0 @@
# Empty, loading and error states
Every route the PWA serves — the handoff's routes plus the added Settings
screens — with its empty, loading and error state. The handoff does not mock
these states; it gives one pattern for each
([handoff README](../design/MealQuest%20UI%20Designs/design_handoff_mealquest/README.md),
"Empty / loading / error states"), and every screen uses the shared component
for it:
| State | Component | Pattern |
| --- | --- | --- |
| Loading | `Skeleton` ([web/src/ui/Skeleton.tsx](../web/src/ui/Skeleton.tsx)) | Pill-shaped skeletons in `--color-surface` with a 1.2 s opacity pulse (no pulse under reduced motion). |
| Error | `ErrorBanner` ([web/src/ui/ErrorBanner.tsx](../web/src/ui/ErrorBanner.tsx)) | `--color-neutral-100` card, grape-700 (`--color-accent-700`) text, and a **Retry** button that reads or asks again. |
| Empty | `EmptyState` ([web/src/ui/EmptyState.tsx](../web/src/ui/EmptyState.tsx)) | Bagel title, body text and an optional primary action — the Discover deck's "All caught up / New side quests arrive Sunday." with "Get more now" is the model. |
[web/src/states.test.tsx](../web/src/states.test.tsx) renders the loading,
empty and error variant of each route below against a stubbed API, asserting
the copy and pressing Retry. Each screen's own test file covers its other
states.
The loading and error rows name the screen's main read. Smaller reads on a
screen (a Quest log count, a calendar list, a webhook card) use the same
`Skeleton` and `ErrorBanner` in place.
## Handoff routes
| Route | Screen | Empty | Loading | Error |
| --- | --- | --- | --- | --- |
| `/` | Home | `EmptyState` "No quest planned tonight" with **Plan this week**; rows that do not apply are hidden. | Skeletons for the date, Tonight card and status rows. | "Couldn't load tonight's quest. …" + Retry. |
| `/onboarding` | Onboarding — Welcome step | None: the step reads nothing; it shows the welcome and the Regulars, Side quests and Quest log terms with **Let's start**. | None: nothing to read. | None: nothing to read. |
| `/onboarding/regulars` | Onboarding — Regulars step | "Your regulars · 0" with the Common picks to add; **Next: notifications** stays disabled until enough Regulars are saved. | Skeleton under "Your regulars". | "Your Regulars could not load: …" + Retry. |
| `/onboarding/notifications` | Onboarding — Notifications step | None: the step reads nothing. | While finishing, **Turn on notifications** and **Maybe later** are disabled. | "Could not finish setting up: …"; the buttons come back to try again, with **Add Regulars** when fewer than three Regulars are saved. |
| `/plan` | Weekly plan (Draft board) | `EmptyState` "Nothing planned yet" with **Draft this week**. | Seven skeleton tiles ("Plan loading"; "Drafting this week" while the AI drafts). | "Couldn't load this week's plan. …" + Retry. |
| `/plan/shopping` | Supply run | `EmptyState` "No shopping list yet" with **Make the shopping list**; an archived week with none shows "No shopping list". A list with no ingredients shows "Nothing to buy". | Skeleton title and dinner cards. | "Couldn't load the shopping list. …" + Retry. |
| `/plan/shopping/archive` | Supply archive | `EmptyState` "Nothing archived yet" — "Each week's Supply run becomes a post here once it's made." | Skeleton posts. | "Couldn't load the Supply archive. …" + Retry. |
| `/discover` | Side quests deck | `EmptyState` **"All caught up"** — "New side quests arrive Sunday." with **Get more now**. | Skeleton card stack (also while a batch finds more). | "Couldn't load your side quests. …" + Retry. |
| `/quick` | Quick dinners | No ideas found: "Couldn't find Quick dinners. No ideas came back with a recipe to match." + Retry (an `ErrorBanner`, as no idea was found to show). | Six skeleton rows while the tab's ideas are found. | "Couldn't start looking for Quick dinners. …" + Retry. |
| `/cook-from-pantry` | Cook from what I have | Before a search: no ingredient chips and **Find ideas** disabled. No ideas found: "Couldn't find ideas. No ideas came back with a recipe to match." + Retry. | Five skeleton cards ("Ideas loading"). | "Couldn't start looking for ideas. …" + Retry. |
| `/dinner/:id` | Dinner detail | `EmptyState` "Dinner not found" — "This dinner isn't in our kitchen — it may have been removed." | Skeleton hero, title and lines. | "Couldn't load this dinner. …" + Retry. |
| `/settings` | Settings home | Status lines "None on rotation yet", "Not connected", "Push off · Google Chat not connected". | A skeleton per status line. | "Settings could not load: …" + Retry, which reads every failed status again. |
| `/settings/notifications` | Notifications & Google Chat | No webhook stored: the webhook form with **Save webhook**. | Skeletons for the channel matrix and the webhook card. | "Notification settings could not load: …" + Retry ("The Google Chat webhook could not load: …" for the webhook card). A failed webhook test says "Test message not sent: …" under the form. |
| `/settings/ai` | Admin · AI assistant | No key stored: "No key stored" with **Add key**. | Skeleton. | "AI settings could not load: …" + Retry. |
## Added Settings screens
| Route | Screen | Empty | Loading | Error |
| --- | --- | --- | --- | --- |
| `/settings/household` | Household | None: the household's settings always exist, seeded on first start; the screen shows the seeded values. | Skeleton. | "Household settings could not load: …" + Retry. |
| `/settings/regulars` | Regular dinners | "On rotation · 0" — "No Regulars yet. Add the dinners your household makes on repeat." | Skeleton. | "Your Regulars could not load: …" + Retry. |
| `/settings/calendar` | Google Calendar | "Not connected" with **Connect Google Calendar**. | Skeleton (and one for the calendar picker). | "Google Calendar status could not load: …" + Retry ("Your calendars could not load: …" + Retry for the picker). |
+164
View File
@@ -214,6 +214,26 @@ func (q *Queries) FailPendingDinnerGrounding(ctx context.Context, arg FailPendin
return result.RowsAffected()
}
const getActivePhotoBackfillJob = `-- name: GetActivePhotoBackfillJob :one
SELECT id
FROM jobs
WHERE kind = ?1
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1
`
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
func (q *Queries) GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error) {
row := q.db.QueryRowContext(ctx, getActivePhotoBackfillJob, kind)
var id string
err := row.Scan(&id)
return id, err
}
const getDinner = `-- name: GetDinner :one
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE id = ?
@@ -344,6 +364,58 @@ func (q *Queries) GroundDinner(ctx context.Context, arg GroundDinnerParams) (Din
return i, err
}
const groundFailedDinner = `-- name: GroundFailedDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
photo_id = ?2,
method = COALESCE(method, ?3),
minutes_total = COALESCE(minutes_total, ?4),
difficulty = COALESCE(difficulty, ?5),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = ?6
WHERE id = ?7
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?8
`
type GroundFailedDinnerParams struct {
RecipeID sql.NullString
PhotoID sql.NullString
Method sql.NullString
MinutesTotal sql.NullInt64
Difficulty sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
func (q *Queries) GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error) {
result, err := q.db.ExecContext(ctx, groundFailedDinner,
arg.RecipeID,
arg.PhotoID,
arg.Method,
arg.MinutesTotal,
arg.Difficulty,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const groundPendingDinner = `-- name: GroundPendingDinner :execrows
UPDATE dinners
SET recipe_id = ?1,
@@ -444,6 +516,64 @@ func (q *Queries) ListDinnersByStatus(ctx context.Context, status string) ([]Din
return items, nil
}
const listPhotoBackfillDinners = `-- name: ListPhotoBackfillDinners :many
SELECT id, name, normalized_name, status, origin, method, minutes_total, difficulty, cuisine, is_favorite, recipe_id, photo_id, grounding_status, updated_by, created_at, updated_at, recipe_url, grounding_error, grounding_revision FROM dinners
WHERE status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(?1, id)
ORDER BY normalized_name, id
`
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
func (q *Queries) ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error) {
rows, err := q.db.QueryContext(ctx, listPhotoBackfillDinners, id)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Dinner
for rows.Next() {
var i Dinner
if err := rows.Scan(
&i.ID,
&i.Name,
&i.NormalizedName,
&i.Status,
&i.Origin,
&i.Method,
&i.MinutesTotal,
&i.Difficulty,
&i.Cuisine,
&i.IsFavorite,
&i.RecipeID,
&i.PhotoID,
&i.GroundingStatus,
&i.UpdatedBy,
&i.CreatedAt,
&i.UpdatedAt,
&i.RecipeUrl,
&i.GroundingError,
&i.GroundingRevision,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listUnarchivedDinners = `-- name: ListUnarchivedDinners :many
SELECT dinners.id, dinners.name, dinners.normalized_name, dinners.status, dinners.origin, dinners.method, dinners.minutes_total, dinners.difficulty, dinners.cuisine, dinners.is_favorite, dinners.recipe_id, dinners.photo_id, dinners.grounding_status, dinners.updated_by, dinners.created_at, dinners.updated_at, dinners.recipe_url, dinners.grounding_error, dinners.grounding_revision, recipes.source_url AS recipe_source_url
FROM dinners
@@ -560,6 +690,40 @@ func (q *Queries) ListUnplannedQuestLogDinners(ctx context.Context, fromWeekStar
return items, nil
}
const refailDinnerGrounding = `-- name: RefailDinnerGrounding :execrows
UPDATE dinners
SET grounding_error = ?1,
updated_at = ?2
WHERE id = ?3
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = ?4
`
type RefailDinnerGroundingParams struct {
GroundingError sql.NullString
UpdatedAt string
ID string
GroundingRevision int64
}
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
func (q *Queries) RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error) {
result, err := q.db.ExecContext(ctx, refailDinnerGrounding,
arg.GroundingError,
arg.UpdatedAt,
arg.ID,
arg.GroundingRevision,
)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const setDinnerFavorite = `-- name: SetDinnerFavorite :one
UPDATE dinners
SET is_favorite = ?,
+20
View File
@@ -117,6 +117,10 @@ type Querier interface {
// when there is none, so there is at most one. Returns sql.ErrNoRows when
// there is none.
GetActiveDraftJob(ctx context.Context, arg GetActiveDraftJobParams) (GetActiveDraftJobRow, error)
// The id of the full photo backfill job (no dinner_id) of kind queued or
// running. StartPhotoBackfill enqueues one only when there is none, so
// there is at most one. Returns sql.ErrNoRows when there is none.
GetActivePhotoBackfillJob(ctx context.Context, kind string) (string, error)
// The batch still pending or running, oldest first. Returns sql.ErrNoRows
// when there is none.
GetActiveSuggestionBatch(ctx context.Context) (SuggestionBatch, error)
@@ -175,6 +179,13 @@ type Querier interface {
// unknown id, returns sql.ErrNoRows. A method, minutes_total or difficulty
// no longer as observed is kept: a member edited it while it was grounding.
GroundDinner(ctx context.Context, arg GroundDinnerParams) (Dinner, error)
// Records a photo backfill's grounding of a dinner it listed, while the
// dinner is still unarchived, failed, photoless, without a recipe_url and at
// the grounding_revision it was listed at; a dinner grounded by hand,
// renamed or archived meanwhile is left as it is (0 rows). Like
// GroundPendingDinner it keeps a method, minutes_total or difficulty a
// member already set.
GroundFailedDinner(ctx context.Context, arg GroundFailedDinnerParams) (int64, error)
// Records the background grounding of an unarchived dinner still pending at
// the grounding_revision it was grounded for; a dinner grounded some other
// way, renamed or archived meanwhile is left as it is (0 rows). A method,
@@ -206,6 +217,12 @@ type Querier interface {
// The pending Rotation checks whose dinner is still tried, oldest plan
// date first, with their dinners.
ListPendingRotationReviews(ctx context.Context) ([]ListPendingRotationReviewsRow, error)
// The dinners a photo backfill grounds by name: unarchived, failed, with no
// photo and no pasted recipe_url (the household's own link is never
// replaced by a search), ordered by normalized name. A non-NULL id narrows
// the list to that dinner. Grounded, manual and pending dinners are never
// listed: they have a recipe, or a ground_dinner job grounding them.
ListPhotoBackfillDinners(ctx context.Context, id sql.NullString) ([]Dinner, error)
// The days of plan plan_id with their dinners, Monday first. A day with
// no dinner is left out.
ListPlanDays(ctx context.Context, planID string) ([]ListPlanDaysRow, error)
@@ -307,6 +324,9 @@ type Querier interface {
// Records a new draft over plan id: its status and note, raising its
// revision.
RedraftWeekPlan(ctx context.Context, arg RedraftWeekPlanParams) error
// Records why a photo backfill could not ground a dinner it listed, under
// the same conditions as GroundFailedDinner (0 rows otherwise).
RefailDinnerGrounding(ctx context.Context, arg RefailDinnerGroundingParams) (int64, error)
// Records a generation of list id from plan plan_id.
RegenerateShoppingList(ctx context.Context, arg RegenerateShoppingListParams) error
// Records a failed delivery and when to try again. The lease must still be
+199
View File
@@ -0,0 +1,199 @@
package dinners
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// KindBackfillPhotos is the job that grounds, by name, the dinners left
// without a photo: those whose grounding failed and that have no pasted
// recipe_url (ListPhotoBackfillDinners).
const KindBackfillPhotos = "backfill_dinner_photos"
// Photo backfill outcomes for one dinner.
const (
BackfillGrounded = "grounded"
BackfillFailed = "failed"
// BackfillSkipped: the dinner was grounded by hand, renamed or archived
// while the backfill grounded it, so the backfill left it as it is.
BackfillSkipped = "skipped"
)
// PhotoBackfillEligible reports whether a photo backfill grounds r: the
// condition ListPhotoBackfillDinners lists dinners by, and GroundFailedDinner
// and RefailDinnerGrounding write under, for one dinner. The dinner is
// unarchived, failed, with no photo and no pasted recipe_url.
func PhotoBackfillEligible(r db.Dinner) bool {
return r.Status != StatusArchived &&
r.GroundingStatus == GroundingFailed &&
!r.PhotoID.Valid &&
!r.RecipeUrl.Valid
}
// BackfillJob is the KindBackfillPhotos payload. DinnerID narrows the
// backfill to that dinner; empty backfills every dinner without a photo.
type BackfillJob struct {
DinnerID string `json:"dinner_id,omitempty"`
}
// BackfillOutcome is what a photo backfill did for one dinner. Reason is
// set only for BackfillFailed: the Reason recorded as its grounding_error.
type BackfillOutcome struct {
DinnerID string `json:"dinner_id"`
Outcome string `json:"outcome"`
Reason *string `json:"reason"`
}
// backfillResult is the KindBackfillPhotos result: the outcome of each
// dinner the backfill tried, in the order it tried them.
type backfillResult struct {
Dinners []BackfillOutcome `json:"dinners"`
}
func (r backfillResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// StartPhotoBackfill enqueues a KindBackfillPhotos job over every dinner
// without a photo and returns its id, without waiting for it. While a full
// backfill is queued or running it enqueues nothing and returns that job's
// id instead, so two passes never ground the same dinner at once; the jobs
// table holds the claim, so it holds across restarts too.
func (s *Service) StartPhotoBackfill(ctx context.Context) (string, error) {
var id string
created := false
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
active, err := db.New(tx).GetActivePhotoBackfillJob(ctx, KindBackfillPhotos)
switch {
case errors.Is(err, sql.ErrNoRows):
case err != nil:
return fmt.Errorf("dinners: read active photo backfill: %w", err)
default:
id = active
return nil
}
id, err = s.backfillJob.EnqueueTx(ctx, tx, BackfillJob{})
if err != nil {
return fmt.Errorf("dinners: enqueue photo backfill: %w", err)
}
created = true
return nil
})
if err != nil {
return "", err
}
if created {
s.backfillJob.Wake()
}
return id, nil
}
// errBackfillNotRecorded fails a photo backfill that could not record the
// outcome of one or more of its dinners.
var errBackfillNotRecorded = errors.New("dinners: photo backfill: dinner outcomes not recorded")
// runBackfillJob grounds each dinner ListPhotoBackfillDinners lists for p,
// one at a time, with recipes.Grounder.Ground on its name: the first ranked
// candidate whose photo caches and whose ingredients normalize grounds it,
// with the recipe's source URL and site kept as any grounding keeps them.
// Success records the recipe and photo with grounding_status grounded;
// failure leaves the dinner failed with the new Reason as its
// grounding_error, for the household to fix its recipe by hand. One
// dinner's failure, its search's, model's or database write's, never stops
// the others. A failure to list the dinners, or the job's context ending,
// fails the job at once. A write that failed fails the job once every
// dinner has been tried, as a grounding job's failed write does, with the
// outcomes published as its result, so the job never reports done for a
// dinner whose outcome is not stored.
func (s *Service) runBackfillJob(ctx context.Context, p BackfillJob, publish func(backfillResult) error) (backfillResult, error) {
q := db.New(s.st.Reader)
rows, err := q.ListPhotoBackfillDinners(ctx, sql.NullString{String: p.DinnerID, Valid: p.DinnerID != ""})
if err != nil {
return backfillResult{}, fmt.Errorf("dinners: photo backfill: list: %w", err)
}
res := backfillResult{Dinners: make([]BackfillOutcome, 0, len(rows))}
grounded, unrecorded := false, false
for _, row := range rows {
out, recorded, err := s.backfillDinner(ctx, row)
if err != nil {
return backfillResult{}, err
}
grounded = grounded || out.Outcome == BackfillGrounded
unrecorded = unrecorded || !recorded
res.Dinners = append(res.Dinners, out)
}
if grounded {
s.wake()
}
if unrecorded {
if err := publish(res); err != nil {
return backfillResult{}, errors.Join(errBackfillNotRecorded, err)
}
return backfillResult{}, errBackfillNotRecorded
}
return res, nil
}
// backfillDinner grounds row, a dinner ListPhotoBackfillDinners listed, as
// runBackfillJob describes, and reports whether its outcome was recorded.
// Its error is only the job's context ending.
func (s *Service) backfillDinner(ctx context.Context, row db.Dinner) (BackfillOutcome, bool, error) {
out := BackfillOutcome{DinnerID: row.ID}
w := db.New(s.st.Writer)
gr, err := s.grounder.Ground(ctx, row.Name, row.Name)
if ctxErr := ctx.Err(); ctxErr != nil {
return BackfillOutcome{}, false, ctxErr
}
if err != nil {
reason := reasonFor(err, false)
// Only the reason is logged: the error may quote a dependency's
// response, which jobs never store, return or log.
s.logger.InfoContext(ctx, "dinner photo backfill failed", "dinner_id", row.ID, "reason", reason)
n, err := w.RefailDinnerGrounding(ctx, db.RefailDinnerGroundingParams{
GroundingError: sql.NullString{String: reason, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillFailed, reason, n, err)
return out, recorded, nil
}
n, err := w.GroundFailedDinner(ctx, db.GroundFailedDinnerParams{
RecipeID: sql.NullString{String: gr.RecipeID, Valid: true},
PhotoID: sql.NullString{String: gr.PhotoID, Valid: true},
Method: sql.NullString{String: gr.Method, Valid: true},
MinutesTotal: nullInt(gr.MinutesTotal),
Difficulty: sql.NullString{String: gr.Difficulty, Valid: true},
UpdatedAt: store.Now().String(),
ID: row.ID,
GroundingRevision: row.GroundingRevision,
})
out, recorded := s.backfillRecorded(ctx, out, BackfillGrounded, "", n, err)
return out, recorded, nil
}
// backfillRecorded is out once the backfill recorded outcome (with reason,
// for BackfillFailed) on the dinner, writing n rows with err: a dinner that
// changed meanwhile (no row) is BackfillSkipped, and one whose write failed
// is BackfillFailed with ReasonGroundingError, the dinner left as it was,
// and not recorded.
func (s *Service) backfillRecorded(ctx context.Context, out BackfillOutcome, outcome, reason string, n int64, err error) (BackfillOutcome, bool) {
switch {
case err != nil:
// The error is a database error, never a dependency's text.
s.logger.ErrorContext(ctx, "dinner photo backfill not recorded", "dinner_id", out.DinnerID, "error", err)
out.Outcome, reason = BackfillFailed, ReasonGroundingError
case n == 0:
out.Outcome, reason = BackfillSkipped, ""
default:
out.Outcome = outcome
}
if reason != "" {
out.Reason = &reason
}
return out, err == nil
}
+425
View File
@@ -0,0 +1,425 @@
package dinners_test
import (
"database/sql"
"encoding/json"
"errors"
"slices"
"sync"
"testing"
"time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/db"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/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/recipes/recipestest"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/store"
)
// failedDinner stores a Regular named name whose grounding failed with
// no_recipe_found, without a photo or recipe_url, and returns its id.
func (e *env) failedDinner(t *testing.T, name string) string {
t.Helper()
q := db.New(e.st.Writer)
now := store.Now().String()
row, err := q.CreateDinner(t.Context(), db.CreateDinnerParams{
ID: store.NewID(),
Name: name,
NormalizedName: harness.Normalize(name),
Status: dinners.StatusRegular,
Origin: "regular",
GroundingStatus: dinners.GroundingPending,
UpdatedBy: sql.NullString{String: member, Valid: true},
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatal(err)
}
if _, err := q.FailPendingDinnerGrounding(t.Context(), db.FailPendingDinnerGroundingParams{
GroundingError: sql.NullString{String: dinners.ReasonNoRecipe, Valid: true},
UpdatedAt: now,
ID: row.ID,
GroundingRevision: row.GroundingRevision,
}); err != nil {
t.Fatal(err)
}
return row.ID
}
func outcome(id, out string, reason *string) dinners.BackfillOutcome {
return dinners.BackfillOutcome{DinnerID: id, Outcome: out, Reason: reason}
}
func checkOutcomes(t *testing.T, got, want []dinners.BackfillOutcome) {
t.Helper()
gj, _ := json.Marshal(got)
wj, _ := json.Marshal(want)
if string(gj) != string(wj) {
t.Fatalf("outcomes = %s, want %s", gj, wj)
}
}
func TestBackfillGroundsFirstCandidateWithAPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
// curryA ranks first but its photo is gone; curryB's photo caches.
delete(e.images.JPEGs, "https://a.example/curry.jpg")
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 25))
id := e.failedDinner(t, "Chicken curry")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(id, dinners.BackfillGrounded, nil)})
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid || !row.RecipeID.Valid || row.GroundingError.Valid {
t.Fatalf("dinner = %+v, want grounded with a photo and no error", row)
}
rec, err := db.New(e.st.Reader).GetRecipe(t.Context(), row.RecipeID.String)
if err != nil {
t.Fatal(err)
}
if rec.SourceUrl != curryB || rec.SiteName.String != "B Kitchen" || rec.PhotoID != row.PhotoID {
t.Errorf("recipe = %s from %q photo %s, want %s from B Kitchen photo %s", rec.SourceUrl, rec.SiteName.String, rec.PhotoID.String, curryB, row.PhotoID.String)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want the dinner's name", got)
}
}
func TestBackfillReusesCachedRecipeAndPhoto(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Results["Curry chicken"] = []recipes.Candidate{{URL: curryA}}
// One reply: the second dinner must reuse the recipe the first stored.
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
first := e.failedDinner(t, "Chicken curry")
second := e.failedDinner(t, "Curry chicken")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(first, dinners.BackfillGrounded, nil),
outcome(second, dinners.BackfillGrounded, nil),
})
a, b := e.dinner(t, first), e.dinner(t, second)
if a.RecipeID != b.RecipeID || a.PhotoID != b.PhotoID || !a.PhotoID.Valid {
t.Errorf("dinners grounded in recipe %s photo %s and recipe %s photo %s, want the same cached ones",
a.RecipeID.String, a.PhotoID.String, b.RecipeID.String, b.PhotoID.String)
}
if n := len(e.model.Requests()); n != 1 {
t.Errorf("normalize_recipe ran %d times, want 1", n)
}
if n := slices.Index(e.pages.Fetched(), curryA); n < 0 || slices.Contains(e.pages.Fetched()[n+1:], curryA) {
t.Errorf("fetched %q, want %s fetched once", e.pages.Fetched(), curryA)
}
}
func TestBackfillIsolatesEachDinnersFailure(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.search.Fail = map[string]error{"Broken search": errors.New("search provider down")}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
// Listed by normalized name: both failures come before the curry.
broken := e.failedDinner(t, "Broken search")
curry := e.failedDinner(t, "Chicken curry")
absent := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(broken, dinners.BackfillFailed, ptr(dinners.ReasonSearchFailed)),
outcome(curry, dinners.BackfillGrounded, nil),
})
for id, reason := range map[string]string{absent: dinners.ReasonNoRecipe, broken: dinners.ReasonSearchFailed} {
row := e.dinner(t, id)
if row.GroundingStatus != dinners.GroundingFailed || row.GroundingError.String != reason || row.PhotoID.Valid {
t.Errorf("dinner %s = %+v, want failed with %s and no photo", row.Name, row, reason)
}
}
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingGrounded || !row.PhotoID.Valid {
t.Errorf("curry = %+v, want grounded with a photo", row)
}
}
func TestBackfillSkipsDinnersNotMissingAPhoto(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *c.GroundingJobID)
if row := e.dinner(t, c.ID); row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded", row)
}
// A failed dinner with a pasted link keeps that link for a fix by hand.
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
if row := e.dinner(t, linked.ID); row.GroundingStatus != dinners.GroundingFailed {
t.Fatalf("linked dinner = %+v, want failed", row)
}
archived := e.failedDinner(t, "Old stew")
if _, err := e.svc.Archive(t.Context(), archived, member); err != nil {
t.Fatal(err)
}
searched := len(e.search.Queries())
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Kind != dinners.KindBackfillPhotos || string(job.Result) != `{"dinners":[]}` {
t.Fatalf("job = %+v (result %s), want a backfill with no dinners", job, job.Result)
}
if got := e.search.Queries()[searched:]; len(got) != 0 {
t.Errorf("backfill searched %q, want nothing", got)
}
}
// TestPhotoBackfillEligibleIsWhatTheBackfillLists holds the API's
// photo_backfill_eligible to ListPhotoBackfillDinners for a dinner the
// backfill grounds and for each kind it never does.
func TestPhotoBackfillEligibleIsWhatTheBackfillLists(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
grounded, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Chicken curry")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *grounded.GroundingJobID)
linked, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr("Mystery pie"), RecipeURL: ptr("https://d.example/pie")}, member)
if err != nil {
t.Fatal(err)
}
e.waitJob(t, *linked.GroundingJobID)
failed := e.failedDinner(t, "Abalone surprise")
archived, err := e.svc.Archive(t.Context(), e.failedDinner(t, "Old stew"), member)
if err != nil {
t.Fatal(err)
}
shown := map[string]dinners.Dinner{archived.ID: archived}
regulars, err := e.svc.List(t.Context(), dinners.StatusRegular)
if err != nil {
t.Fatal(err)
}
for _, d := range regulars {
shown[d.ID] = d
}
q := db.New(e.st.Reader)
for id, want := range map[string]bool{grounded.ID: false, linked.ID: false, failed: true, archived.ID: false} {
listed, err := q.ListPhotoBackfillDinners(t.Context(), sql.NullString{String: id, Valid: true})
if err != nil {
t.Fatal(err)
}
d, ok := shown[id]
if !ok {
t.Fatalf("dinner %s not shown", id)
}
if got := len(listed) == 1; got != want || d.PhotoBackfillEligible != want {
t.Errorf("%s: listed %t, photo_backfill_eligible %t, want both %t", d.Name, got, d.PhotoBackfillEligible, want)
}
}
}
func TestBackfillOfOneDinnerGroundsOnlyIt(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
curry := e.failedDinner(t, "Chicken curry")
other := e.failedDinner(t, "Abalone surprise")
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: curry})
if err != nil {
t.Fatal(err)
}
checkOutcomes(t, got, []dinners.BackfillOutcome{outcome(curry, dinners.BackfillGrounded, nil)})
if row := e.dinner(t, other); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("other dinner = %+v, want left failed", row)
}
if got := e.search.Queries(); !slices.Equal(got, []string{"Chicken curry"}) {
t.Errorf("searched %q, want only the dinner's name", got)
}
}
func TestBackfillLeavesDinnerChangedWhileGrounding(t *testing.T) {
t.Parallel()
e := newUnstartedEnv(t)
id := e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
done := make(chan []dinners.BackfillOutcome)
go func() {
got, err := e.svc.RunBackfillJob(t.Context(), dinners.BackfillJob{DinnerID: id})
if err != nil {
t.Error(err)
}
done <- got
}()
// The backfill listed the dinner and waits at its search.
for len(e.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
if _, err := e.svc.Update(t.Context(), id, dinners.Patch{Name: ptr("Chicken korma")}, member); err != nil {
t.Fatal(err)
}
close(gate)
checkOutcomes(t, <-done, []dinners.BackfillOutcome{outcome(id, dinners.BackfillSkipped, nil)})
if row := e.dinner(t, id); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("dinner = %+v, want left failed without a photo", row)
}
}
// triggerAtOnce calls svc.StartPhotoBackfill from n goroutines released
// together and returns the job ids they got.
func triggerAtOnce(t *testing.T, svc *dinners.Service, n int) []string {
t.Helper()
ids := make([]string, n)
errs := make([]error, n)
release := make(chan struct{})
var wg sync.WaitGroup
for i := range n {
wg.Add(1)
go func() {
defer wg.Done()
<-release
ids[i], errs[i] = svc.StartPhotoBackfill(t.Context())
}()
}
close(release)
wg.Wait()
if err := errors.Join(errs...); err != nil {
t.Fatal(err)
}
return ids
}
// backfillJobs counts the KindBackfillPhotos jobs in st.
func backfillJobs(t *testing.T, st *store.Store) int {
t.Helper()
var n int
if err := st.Reader.QueryRowContext(t.Context(), `SELECT count(*) FROM jobs WHERE kind = ?`, dinners.KindBackfillPhotos).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
func TestStartPhotoBackfillIsSingleFlight(t *testing.T) {
t.Parallel()
const triggers = 8
dataDir := t.TempDir()
st := openStore(t, dataDir)
e := newUnstartedEnvOn(t, st, dataDir)
e.failedDinner(t, "Chicken curry")
gate := make(chan struct{})
e.search.Gate = gate
// Queued: simultaneous triggers all get the one pass they enqueued.
ids := triggerAtOnce(t, e.svc, triggers)
first := ids[0]
for _, id := range ids {
if id != first {
t.Fatalf("simultaneous triggers = %q, want one job id", ids)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs after simultaneous triggers, want 1", n)
}
// After a restart the queued pass is still the one: the claim is the
// stored job, not the process's memory.
if err := st.Close(); err != nil {
t.Fatal(err)
}
st = openStore(t, dataDir)
restarted := newUnstartedEnvOn(t, st, dataDir)
restarted.search.Gate = gate
restarted.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if again, err := restarted.svc.StartPhotoBackfill(t.Context()); err != nil || again != first {
t.Fatalf("trigger after restart = %q, %v; want the queued %q", again, err, first)
}
// Running: the pass waits at its search, and simultaneous triggers all
// return it.
restarted.start(t)
for len(restarted.search.Queries()) == 0 {
time.Sleep(time.Millisecond)
}
for _, id := range triggerAtOnce(t, restarted.svc, triggers) {
if id != first {
t.Fatalf("trigger while running = %q, want the running %q", id, first)
}
}
if n := backfillJobs(t, st); n != 1 {
t.Fatalf("%d backfill jobs, want 1", n)
}
// Finished: the next trigger starts a new pass.
close(gate)
if job := restarted.waitJob(t, first); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v (result %s), want succeeded", job, job.Result)
}
next, err := restarted.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
if next == first {
t.Fatalf("trigger after the pass finished = %q, want a new job", next)
}
restarted.waitJob(t, next)
}
func TestBackfillFailsJobWhenAWriteFails(t *testing.T) {
t.Parallel()
e := newEnv(t)
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
absent := e.failedDinner(t, "Abalone surprise")
curry := e.failedDinner(t, "Chicken curry")
if _, err := e.st.Writer.ExecContext(t.Context(), `CREATE TRIGGER refuse_grounded BEFORE UPDATE OF grounding_status ON dinners
WHEN NEW.id = '`+curry+`' AND NEW.grounding_status = 'grounded'
BEGIN SELECT RAISE(ABORT, 'grounded refused'); END`); err != nil {
t.Fatal(err)
}
id, err := e.svc.StartPhotoBackfill(t.Context())
if err != nil {
t.Fatal(err)
}
job := e.waitJob(t, id)
if job.Status != jobs.StatusFailed || job.Error == nil || *job.Error != jobs.CodeInternal {
t.Fatalf("job = %+v, want failed with %s", job, jobs.CodeInternal)
}
var got struct {
Dinners []dinners.BackfillOutcome `json:"dinners"`
}
if err := json.Unmarshal(job.Result, &got); err != nil {
t.Fatalf("result %s: %v", job.Result, err)
}
// The other dinner was still tried and recorded.
checkOutcomes(t, got.Dinners, []dinners.BackfillOutcome{
outcome(absent, dinners.BackfillFailed, ptr(dinners.ReasonNoRecipe)),
outcome(curry, dinners.BackfillFailed, ptr(dinners.ReasonGroundingError)),
})
if row := e.dinner(t, curry); row.GroundingStatus != dinners.GroundingFailed || row.PhotoID.Valid {
t.Errorf("curry = %+v, want left failed without a photo", row)
}
}
+19 -2
View File
@@ -79,6 +79,11 @@ type Dinner struct {
UpdatedBy *string `json:"updated_by"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
// PhotoBackfillEligible is whether a photo backfill grounds the dinner
// (PhotoBackfillEligible): false for one with a pasted recipe link,
// which a search never replaces.
PhotoBackfillEligible bool `json:"photo_backfill_eligible"`
}
// NewRegular is a Regular to add: its Name, which the household always
@@ -138,6 +143,8 @@ type Service struct {
grounder *recipes.Grounder
logger *slog.Logger
groundJob *jobs.Kind[GroundJob]
// backfillJob enqueues KindBackfillPhotos jobs.
backfillJob *jobs.Kind[BackfillJob]
// wake is called after every committed change to a dinner's method or
// minutes, which can move its crock-pot start reminder.
wake func()
@@ -162,8 +169,8 @@ type Service struct {
// NewService returns a service over st that grounds dinners with grounder,
// calls wake (the scheduler's Wake) after every committed change to a
// dinner's method or minutes, and registers the KindGroundDinner job on
// runner, which must not have started yet.
// dinner's method or minutes, and registers the KindGroundDinner and
// KindBackfillPhotos jobs on runner, which must not have started yet.
func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner, wake func(), logger *slog.Logger) (*Service, error) {
switch {
case st == nil:
@@ -184,6 +191,14 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
return nil, err
}
s.groundJob = kind
backfill, err := jobs.Register(runner, KindBackfillPhotos, s.runBackfillJob)
if err != nil {
return nil, err
}
if err := backfill.ServiceOnly(); err != nil {
return nil, err
}
s.backfillJob = backfill
return s, nil
}
@@ -702,6 +717,8 @@ func fromRow(r db.Dinner) Dinner {
UpdatedBy: nullString(r.UpdatedBy),
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
PhotoBackfillEligible: PhotoBackfillEligible(r),
}
if r.MinutesTotal.Valid {
d.MinutesTotal = &r.MinutesTotal.Int64
+15
View File
@@ -74,3 +74,18 @@ func (s *Service) RunGroundJob(ctx context.Context, p GroundJob) (string, error)
// SafeSourceURL is safeSourceURL.
var SafeSourceURL = safeSourceURL
// RunBackfillJob runs the KindBackfillPhotos handler for p in the caller's
// goroutine, as the job runner would, and returns its outcomes: on an
// error, those it last published.
func (s *Service) RunBackfillJob(ctx context.Context, p BackfillJob) ([]BackfillOutcome, error) {
var published backfillResult
r, err := s.runBackfillJob(ctx, p, func(partial backfillResult) error {
published = partial
return nil
})
if err != nil {
return published.Dinners, err
}
return r.Dinners, nil
}
+2 -1
View File
@@ -39,6 +39,7 @@ type env struct {
runner *jobs.Runner
search *recipestest.Search
pages *recipestest.Pages
images *recipestest.Images
model *llm.Fake
}
@@ -108,7 +109,7 @@ func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
if err != nil {
t.Fatal(err)
}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, model: model}
return &env{svc: svc, st: st, runner: runner, search: search, pages: pages, images: images, model: model}
}
// start starts the env's job runner, stopping it when the test ends.
+14
View File
@@ -112,6 +112,20 @@ func postDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
}
}
// postPhotoBackfill starts a photo backfill over every dinner without a
// photo and answers 202 with its job id, which GET /jobs/{id} reports on,
// without waiting for the job.
func postPhotoBackfill(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := svc.StartPhotoBackfill(r.Context())
if err != nil {
writeDinnerError(w, r, logger, "start photo backfill", err)
return
}
writeJSON(w, r, logger, http.StatusAccepted, jobCreated{ID: id})
}
}
// patchDinner edits a dinner, recording the session's actor as updated_by.
func patchDinner(logger *slog.Logger, svc *dinners.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+40
View File
@@ -556,3 +556,43 @@ func TestPostDinnerEnqueueFailureAnswersFailedDinner(t *testing.T) {
t.Errorf("listed = %v, want failed", got)
}
}
func TestPostPhotoBackfillGroundsFailedDinners(t *testing.T) {
t.Parallel()
e := newDinnersEnv(t).withRecipes(t)
e.start(t)
// No model reply is scripted, so grounding on creation fails.
rec, created := callDinners(t, e.h, http.MethodPost, dinnersPath, `{"name":"Chicken curry"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("POST = %d: %s", rec.Code, rec.Body)
}
id := created["id"].(string)
waitJob(t, e.h, created["grounding_job_id"].(string))
if d := listedDinner(t, e.h, id); d["grounding_status"] != "failed" || d["photo_url"] != nil {
t.Fatalf("dinner = %v, want failed without a photo", d)
}
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
rec, started := callDinners(t, e.h, http.MethodPost, "/api/v1/settings/photo-backfill", "")
jobID, _ := started["id"].(string)
if rec.Code != http.StatusAccepted || jobID == "" {
t.Fatalf("POST photo-backfill = %d: %s, want 202 with a job id", rec.Code, rec.Body)
}
job := waitJob(t, e.h, jobID)
want := fmt.Sprintf(`map[dinners:[map[dinner_id:%s outcome:grounded reason:<nil>]]]`, id)
if job["status"] != "succeeded" || job["kind"] != "backfill_dinner_photos" || fmt.Sprint(job["result"]) != want {
t.Fatalf("job = %v, want backfill_dinner_photos succeeded with %s", job, want)
}
d := listedDinner(t, e.h, id)
photo, _ := d["photo_url"].(string)
if d["grounding_status"] != "grounded" || d["grounding_error"] != nil || !strings.HasSuffix(photo, "-400.jpg") {
t.Errorf("dinner = %v, want grounded with a photo", d)
}
// The backfill is started only by its own endpoint.
rec, _ = callDinners(t, e.h, http.MethodPost, "/api/v1/jobs", `{"kind":"backfill_dinner_photos","payload":{}}`)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "service_only_kind") {
t.Errorf("POST /jobs backfill = %d: %s, want 400 service_only_kind", rec.Code, rec.Body)
}
}
+1
View File
@@ -162,6 +162,7 @@ func NewRouter(logger *slog.Logger, version string, trusted forwarded.Trusted, a
apiV1.Get("/settings/chat-webhook", getChatWebhook(logger, chatSvc))
apiV1.Put("/settings/chat-webhook", putChatWebhook(logger, chatSvc))
apiV1.Post("/settings/chat-webhook/test", postChatWebhookTest(logger, chatSvc))
apiV1.Post("/settings/photo-backfill", postPhotoBackfill(logger, dinnerSvc))
apiV1.Get("/dinners", getDinners(logger, dinnerSvc))
apiV1.Post("/dinners", postDinner(logger, dinnerSvc))
apiV1.Get("/dinners/{id}", getDinner(logger, dinnerSvc))
+62
View File
@@ -181,3 +181,65 @@ WHERE grounding_status = 'pending'
AND json_extract(jobs.payload, '$.dinner_id') = dinners.id
AND json_extract(jobs.payload, '$.revision') = dinners.grounding_revision
);
-- name: ListPhotoBackfillDinners :many
-- The dinners a photo backfill grounds by name: unarchived, failed, with no
-- photo and no pasted recipe_url (the household's own link is never
-- replaced by a search), ordered by normalized name. A non-NULL id narrows
-- the list to that dinner. Grounded, manual and pending dinners are never
-- listed: they have a recipe, or a ground_dinner job grounding them.
SELECT * FROM dinners
WHERE status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND id = COALESCE(sqlc.narg(id), id)
ORDER BY normalized_name, id;
-- name: GetActivePhotoBackfillJob :one
-- The id of the full photo backfill job (no dinner_id) of kind queued or
-- running. StartPhotoBackfill enqueues one only when there is none, so
-- there is at most one. Returns sql.ErrNoRows when there is none.
SELECT id
FROM jobs
WHERE kind = sqlc.arg(kind)
AND status IN ('queued', 'running')
AND json_extract(payload, '$.dinner_id') IS NULL
ORDER BY id
LIMIT 1;
-- name: GroundFailedDinner :execrows
-- Records a photo backfill's grounding of a dinner it listed, while the
-- dinner is still unarchived, failed, photoless, without a recipe_url and at
-- the grounding_revision it was listed at; a dinner grounded by hand,
-- renamed or archived meanwhile is left as it is (0 rows). Like
-- GroundPendingDinner it keeps a method, minutes_total or difficulty a
-- member already set.
UPDATE dinners
SET recipe_id = sqlc.arg(recipe_id),
photo_id = sqlc.arg(photo_id),
method = COALESCE(method, sqlc.arg(method)),
minutes_total = COALESCE(minutes_total, sqlc.arg(minutes_total)),
difficulty = COALESCE(difficulty, sqlc.arg(difficulty)),
grounding_status = 'grounded',
grounding_error = NULL,
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = sqlc.arg(grounding_revision);
-- name: RefailDinnerGrounding :execrows
-- Records why a photo backfill could not ground a dinner it listed, under
-- the same conditions as GroundFailedDinner (0 rows otherwise).
UPDATE dinners
SET grounding_error = sqlc.arg(grounding_error),
updated_at = sqlc.arg(updated_at)
WHERE id = sqlc.arg(id)
AND status <> 'archived'
AND grounding_status = 'failed'
AND photo_id IS NULL
AND recipe_url IS NULL
AND grounding_revision = sqlc.arg(grounding_revision);
@@ -52,6 +52,8 @@ function listed(is_favorite: boolean) {
is_favorite,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
}
}
+18 -2
View File
@@ -20,8 +20,12 @@ export type Difficulty = (typeof DIFFICULTIES)[number]
export type GroundingStatus = (typeof GROUNDING_STATUSES)[number]
// Regular is the part of a dinner the Regulars screens read. photo_id is
// the stored photo's id, from photo_url. pending is true while an
// optimistic add waits for the server; its id is not the server's.
// the stored photo's id, from photo_url. grounding_error is why its
// grounding failed, a reason code, set only while grounding_status is
// failed. photo_backfill_eligible is whether a photo backfill (Search
// again) searches for it: never for a dinner with a pasted recipe link.
// pending is true while an optimistic add waits for the server;
// its id is not the server's.
export type Regular = {
id: string
name: string
@@ -31,6 +35,8 @@ export type Regular = {
is_favorite: boolean
photo_id: string | undefined
grounding_status: GroundingStatus
grounding_error: string | null
photo_backfill_eligible: boolean
pending?: true
}
@@ -113,6 +119,9 @@ export function parseDinner(where: string, body: unknown): Regular {
throw new Error(`${where}: response is not a dinner`)
}
if (typeof body.is_favorite !== 'boolean') throw new Error(`${where}: is_favorite is not a boolean`)
if (typeof body.photo_backfill_eligible !== 'boolean') {
throw new Error(`${where}: photo_backfill_eligible is not a boolean`)
}
return {
id: body.id,
name: body.name,
@@ -122,6 +131,11 @@ export function parseDinner(where: string, body: unknown): Regular {
is_favorite: body.is_favorite,
photo_id: photoId(where, body.photo_url),
grounding_status: oneOf(where, 'grounding_status', GROUNDING_STATUSES, body.grounding_status),
grounding_error: nullable(body.grounding_error, (v) => {
if (typeof v !== 'string') throw new Error(`${where}: grounding_error is not a string`)
return v
}),
photo_backfill_eligible: body.photo_backfill_eligible,
}
}
@@ -225,6 +239,8 @@ export function useAddRegular() {
is_favorite: false,
photo_id: undefined,
grounding_status: 'pending',
grounding_error: null,
photo_backfill_eligible: false,
pending: true,
}
await changeOptimistically(queryClient, (list) => [...list, optimistic])
+2
View File
@@ -114,6 +114,8 @@ function dinner(id: string, name: string, difficulty: string | null, minutes: nu
is_favorite: false,
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
}
}
@@ -300,3 +300,27 @@
.lock {
display: contents;
}
/* Find missing photos: its hint, button and progress, padded like a sheet. */
.card.backfill {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
/* Why a Regular's recipe was not found. */
.reason {
margin: 0;
padding: 12px 14px;
border-radius: 18px;
background: var(--color-neutral-100);
color: var(--color-neutral-900);
font-family: var(--font-body);
font-size: 14px;
line-height: 1.4;
}
.reasonLabel {
font-weight: 700;
}
+203 -1
View File
@@ -85,7 +85,11 @@ const TACOS = dinnerBody('d1', 'Beef tacos', {
photo_url: '/media/photos/p1-400.jpg',
})
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
const PIZZA = dinnerBody('d3', 'Pizza night', {
grounding_status: 'failed',
grounding_error: 'no_recipe_found',
photo_backfill_eligible: true,
})
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
function rows() {
@@ -496,6 +500,204 @@ describe('RegularsSettingsPage', () => {
})
})
describe('Photo backfill', () => {
const BACKFILL = 'POST /api/v1/settings/photo-backfill'
const JOB = 'GET /api/v1/jobs/j9'
// backfillJob is a GET /api/v1/jobs/j9 answer for the photo backfill.
function backfillJob(status: string, result: unknown = null, error: string | null = null) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error })
}
// polls answers each poll of the job with the next answer, then the last
// one again; an answer may change the server first.
function polls(...answers: (() => Response)[]) {
let next = 0
return () => answers[Math.min(next++, answers.length - 1)]!()
}
function fixSheet() {
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
}
const NO_CANDIDATES = { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) }
it('shows why the recipe was not found in the fix-recipe sheet', async () => {
stubServer([PIZZA], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('No recipe page the search found had a recipe we could use.')).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('offers no search again for a pasted link that had no usable recipe', async () => {
const linked = { ...PIZZA, grounding_error: 'recipe_unusable', photo_backfill_eligible: false }
stubServer([linked], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText('The pasted recipe link has no recipe we could use.')).toBeInTheDocument()
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
})
it('offers no search again for a dinner with a pasted link whatever its reason', async () => {
const linked = { ...PIZZA, grounding_error: 'ai_failed', photo_backfill_eligible: false }
stubServer([linked], NO_CANDIDATES)
await renderPage()
openRow('Pizza night')
expect(fixSheet().getByText("The AI assistant couldn't read the recipe.")).toBeInTheDocument()
expect(fixSheet().queryByRole('button', { name: 'Search again' })).not.toBeInTheDocument()
})
it('searches again from the fix-recipe sheet and shows the new reason when it still fails', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('running'),
() => {
server.regulars = [{ ...PIZZA, grounding_error: 'search_failed' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'failed', reason: 'search_failed' }] })
},
),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByRole('button', { name: 'Searching again…' })).toBeDisabled()
expect(fixSheet().getByText('Searching again… this can take a few minutes.')).toBeInTheDocument()
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await fixSheet().findByText('Searched again: still no recipe found.', {}, { timeout: 3000 })).toBeInTheDocument()
expect(await fixSheet().findByText("The recipe search couldn't be reached.")).toBeInTheDocument()
expect(fixSheet().getByRole('button', { name: 'Search again' })).toBeEnabled()
})
it('closes the fix-recipe sheet once searching again finds the recipe and its photo', async () => {
const server = stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => {
server.regulars = [{ ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg')
})
it('says when a dinner was not included in the search', async () => {
stubServer([PIZZA], {
...NO_CANDIDATES,
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
openRow('Pizza night')
fireEvent.click(fixSheet().getByRole('button', { name: 'Search again' }))
expect(await fixSheet().findByText('This dinner was not included in that search.')).toBeInTheDocument()
})
it('finds missing photos: the placeholder shows until the backfill finds a photo', async () => {
const found = { ...PIZZA, grounding_status: 'grounded', grounding_error: null, photo_url: '/media/photos/p8-400.jpg' }
const server = stubServer([TACOS, PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: polls(
() => backfillJob('queued'),
() => {
server.regulars = [TACOS, found]
return backfillJob('succeeded', { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] })
},
),
})
await renderPage()
// The photoless dinner shows the striped placeholder, not an image.
const placeholder = within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })
expect(placeholder).not.toHaveAttribute('src')
expect(placeholder).toHaveTextContent('dish photo')
const trigger = screen.getByRole('button', { name: 'Find missing photos' })
expectMinHitArea(trigger)
fireEvent.click(trigger)
expect(await screen.findByRole('button', { name: 'Finding photos…' })).toBeDisabled()
expect(await screen.findByText('Finding missing photos… this can take a few minutes.')).toBeInTheDocument()
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).not.toHaveAttribute('src')
expect(sent(server.fetchMock)).toEqual([{ request: BACKFILL, body: undefined }])
expect(await screen.findByText('Photo search done: 1 photo found.', {}, { timeout: 3000 })).toBeInTheDocument()
await waitFor(() =>
expect(within(row('Pizza night')).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p8-400.jpg'),
)
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
expect(reads(server.fetchMock)).toBe(2)
})
it('sums up what the backfill did', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () =>
backfillJob('succeeded', {
dinners: [
{ dinner_id: 'd3', outcome: 'grounded', reason: null },
{ dinner_id: 'd5', outcome: 'grounded', reason: null },
{ dinner_id: 'd6', outcome: 'failed', reason: 'no_recipe_found' },
{ dinner_id: 'd7', outcome: 'skipped', reason: null },
],
}),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(
await screen.findByText(
'Photo search done: 2 photos found · 1 dinner still needs a recipe · 1 dinner changed meanwhile, left as it was.',
),
).toBeInTheDocument()
})
it('says when no dinner was missing a photo', async () => {
stubServer([TACOS], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('succeeded', { dinners: [] }),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByText('No dinners were missing a photo.')).toBeInTheDocument()
})
it('says why the backfill stopped', async () => {
stubServer([PIZZA], {
[BACKFILL]: () => jsonResponse(202, { id: 'j9' }),
[JOB]: () => backfillJob('failed', { dinners: [] }, 'internal_error'),
})
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search stopped: Something went wrong on the server.')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
it('says why the backfill could not start', async () => {
stubServer([PIZZA], { [BACKFILL]: () => errorResponse(403, 'csrf_failed') })
await renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Find missing photos' }))
expect(await screen.findByRole('alert')).toHaveTextContent('Photo search not started: csrf_failed message')
expect(screen.getByRole('button', { name: 'Find missing photos' })).toBeEnabled()
})
})
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
// Only intervals are faked: TanStack Query polls with setInterval, and
// Testing Library's waits keep their real timers.
+147 -10
View File
@@ -34,6 +34,7 @@ import { Sheet } from '../ui/Sheet.tsx'
import { Skeleton } from '../ui/Skeleton.tsx'
import { Tag } from '../ui/Tag.tsx'
import { TextInput } from '../ui/TextInput.tsx'
import { usePhotoBackfill, type BackfillResult, type PhotoBackfill } from './photoBackfill.ts'
import styles from './RegularsSettingsPage.module.css'
// The most minutes a dinner may take, as the dinners API allows: a day.
@@ -57,7 +58,8 @@ const LINK = /^https?:\/\//i
// method and minutes, favorite heart and whether its recipe was found. The
// field at the top adds one by name, or by pasted recipe link with a name
// the household gives it; tapping one
// edits or removes it, or fixes its recipe when none was found.
// edits or removes it, or fixes its recipe when none was found. Find
// missing photos searches again for every dinner left without one.
export function RegularsSettingsPage() {
const navigate = useNavigate()
return (
@@ -86,6 +88,7 @@ function RegularsManager() {
const remove = useRemoveRegular()
const favorite = useToggleFavorite()
const { refusals, track } = useRefusals()
const backfill = usePhotoBackfill()
const [draft, setDraft] = useState('')
const [linkName, setLinkName] = useState('')
const [open, setOpen] = useState<Open>(null)
@@ -94,8 +97,10 @@ function RegularsManager() {
const isLink = LINK.test(draft.trim())
const ready = draft.trim() !== '' && (!isLink || linkName.trim() !== '')
const list = regulars.data ?? []
// A Regular removed meanwhile closes its sheet.
// A Regular removed meanwhile closes its sheet, and one whose recipe was
// found meanwhile closes its fix-recipe sheet.
const opened = open && list.find((regular) => regular.id === open.id && !regular.pending)
const fixing = opened && open.sheet === 'fix' && opened.grounding_status === 'failed' ? opened : undefined
function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
@@ -188,6 +193,7 @@ function RegularsManager() {
)}
</section>
)}
{regulars.isSuccess && <FindMissingPhotos backfill={backfill} />}
{opened && open.sheet === 'edit' && (
<EditRegularSheet
@@ -197,12 +203,13 @@ function RegularsManager() {
onRemove={() => onRemove(opened)}
/>
)}
{opened && open.sheet === 'fix' && (
{fixing && (
<FixRecipeSheet
key={opened.id}
regular={opened}
key={fixing.id}
regular={fixing}
backfill={backfill}
onClose={() => setOpen(null)}
onEdit={() => setOpen({ sheet: 'edit', id: opened.id })}
onEdit={() => setOpen({ sheet: 'edit', id: fixing.id })}
/>
)}
</>
@@ -231,6 +238,88 @@ function GroundingBadge({ status }: { status: GroundingStatus }) {
return <Tag className={`${styles.badge} ${badge.className}`}>{badge.text}</Tag>
}
// groundingReasons says why a dinner's recipe was not found, by its
// grounding_error reason code.
const groundingReasons: Record<string, string> = {
no_recipe_found: 'No recipe page the search found had a recipe we could use.',
recipe_unusable: 'The pasted recipe link has no recipe we could use.',
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.",
ai_failed: "The AI assistant couldn't read the recipe.",
interrupted: 'The server stopped while finding the recipe.',
grounding_error: 'Something went wrong on the server.',
}
// groundingReason says why a dinner's recipe was not found; an unknown
// reason code is shown as it is.
export function groundingReason(code: string): string {
return groundingReasons[code] ?? code
}
// backfillJobFailures says why a photo backfill job failed, by its failure
// code; an unknown code is shown as it is.
const backfillJobFailures: Record<string, string> = {
internal_error: 'Something went wrong on the server.',
}
// backfillProblem says what went wrong with a photo backfill, or null.
function backfillProblem(backfill: PhotoBackfill): string | null {
if (backfill.startError) return `Photo search not started: ${backfill.startError.message}`
if (backfill.status === 'failed' && backfill.error) {
return `Photo search stopped: ${backfillJobFailures[backfill.error.message] ?? backfill.error.message}`
}
if (backfill.error) return `Couldn't check on the photo search: ${backfill.error.message}`
return null
}
function count(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`
}
// backfillSummary says what a finished photo backfill did.
function backfillSummary(result: BackfillResult): string {
const tally = (outcome: string) => result.dinners.filter((dinner) => dinner.outcome === outcome).length
if (result.dinners.length === 0) return 'No dinners were missing a photo.'
const parts = [count(tally('grounded'), 'photo found', 'photos found')]
const failed = tally('failed')
if (failed > 0) parts.push(`${count(failed, 'dinner still needs', 'dinners still need')} a recipe`)
const skipped = tally('skipped')
if (skipped > 0) parts.push(`${count(skipped, 'dinner', 'dinners')} changed meanwhile, left as it was`)
return `Photo search done: ${parts.join(' · ')}.`
}
// FindMissingPhotos searches again, by name, for a recipe and photo for
// every dinner left without a photo because its recipe was not found.
function FindMissingPhotos({ backfill }: { backfill: PhotoBackfill }) {
const headingId = useId()
const problem = backfillProblem(backfill)
return (
<section aria-labelledby={headingId} className={styles.section}>
<h2 id={headingId} className={styles.groupLabel}>
Missing photos
</h2>
<Card className={`${styles.card} ${styles.backfill}`}>
<p className={styles.hint}>
Search again for a recipe and photo for every dinner whose recipe wasn&apos;t found. Dinners with a pasted
recipe link keep it.
</p>
<Button disabled={backfill.active} onClick={backfill.start}>
{backfill.active ? 'Finding photos…' : 'Find missing photos'}
</Button>
<p role="status" className={styles.status}>
{backfill.active && 'Finding missing photos… this can take a few minutes.'}
{backfill.status === 'succeeded' && backfill.result && backfillSummary(backfill.result)}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</Card>
</section>
)
}
type RegularRowProps = {
regular: Regular
onOpen: () => void
@@ -447,6 +536,7 @@ const fixPaths = [
type FixRecipeSheetProps = {
regular: Regular
backfill: PhotoBackfill
onClose: () => void
onEdit: () => void
}
@@ -459,10 +549,20 @@ function lines(text: string): string[] {
.filter((line) => line !== '')
}
// FixRecipeSheet grounds a Regular whose recipe was not found: in one of
// up to three recipe pages found for its name, in a pasted recipe link, or
// in ingredients and steps typed in.
function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
// retryOutcomes says what searching again did for this dinner.
const retryOutcomes = {
grounded: 'Searched again: a recipe and photo were found.',
failed: 'Searched again: still no recipe found.',
skipped: 'It changed while it was searched again, so it was left as it is.',
} as const
// FixRecipeSheet shows why a Regular's recipe was not found and grounds it:
// by searching its name again (a photo backfill), in one of up to three
// recipe pages found for its name, in a pasted recipe link, or in
// ingredients and steps typed in. Searching again is offered only for a
// dinner the backfill searches (photo_backfill_eligible): it never replaces
// a pasted link.
function FixRecipeSheet({ regular, backfill, onClose, onEdit }: FixRecipeSheetProps) {
const [path, setPath] = useState<FixPath>('pick')
const ground = useGroundRegular()
@@ -475,6 +575,15 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
<p className={styles.hint}>
We couldn&apos;t find a recipe for {regular.name}. Pick a match below, paste a recipe link or type it in.
</p>
{regular.grounding_error !== null && (
<p className={styles.reason}>
<span className={styles.reasonLabel}>Why: </span>
{groundingReason(regular.grounding_error)}
</p>
)}
{regular.photo_backfill_eligible && (
<RetrySearch id={regular.id} backfill={backfill} disabled={ground.isPending} />
)}
<Segmented
label="How to fix the recipe"
options={fixPaths}
@@ -504,6 +613,34 @@ function FixRecipeSheet({ regular, onClose, onEdit }: FixRecipeSheetProps) {
)
}
// RetrySearch searches the dinner's name again with a photo backfill, and
// says what it did for the dinner once it ends. The backfill searches
// every dinner without a photo, this one with them.
function RetrySearch({ id, backfill, disabled }: { id: string; backfill: PhotoBackfill; disabled: boolean }) {
const problem = backfillProblem(backfill)
const tried = backfill.result?.dinners.find((dinner) => dinner.dinner_id === id)
let status: string | null = null
if (backfill.active) status = 'Searching again… this can take a few minutes.'
else if (backfill.status === 'succeeded') {
status = tried ? retryOutcomes[tried.outcome] : 'This dinner was not included in that search.'
}
return (
<>
<Button variant="outline" disabled={disabled || backfill.active} onClick={backfill.start}>
{backfill.active ? 'Searching again…' : 'Search again'}
</Button>
<p role="status" className={styles.status}>
{status}
</p>
{problem && (
<p role="alert" className={styles.problem}>
{problem}
</p>
)}
</>
)
}
function PickCandidate({ id, onPick }: { id: string; onPick: (candidateId: string) => void }) {
const candidates = useRecipeCandidates(id)
if (candidates.isPending) return <Skeleton className={styles.candidatesLoading} />
+63
View File
@@ -0,0 +1,63 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { HOME_KEY } from '../home/api.ts'
import { planKey } from '../plan/api.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { fakeLocation, jsonResponse, ME, stubFetch } from '../test/fetchStub.ts'
import { parseBackfillResult, usePhotoBackfill } from './photoBackfill.ts'
afterEach(() => {
vi.unstubAllGlobals()
})
const RESULT = { dinners: [{ dinner_id: 'd3', outcome: 'grounded', reason: null }] }
function backfillJob(status: string, result: unknown) {
return jsonResponse(200, { id: 'j9', kind: 'backfill_dinner_photos', status, result, error: null })
}
describe('usePhotoBackfill', () => {
it('reads the plans, home and dinners again once the backfill ends, so a found photo shows everywhere', async () => {
const fetchMock = stubFetch(
jsonResponse(200, ME),
jsonResponse(202, { id: 'j9' }),
backfillJob('running', null),
backfillJob('succeeded', RESULT),
)
const api = createApiClient(fakeLocation(), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
const queryClient = createQueryClient()
const plan = planKey('2026-10-05')
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) queryClient.setQueryData(key, {})
const wrapper = ({ children }: { children: ReactNode }) => (
<ApiProvider client={api}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</ApiProvider>
)
const { result } = renderHook(() => usePhotoBackfill(), { wrapper })
act(() => result.current.start())
await waitFor(() => expect(result.current.status).toBe('running'))
expect(result.current.active).toBe(true)
expect(queryClient.getQueryState(plan)?.isInvalidated).toBe(false)
await waitFor(() => expect(result.current.status).toBe('succeeded'), { timeout: 3000 })
expect(result.current).toMatchObject({ active: false, error: null, result: RESULT })
expect(fetchMock).toHaveBeenCalledTimes(4)
for (const key of [plan, HOME_KEY, ['dinners', 'quest_log']]) {
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(true)
}
})
})
describe('parseBackfillResult', () => {
it('refuses a result that is not a photo backfill', () => {
expect(() => parseBackfillResult('job', { dinners: [{ dinner_id: 'd3', outcome: 'lost', reason: null }] })).toThrow(
'job: outcome "lost" is not one of grounded, failed, skipped',
)
expect(() => parseBackfillResult('job', { stage: 'x' })).toThrow('job: result is not a photo backfill')
})
})
+110
View File
@@ -0,0 +1,110 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
import { useApi, useJob, type JobStatus } from '../api/index.ts'
import { isRecord } from '../api/errors.ts'
import { oneOf } from '../dinners/api.ts'
import { HOME_KEY } from '../home/api.ts'
export const PHOTO_BACKFILL_PATH = '/api/v1/settings/photo-backfill'
// BACKFILL_JOB_KIND is the kind of the job a photo backfill runs as.
export const BACKFILL_JOB_KIND = 'backfill_dinner_photos'
export const BACKFILL_OUTCOMES = ['grounded', 'failed', 'skipped'] as const
export type BackfillOutcome = (typeof BACKFILL_OUTCOMES)[number]
// BackfillDinner is what a photo backfill did for one dinner. reason is
// set only when it failed: the reason code now its grounding_error.
export type BackfillDinner = { dinner_id: string; outcome: BackfillOutcome; reason: string | null }
// BackfillResult is the photo backfill job's result: each dinner it tried,
// in the order it tried them.
export type BackfillResult = { dinners: BackfillDinner[] }
// Every query showing a dinner's photo, read again once a backfill ends:
// the dinners lists and details, the plans and the home dashboard.
const PHOTO_KEYS = [['dinners'], ['plans'], HOME_KEY] as const
// parseBackfillResult checks a photo backfill job's result, which useJob
// passes on unread.
export function parseBackfillResult(where: string, body: unknown): BackfillResult {
if (!isRecord(body) || !Array.isArray(body.dinners)) throw new Error(`${where}: result is not a photo backfill`)
return {
dinners: body.dinners.map((dinner): BackfillDinner => {
if (!isRecord(dinner) || typeof dinner.dinner_id !== 'string' || (dinner.reason !== null && typeof dinner.reason !== 'string')) {
throw new Error(`${where}: result has a dinner that is not a backfill outcome`)
}
return {
dinner_id: dinner.dinner_id,
outcome: oneOf(where, 'outcome', BACKFILL_OUTCOMES, dinner.outcome),
reason: dinner.reason,
}
}),
}
}
// PhotoBackfill is a photo backfill started from this screen, as it goes.
export type PhotoBackfill = {
// start asks the server for a backfill over every dinner without a
// photo. While one is already queued or running the server answers with
// that job, so starting never runs two at once.
start: () => void
// active is true from start until the job ends, except while a poll's
// failure leaves its progress unknown: starting again then asks the
// server for the job it is running and polls it anew.
active: boolean
// Why the backfill could not be started.
startError: Error | null
// The job's status, undefined until it is started and first polled.
status: JobStatus | undefined
// The job's result, once it has one.
result: BackfillResult | null
// The job's failure, a poll's failure, or a result that is not a backfill.
error: Error | null
}
// usePhotoBackfill starts a photo backfill (POST
// /api/v1/settings/photo-backfill, 202 {id}) and follows its job with
// useJob. When the job ends, every query showing a dinner's photo is read
// again, so dinners it grounded show their photo everywhere.
export function usePhotoBackfill(): PhotoBackfill {
const { apiFetch } = useApi()
const queryClient = useQueryClient()
const [jobId, setJobId] = useState<string | null>(null)
const job = useJob<unknown>(BACKFILL_JOB_KIND, jobId)
const begin = useMutation({
mutationFn: async (): Promise<string> => {
const body = await apiFetch<unknown>(PHOTO_BACKFILL_PATH, { method: 'POST' })
if (!isRecord(body) || typeof body.id !== 'string') throw new Error(`POST ${PHOTO_BACKFILL_PATH}: response has no job id`)
return body.id
},
onSuccess: (id) => {
setJobId(id)
job.poll()
},
})
const ended = job.status === 'succeeded' || job.status === 'failed'
useEffect(() => {
if (!ended) return
for (const queryKey of PHOTO_KEYS) void queryClient.invalidateQueries({ queryKey })
}, [ended, jobId, queryClient])
const parsed = useMemo(() => {
if (job.result === null) return { result: null, error: null }
try {
return { result: parseBackfillResult(`GET /api/v1/jobs/${jobId}`, job.result), error: null }
} catch (error) {
return { result: null, error: error as Error }
}
}, [job.result, jobId])
return {
start: () => begin.mutate(),
active: begin.isPending || (jobId !== null && !ended && job.error === null),
startError: begin.error,
status: job.status,
result: parsed.result,
error: job.error ?? parsed.error,
}
}
+68 -111
View File
@@ -1,5 +1,5 @@
import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, within } from '@testing-library/react'
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'
@@ -213,46 +213,8 @@ function reload(page: { unmount: () => void }) {
vi.setSystemTime(now)
}
// advance jumps the clock past a deadline or a wait, firing the timers due
// by then; what they set off runs as later turns (until, settle) let it.
function advance(ms: number) {
act(() => vi.advanceTimersByTime(ms))
}
// The host's setImmediate, which these tests do not fake: it runs its
// callback on the next turn of the event loop, once every promise already
// resolved has run, whatever the clock says.
const nextTurn = (globalThis as unknown as { setImmediate: (callback: () => void) => void }).setImmediate
// turn lets the page take one turn of the event loop, inside act: the
// promises it has resolved run, and React renders and runs the effects they
// cause. No time passes, real or fake.
async function turn() {
await act(() => new Promise<void>((resolve) => nextTurn(resolve)))
}
// TURNS bounds how many turns until waits and settle takes: far more than
// any change here needs to reach the page.
const TURNS = 50
// until lets the page take turns until check passes, and returns what check
// returns; it fails with check's error if check still fails after TURNS.
async function until<T>(check: () => T): Promise<T> {
for (let turns = 0; ; turns++) {
try {
return check()
} catch (error) {
if (turns === TURNS) throw error
}
await turn()
}
}
// settle lets the page take TURNS turns, the clock standing still: whatever
// it would do without a deadline or a wait passing, such as send a PATCH too
// soon, it has done.
async function settle() {
for (let turns = 0; turns < TURNS; turns++) await turn()
async function advance(ms: number) {
await act(() => vi.advanceTimersByTimeAsync(ms))
}
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
@@ -262,8 +224,8 @@ let timers: MockInstance<typeof setTimeout>
// 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 until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
advance(2 * RETRY_FIRST)
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST)
}
function checkbox(name: string) {
@@ -278,21 +240,16 @@ beforeEach(() => {
onLine = true
reachable = true
mockViewport(390)
// The clock and setTimeout move only when a test jumps past a deadline or
// a wait (advance), never with real time: under load, real time would fire
// a deadline or a wait the test never reached. React Query sends its
// batched notices a turn later, as it does in the app on a setTimeout of 0,
// so the page renders its changes without the clock moving.
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] })
// 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'))
notifyManager.setScheduler(nextTurn)
timers = vi.spyOn(globalThis, 'setTimeout')
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
notifyManager.setScheduler(defaultScheduler)
vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia')
})
@@ -301,7 +258,7 @@ 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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -319,7 +276,7 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`,
@@ -335,14 +292,14 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' })))
await until(() => expect(queue()).toEqual([]))
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 until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
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()
@@ -350,7 +307,7 @@ describe('offline toggle queue', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline()
await until(() => expect(queue()).toEqual([]))
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()
@@ -359,7 +316,7 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef'))
@@ -368,7 +325,7 @@ describe('offline toggle queue', () => {
// Reload while offline: the settings, /me and the list cannot be read.
reload(first)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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()
@@ -376,9 +333,9 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([])
goOnline()
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
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()
@@ -387,7 +344,7 @@ describe('offline toggle queue', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Taco seasoning'))
@@ -395,25 +352,25 @@ describe('offline toggle queue', () => {
server.archive()
goOnline()
const alert = await until(() => screen.getByRole('alert'))
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
expect(await until(() => screen.getByText('Archived'))).toBeInTheDocument()
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 until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef'))
expect(await until(() => screen.getByRole('alert'))).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
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()
@@ -421,7 +378,7 @@ describe('offline toggle queue', () => {
goOffline()
goOnline()
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
})
})
@@ -438,16 +395,16 @@ describe('offline toggle queue, replay', () => {
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage()
await until(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' })))
await waitFor(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
for (let sent = 1; sent <= 4; sent++) {
await until(() => expect(patches(server.fetchMock)).toHaveLength(sent))
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
// The next one is not sent before this one is answered.
await settle()
await new Promise((resolve) => setTimeout(resolve, 20))
expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release())
}
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`,
@@ -455,27 +412,27 @@ describe('offline toggle queue, replay', () => {
`${ITEM('i2')} {"needed":false}`,
])
expect(server.mostInFlight()).toBe(1)
await until(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives.
await until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
expect(queue()).toEqual(['i3 true'])
reload(first)
act(() => answers.release())
await openPage()
await until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
act(() => answers.release())
await until(() => expect(queue()).toEqual([]))
await waitFor(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
await until(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
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()
@@ -485,18 +442,18 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
goOffline()
fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline()
await until(() => expect(answers.waiting()).toBe(1))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -506,29 +463,29 @@ describe('offline toggle queue, replay', () => {
// 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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(patches(server.fetchMock)).toHaveLength(1))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -539,26 +496,26 @@ describe('offline toggle queue, replay', () => {
// answers. Every later /me answers at once.
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(tries).toBe(2))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -579,29 +536,29 @@ describe('offline toggle queue, replay', () => {
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
)
await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning'))
await until(() => expect(tries).toBe(1))
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.
advance(TOGGLE_DEADLINE)
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -609,21 +566,21 @@ describe('offline toggle queue, replay', () => {
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 until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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 until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
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 until(() => expect(queue()).toEqual([]))
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 until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -631,20 +588,20 @@ describe('offline toggle queue, replay', () => {
const answers = held()
stubServer(undefined, answers.hold)
const page = await openPage()
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
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 until(() => expect(answers.waiting()).toBe(1))
await waitFor(() => expect(answers.waiting()).toBe(1))
page.reopen()
expect(await until(() => within(row('Ground beef')).getByText('will sync'))).toBeInTheDocument()
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false'])
act(() => answers.release())
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
expect(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument()
})
@@ -656,10 +613,10 @@ describe('offline toggle queue, replay', () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage()
expect(await until(() => screen.getByText(/Couldn't load the household settings/))).toBeInTheDocument()
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 settle()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
})
})
-601
View File
@@ -1,601 +0,0 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import type { ReactNode } from 'react'
import { Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from './api/index.ts'
import { DinnerDetailPage } from './dinners/DinnerDetailPage.tsx'
import { DiscoverPage } from './discover/DiscoverPage.tsx'
import { HomePage } from './home/HomePage.tsx'
import { CookFromPantryPage } from './ideas/CookFromPantryPage.tsx'
import { QuickDinnersPage } from './ideas/QuickDinnersPage.tsx'
import { OnboardingPage } from './onboarding/OnboardingPage.tsx'
import { createNetworkStatus } from './pwa/networkStatus.ts'
import { PlanPage } from './plan/PlanPage.tsx'
import { defaultWeek } from './plan/week.ts'
import { AISettingsPage } from './settings/AISettingsPage.tsx'
import { CalendarSettingsPage } from './settings/CalendarSettingsPage.tsx'
import { HouseholdSettingsPage } from './settings/HouseholdSettingsPage.tsx'
import { NotificationsPage } from './settings/NotificationsPage.tsx'
import { RegularsSettingsPage } from './settings/RegularsSettingsPage.tsx'
import { SettingsPage } from './settings/SettingsPage.tsx'
import { SupplyArchivePage } from './shopping/SupplyArchivePage.tsx'
import { SupplyRunPage } from './shopping/SupplyRunPage.tsx'
import {
AI_MODELS,
AI_SETTINGS,
CHAT_WEBHOOK_NONE,
GOOGLE_NOT_CONNECTED,
HOME,
HOUSEHOLD_SETTINGS,
NOTIFICATION_SETTINGS,
RECIPE_SEARCH_SETTINGS,
SETTINGS_API,
} from './test/ApiProviders.tsx'
import { InDataRouter } from './test/dataRouter.tsx'
import { dinnerBody } from './test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from './test/fetchStub.ts'
import { mockViewport } from './test/matchMedia.ts'
import skeletonStyles from './ui/Skeleton.module.css'
// The empty, loading and error state of every route, as docs/STATES.md
// lists them: each route renders its loading, empty and error variants
// against a stubbed API.
type Responder = () => Response | Promise<Response>
// pending is a response that never comes, so the page stays loading.
const pending: Responder = () => new Promise<Response>(() => {})
// failThen answers the first request with a 500, and every later one with
// then: Retry reads again and gets it.
function failThen(then: Responder): Responder {
let failed = false
return () => {
if (failed) return then()
failed = true
return errorResponse(500, 'internal')
}
}
const json = (body: unknown): Responder => () => jsonResponse(200, body)
// startJobs answers each POST that starts a job with the next job id, j1,
// j2 and so on.
function startJobs(): Responder {
let n = 0
return () => jsonResponse(202, { jobId: `j${++n}` })
}
// serve answers "METHOD path" requests from responders, with /me and the
// household settings; any other request fails the test.
function serve(responders: Record<string, Responder>) {
const all: Record<string, Responder> = {
'GET /api/v1/me': json(ME),
'GET /api/v1/settings/household': json(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())
})
vi.stubGlobal('fetch', fetchMock)
return fetchMock
}
// renderRoute renders element on its route pattern at path, under a data
// router as App renders it, once /me has loaded as AuthBootstrap loads it.
async function renderRoute(path: string, pattern: string, element: ReactNode) {
const api = createApiClient(fakeLocation(path), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<InDataRouter path={path}>
<Routes>
<Route path={pattern} element={element} />
</Routes>
</InDataRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function skeletons() {
return document.querySelectorAll(`.${skeletonStyles.skeleton}`)
}
// expectLoading waits for the pill skeletons and checks nothing failed.
async function expectLoading() {
await waitFor(() => expect(skeletons().length).toBeGreaterThan(0))
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
}
// expectOnlyLoadingBefore checks, once a page's independent sections have
// settled, that the one skeleton left is the held read's: it sits before
// next, the first settled section after it, and nothing failed.
function expectOnlyLoadingBefore(next: Element) {
expect(skeletons()).toHaveLength(1)
const left = skeletons().item(0)
if (left === null) throw new Error('no skeleton left')
expect(next.contains(left)).toBe(false)
expect(left.compareDocumentPosition(next) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
}
// expectRetry finds the error banner saying said, presses its Retry and
// waits for recovered.
async function expectRetry(said: string, recovered: () => Promise<unknown>) {
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent(said)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await recovered()
await waitFor(() => expect(screen.queryByRole('alert')).not.toBeInTheDocument())
}
const WEEK = () => defaultWeek(HOUSEHOLD_SETTINGS, new Date())
beforeEach(() => {
mockViewport(390)
})
afterEach(() => {
Reflect.deleteProperty(window, 'matchMedia')
vi.unstubAllGlobals()
})
describe('/ Home', () => {
const NOTHING_TONIGHT = { ...HOME, tonight: null, sideQuestCount: 0, recentUpdates: { unread: false } }
const page = async (home: Responder) => {
serve({ 'GET /api/v1/home': home, 'GET /api/v1/rotation-reviews': json({ reviews: [] }) })
await renderRoute('/', '/', <HomePage />)
}
it('loading: skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: no quest planned tonight, with Plan this week', async () => {
await page(json(NOTHING_TONIGHT))
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Plan this week' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NOTHING_TONIGHT)))
await expectRetry("Couldn't load tonight's quest. internal message", () =>
screen.findByRole('heading', { name: 'No quest planned tonight' }),
)
})
})
describe('/onboarding Welcome step', () => {
it('reads nothing, so it has no empty, loading or error state', async () => {
const fetchMock = serve({})
await renderRoute('/onboarding', '/onboarding/*', <OnboardingPage />)
expect(screen.getByRole('heading', { name: 'Welcome to MealQuest' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: "Let's start" })).toBeEnabled()
expect(skeletons()).toHaveLength(0)
expect(fetchMock.mock.calls.map(([input]) => String(input))).toEqual(['/api/v1/me'])
})
})
describe('/onboarding/regulars Regulars step', () => {
const page = async (regulars: Responder) => {
serve({ 'GET /api/v1/dinners?status=regular': regulars })
await renderRoute('/onboarding/regulars', '/onboarding/*', <OnboardingPage />)
}
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars yet, common picks offered and Next disabled', async () => {
await page(json({ dinners: [] }))
expect(await screen.findByRole('heading', { name: 'Common picks' })).toBeInTheDocument()
expect(screen.getByRole('heading', { name: 'Your regulars · 0' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Next: notifications' })).toBeDisabled()
})
it('error: banner with Retry', async () => {
await page(failThen(json({ dinners: [] })))
await expectRetry('Your Regulars could not load: internal message', () =>
screen.findByRole('heading', { name: 'Common picks' }),
)
})
})
describe('/onboarding/notifications Notifications step', () => {
const page = async (finish: Responder) => {
serve({ 'PATCH /api/v1/settings/household': finish })
await renderRoute('/onboarding/notifications', '/onboarding/*', <OnboardingPage />)
fireEvent.click(screen.getByRole('button', { name: 'Maybe later' }))
}
it("loading: the step's buttons wait while it finishes", async () => {
await page(pending)
await waitFor(() => expect(screen.getByRole('button', { name: 'Maybe later' })).toBeDisabled())
expect(screen.getByRole('button', { name: 'Turn on notifications' })).toBeDisabled()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('error: says finishing failed and lets it be tried again', async () => {
await page(failThen(pending))
expect(await screen.findByRole('alert')).toHaveTextContent('Could not finish setting up: internal message')
expect(screen.getByRole('button', { name: 'Maybe later' })).toBeEnabled()
})
})
describe('/plan Draft board', () => {
const page = async (plan: Responder) => {
serve({ [`GET /api/v1/plans/${WEEK()}`]: plan, [`GET /api/v1/shopping/${WEEK()}`]: () => errorResponse(404, 'not_found') })
await renderRoute('/plan', '/plan', <PlanPage />)
}
const noPlan = () => errorResponse(404, 'not_found')
it('loading: skeleton tiles', async () => {
await page(pending)
expect(await screen.findByRole('region', { name: 'Plan loading' })).toBeInTheDocument()
await expectLoading()
})
it('empty: nothing planned yet, with Draft this week', async () => {
await page(noPlan)
expect(await screen.findByRole('heading', { name: 'Nothing planned yet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(noPlan))
await expectRetry("Couldn't load this week's plan. internal message", () =>
screen.findByRole('heading', { name: 'Nothing planned yet' }),
)
})
})
describe('/plan/shopping Supply run', () => {
const page = async (run: Responder) => {
serve({ [`GET /api/v1/shopping/${WEEK()}`]: run })
await renderRoute('/plan/shopping', '/plan/shopping', <SupplyRunPage />)
}
const noList = () => errorResponse(404, 'not_found')
it('loading: skeleton cards', async () => {
await page(pending)
await expectLoading()
})
it('empty: no shopping list yet, with Make the shopping list', async () => {
await page(noList)
expect(await screen.findByRole('heading', { name: 'No shopping list yet' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Make the shopping list' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(noList))
await expectRetry("Couldn't load the shopping list. internal message", () =>
screen.findByRole('heading', { name: 'No shopping list yet' }),
)
})
})
describe('/plan/shopping/archive Supply archive', () => {
const page = async (archive: Responder) => {
serve({ 'GET /api/v1/shopping/archive?by=week': archive })
await renderRoute('/plan/shopping/archive', '/plan/shopping/archive', <SupplyArchivePage />)
}
const nothing = json({ by: 'week', periods: [] })
it('loading: skeleton posts', async () => {
await page(pending)
await expectLoading()
})
it('empty: nothing archived yet', async () => {
await page(nothing)
expect(await screen.findByRole('heading', { name: 'Nothing archived yet' })).toBeInTheDocument()
expect(screen.getByText("Each week's Supply run becomes a post here once it's made.")).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(nothing))
await expectRetry("Couldn't load the Supply archive. internal message", () =>
screen.findByRole('heading', { name: 'Nothing archived yet' }),
)
})
})
describe('/discover Side quests', () => {
const page = async (deck: Responder) => {
serve({ 'GET /api/v1/suggestions/deck': deck, 'GET /api/v1/dinners?status=quest_log': json({ dinners: [] }) })
await renderRoute('/discover', '/discover', <DiscoverPage />)
}
const empty = json({ suggestions: [], active_batch: null, failed_batch: null })
it('loading: skeleton cards', async () => {
await page(pending)
await expectLoading()
})
it('empty: All caught up / New side quests arrive Sunday., with Get more now', async () => {
await page(empty)
expect(await screen.findByRole('heading', { name: 'All caught up' })).toBeInTheDocument()
expect(screen.getByText('New side quests arrive Sunday.')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Get more now' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(empty))
await expectRetry("Couldn't load your side quests. internal message", () =>
screen.findByRole('heading', { name: 'All caught up' }),
)
})
})
describe('/quick Quick dinners', () => {
const QUICK = 'POST /api/v1/ideas/quick'
const job = (status: string, error: string | null) => ({
id: 'j1',
kind: 'quick_ideas',
status,
result: { stage: 'grounding', kind: 'under30', exclude: [], ideas: [], dropped: 0 },
error,
})
const page = async (quick: Responder, poll: Responder = pending) => {
serve({ [QUICK]: quick, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending })
await renderRoute('/quick', '/quick', <QuickDinnersPage />)
}
it('loading: skeleton rows', async () => {
await page(pending)
await waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6))
await expectLoading()
})
it('empty: no ideas found, with Retry', async () => {
await page(startJobs(), json(job('failed', 'no_ideas_found')))
await expectRetry("Couldn't find Quick dinners. No ideas came back with a recipe to match.", () =>
waitFor(() => expect(skeletons().length).toBeGreaterThan(0)),
)
})
it('error: banner with Retry', async () => {
await page(failThen(pending))
await expectRetry("Couldn't start looking for Quick dinners. internal message", () =>
waitFor(() => expect(screen.getAllByTestId('skeleton-row')).toHaveLength(6)),
)
})
})
describe('/cook-from-pantry Cook from what I have', () => {
const FIND = 'POST /api/v1/ideas/from-ingredients'
const job = {
id: 'j1',
kind: 'ideas_from_ingredients',
status: 'failed',
result: { stage: 'grounding', ingredients: ['Rice'], quick_only: false, ideas: [], dropped: 0 },
error: 'no_ideas_found',
}
const page = async (find: Responder, poll: Responder = pending) => {
serve({ [FIND]: find, 'GET /api/v1/jobs/j1': poll, 'GET /api/v1/jobs/j2': pending })
await renderRoute('/cook-from-pantry', '/cook-from-pantry', <CookFromPantryPage />)
}
const search = () => {
fireEvent.change(screen.getByRole('textbox', { name: 'Add ingredient' }), { target: { value: 'Rice' } })
fireEvent.click(screen.getByRole('button', { name: 'Find ideas' }))
}
it('loading: skeleton cards', async () => {
await page(pending)
search()
expect(await screen.findByRole('region', { name: 'Ideas loading' })).toBeInTheDocument()
await expectLoading()
})
it('empty: no ingredients yet, with Find ideas disabled; no ideas found, with Retry', async () => {
await page(startJobs(), json(job))
expect(screen.getByRole('button', { name: 'Find ideas' })).toBeDisabled()
search()
await expectRetry("Couldn't find ideas. No ideas came back with a recipe to match.", () =>
screen.findByRole('region', { name: 'Ideas' }),
)
})
it('error: banner with Retry', async () => {
await page(failThen(pending))
search()
await expectRetry("Couldn't start looking for ideas. internal message", () =>
screen.findByRole('region', { name: 'Ideas loading' }),
)
})
})
describe('/dinner/:id Dinner', () => {
const page = async (dinner: Responder) => {
serve({ 'GET /api/v1/dinners/d1': dinner })
await renderRoute('/dinner/d1', '/dinner/:id', <DinnerDetailPage />)
}
const notFound = () => errorResponse(404, 'not_found')
it('loading: skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: dinner not found', async () => {
await page(notFound)
expect(await screen.findByRole('heading', { name: 'Dinner not found' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notFound))
await expectRetry("Couldn't load this dinner. internal message", () =>
screen.findByRole('heading', { name: 'Dinner not found' }),
)
})
})
describe('/settings Settings home', () => {
const page = async (calendar: Responder) => {
const api = Object.fromEntries(Object.entries(SETTINGS_API).map(([path, respond]) => [`GET ${path}`, respond]))
serve({ ...api, 'GET /api/v1/dinners?status=regular': json({ dinners: [] }), 'GET /api/v1/integrations/google': calendar })
await renderRoute('/settings', '/settings', <SettingsPage />)
}
const notConnected = json(GOOGLE_NOT_CONNECTED)
it('loading: status skeletons', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars, calendar not connected, Google Chat not connected', async () => {
await page(notConnected)
expect(await screen.findByText('None on rotation yet')).toBeInTheDocument()
expect(await screen.findByText('Not connected')).toBeInTheDocument()
expect(await screen.findByText(/Google Chat not connected/)).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notConnected))
await expectRetry('Settings could not load: internal message', () => screen.findByText('Not connected'))
})
})
describe('/settings/notifications Notifications', () => {
const page = async (prefs: Responder) => {
serve({ 'GET /api/v1/settings/notifications': prefs, 'GET /api/v1/settings/chat-webhook': json(CHAT_WEBHOOK_NONE) })
await renderRoute('/settings/notifications', '/settings/notifications', <NotificationsPage />)
}
it('loading: skeleton for the channel matrix', async () => {
await page(pending)
// The webhook card and this device's push status read on their own;
// once they settle, the one skeleton left is the channel matrix's.
await screen.findByRole('button', { name: 'Save webhook' })
const device = screen.getByRole('group', { name: 'Notifications on this device' })
await waitFor(() => expect(device.querySelectorAll(`.${skeletonStyles.skeleton}`)).toHaveLength(0))
expectOnlyLoadingBefore(device)
expect(screen.queryByRole('group', { name: 'Notification channels' })).not.toBeInTheDocument()
})
it('empty: no Google Chat webhook stored', async () => {
await page(json(NOTIFICATION_SETTINGS))
expect(await screen.findByRole('button', { name: 'Save webhook' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NOTIFICATION_SETTINGS)))
await expectRetry('Notification settings could not load: internal message', () =>
screen.findByRole('group', { name: 'Notification channels' }),
)
})
})
describe('/settings/ai AI assistant', () => {
const NO_KEY = { ...AI_SETTINGS, api_key_last4: null, key_verified_at: null }
const page = async (settings: Responder) => {
serve({
'GET /api/v1/settings/ai': settings,
'GET /api/v1/settings/ai/models': json(AI_MODELS),
'GET /api/v1/settings/recipe-search': json(RECIPE_SEARCH_SETTINGS),
})
await renderRoute('/settings/ai', '/settings/ai', <AISettingsPage />)
}
it('loading: skeleton for the AI assistant form', async () => {
await page(pending)
// The Recipe search card reads on its own; once it settles, the one
// skeleton left is the AI assistant form's.
await screen.findByRole('button', { name: 'Save recipe search' })
expectOnlyLoadingBefore(screen.getByRole('region', { name: 'Recipe search' }))
expect(screen.queryByText('No key stored')).not.toBeInTheDocument()
})
it('empty: no key stored, with Add key', async () => {
await page(json(NO_KEY))
expect(await screen.findByText('No key stored')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Add key' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json(NO_KEY)))
await expectRetry('AI settings could not load: internal message', () => screen.findByText('No key stored'))
})
})
describe('/settings/household Household', () => {
const page = async (settings: Responder) => {
serve({ 'GET /api/v1/settings/household': settings })
await renderRoute('/settings/household', '/settings/household', <HouseholdSettingsPage />)
}
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
// The household's settings always exist (seeded on first start), so its
// "empty" state is the seeded defaults.
it('empty: the seeded settings', async () => {
await page(json(HOUSEHOLD_SETTINGS))
expect(await screen.findByLabelText('Dinner time')).toHaveValue('17:30')
})
it('error: banner with Retry', async () => {
await page(failThen(json(HOUSEHOLD_SETTINGS)))
await expectRetry('Household settings could not load: internal message', () => screen.findByLabelText('Dinner time'))
})
})
describe('/settings/regulars Regular dinners', () => {
const page = async (regulars: Responder) => {
serve({ 'GET /api/v1/dinners?status=regular': regulars })
await renderRoute('/settings/regulars', '/settings/regulars', <RegularsSettingsPage />)
}
const none = json({ dinners: [] })
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: no Regulars yet', async () => {
await page(none)
expect(await screen.findByText('No Regulars yet. Add the dinners your household makes on repeat.')).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(json({ dinners: [dinnerBody('d1', 'Beef tacos')] })))
await expectRetry('Your Regulars could not load: internal message', () => screen.findByText('Beef tacos'))
})
})
describe('/settings/calendar Calendar', () => {
const page = async (connection: Responder) => {
serve({ 'GET /api/v1/integrations/google': connection })
await renderRoute('/settings/calendar', '/settings/calendar', <CalendarSettingsPage />)
}
const notConnected = json(GOOGLE_NOT_CONNECTED)
it('loading: skeleton', async () => {
await page(pending)
await expectLoading()
})
it('empty: not connected, with Connect Google Calendar', async () => {
await page(notConnected)
expect(await screen.findByRole('heading', { name: 'Not connected' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: 'Connect Google Calendar' })).toBeInTheDocument()
})
it('error: banner with Retry', async () => {
await page(failThen(notConnected))
await expectRetry('Google Calendar status could not load: internal message', () =>
screen.findByRole('heading', { name: 'Not connected' }),
)
})
})
+2
View File
@@ -13,6 +13,7 @@ export type DinnerBody = {
photo_url: string | null
grounding_status: string
grounding_error: string | null
photo_backfill_eligible: boolean
updated_by: string | null
created_at: string
updated_at: string
@@ -35,6 +36,7 @@ export function dinnerBody(id: string, name: string, overrides: Partial<DinnerBo
photo_url: null,
grounding_status: 'grounded',
grounding_error: null,
photo_backfill_eligible: false,
updated_by: null,
created_at: '2026-10-01T15:00:00.000Z',
updated_at: '2026-10-01T15:00:00.000Z',