Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a1c780137 | ||
|
|
6e8b44bb15 | ||
|
|
d182349646 | ||
|
|
72755a8b61 | ||
|
|
0942e65392 | ||
|
|
791b321b71 | ||
|
|
be41b56690 | ||
|
|
3a39fc8f2c | ||
|
|
d8633d56a4 | ||
|
|
9046b6df78 | ||
|
|
759fbbe3dd | ||
|
|
d390d8dcce | ||
|
|
25edb6fb26 | ||
|
|
17fa82ef2b |
@@ -82,7 +82,8 @@ sqlc:
|
||||
$(SQLC) generate
|
||||
|
||||
# Fails on any difference from the committed generated code, including new
|
||||
# or deleted files.
|
||||
# or deleted files. A failing git status is itself a failure: its empty
|
||||
# output would otherwise read as "no drift".
|
||||
sqlc-check: sqlc
|
||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))"; \
|
||||
@drift="$$(git status --porcelain --untracked-files=all -- $(SQLC_OUT))" || { echo "git status failed; cannot check internal/db for drift"; exit 1; }; \
|
||||
if [ -n "$$drift" ]; then echo "internal/db is stale; run make sqlc and commit:"; echo "$$drift"; git diff -- $(SQLC_OUT); exit 1; fi
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ func run(cfg config.Config, logger *slog.Logger) error {
|
||||
return err
|
||||
}
|
||||
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, logger)
|
||||
planSvc, err := plan.NewService(db, weekDrafter(ai, guard), runner, googleSvc, notifier, sched.Wake, cfg.PublicURL, sched.Now, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
# Accessibility pass
|
||||
|
||||
A pass over every route the PWA serves (the routes in [STATES.md](STATES.md))
|
||||
for launch, on 2026-10-10. Each route was checked for:
|
||||
|
||||
- **Keyboard** — every action reachable and operable with the keyboard alone.
|
||||
- **Focus** — a visible focus ring on everything that takes focus.
|
||||
- **Names and roles** — what a screen reader announces: switches, checkboxes,
|
||||
the current nav item, dialogs and their labels.
|
||||
- **Contrast** — text at least 4.5:1 against its ground, for every token pair
|
||||
used for text.
|
||||
- **Hit targets** — every tap target at least 44 × 44 px.
|
||||
- **Reduced motion** — what `prefers-reduced-motion: reduce` turns off.
|
||||
|
||||
The pass read each screen's markup and stylesheets, computed contrast from
|
||||
[tokens.css](../web/src/styles/tokens.css), and checked the fixes' computed
|
||||
styles in Chrome. Severity: **high** blocks a task for keyboard or
|
||||
screen-reader users; **medium** fails a criterion above but leaves a way
|
||||
through; **low** is a gap outside the criteria above, or a polish item. High
|
||||
and medium findings are fixed in this pass with a regression test; low ones are
|
||||
follow-ups.
|
||||
|
||||
## Shared building blocks
|
||||
|
||||
Most of the criteria are met once, in the shared components every screen uses:
|
||||
|
||||
| Concern | Where | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| Focus ring | [base.css](../web/src/styles/base.css) | `:focus-visible` draws a 2 px grape (`--color-accent`) outline 2 px out, on every focusable element. |
|
||||
| Dialogs | [Sheet](../web/src/ui/Sheet.tsx) | `role="dialog"`, `aria-modal="true"`, and a required `aria-label` or `aria-labelledby` (the type will not compile without one). Opening moves focus in, Tab is trapped, Escape and the scrim close, the page behind is `inert`, and focus returns to the opener. Every sheet is labelled by its visible title. |
|
||||
| Toggles | [Toggle](../web/src/ui/Toggle.tsx) | A `<button role="switch" aria-checked>`; Space and Enter flip it; a 44 px hit box around the 40 × 24 track. |
|
||||
| Checkboxes | [Checkbox](../web/src/ui/Checkbox.tsx) | A native `<input type="checkbox">` inside its `<label>`; the label row is the 44 px target. |
|
||||
| Segmented controls | [Segmented](../web/src/ui/Segmented.tsx) | `role="radiogroup"` of `role="radio"` buttons with a roving tab stop; arrows, Home and End move the choice. |
|
||||
| Icon buttons | [IconButton](../web/src/ui/IconButton.tsx) | A required `label` names every icon-only button; the 40 px face sits in a 44 px hit box. |
|
||||
| Primary nav | [BottomBar](../web/src/shell/BottomBar.tsx), [Rail](../web/src/shell/Rail.tsx), [Sidebar](../web/src/shell/Sidebar.tsx) | `<nav aria-label="Primary">` of `NavLink`s; the current item has `aria-current="page"`, nested routes (`/plan/shopping`, `/settings/ai`) keep their section current. The bottom bar's inactive items keep their name in a visually hidden label. |
|
||||
| Status and errors | [ErrorBanner](../web/src/ui/ErrorBanner.tsx), `role="status"` / `role="alert"` lines | Loading regions are `aria-busy` and named; errors are announced. |
|
||||
| Reduced motion | `--motion-scale` in tokens.css; `@media (prefers-reduced-motion: reduce)` in each animated component; `matchMedia` before every Web Animations call | Sheets appear without rising, skeletons stop pulsing, buttons do not scale, toggles and checkboxes do not spring, the nav pill jumps rather than slides, and the Discover deck fades a decided card out with no throw, bump, wiggle or promotion. |
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Keyboard | Focus | Names and roles | Hit targets | Reduced motion |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| App shell (every route) | Nav is links in tab order. | Global ring; **the bottom bar's ring was 1.8:1 on the dark bar — fixed (M2).** | `nav "Primary"`, `aria-current="page"`. | Bottom bar items 62 px tall, ≥ 44 px wide; rail items 56 × 44. | Pill jumps; no label fade. |
|
||||
| `/` Home | All tiles, status rows, Recipe, the bell and the Rotation check are buttons or links. | Global ring; tiles, status rows and "We didn't make it" draw a 3 px ring, inside their cards' padding. | Tonight `section`, Status list; the bell is named "Recent updates" and its sheet is labelled by its title. | Recipe pill and "We didn't make it" kept at 44 px. | — |
|
||||
| `/onboarding` (all steps) | Buttons and form fields; Add Regular submits on Enter. | Global ring. | `h1` per step; progress bar hidden; "Add Regular" and "Add *pick*" named. | Covered by onboarding tests. | — |
|
||||
| `/plan` Draft board (mobile) | **Swap without drag:** each day tile is a button that opens the swap sheet; the sheet's rows are buttons. Previous/Next week, Rebuild week, Push to Calendar are buttons. | Global ring. | Week list "Dinners this week"; swap sheet labelled "Swap *Day*"; Quest log / Regulars radio group. | Tiles ≥ 86 px tall. | Sheet appears without rising. |
|
||||
| `/plan` plan manager (tablet, desktop) | **Swap without drag:** every day has a "Swap *Day*" button opening the swap sheet. Dragging also works by keyboard: Enter or Space picks a dinner up, arrows move between days, Enter drops, Escape cancels; a polite live region narrates each step and focus lands on the day's swap button. | Global ring. | Days named by weekday; the panel is "Quest log and Regulars"; each draggable row is described by the keys hint. | **The tablet-portrait Quest log chips were 38 px tall — fixed (M4).** | — |
|
||||
| `/plan/shopping` Supply run | Native checkboxes; links to the archive. | Global ring. | Groups named by dinner; native checkbox per item; read-only weeks say "have it" in text, not by colour alone. | Checkbox rows 44 px. | — |
|
||||
| `/plan/shopping/archive` | Period radio group; posts are links. | Posts draw a 3 px ring. | Periods named by heading. | — | — |
|
||||
| `/discover` | **← Pass, → Try it** anywhere on the page (not while typing, nor with a sheet open); Pass and Try it are also buttons; the Quest log pill opens its sheet. | Global ring. | Only the top card is exposed, labelled by its name; cards behind are `aria-hidden` and `inert`; Try it / Pass labels are shown under the buttons. | Pass and Try it 62 px; Quest log pill 44 px. | Card fades out; no throw, bump, wiggle, snap-back spring or promotion. |
|
||||
| `/quick` Quick dinners | Tab radio group; add buttons; More link. | Global ring; More draws a 3 px ring. | Rows named by idea; "Added" is a status. | Covered by Quick dinners tests. | — |
|
||||
| `/cook-from-pantry` | Field adds an ingredient on Enter; chips' remove buttons; Find ideas. | The field rings as a whole (`:focus-within`). | Ingredients list "Ingredients on hand"; idea cards named. | Chip remove buttons 44 px. | — |
|
||||
| `/dinner/:id` (phone) | Buttons for Favorite, Add to week, back. | Global ring. | Favorite is `aria-pressed`; Add to week sheet labelled. | Favorite 52 px. | — |
|
||||
| `/dinner/:id` Kitchen reading mode | ← / → move steps (not while typing, nor with a sheet open); Previous and Next step buttons; text size radio group; ingredient checkboxes. | **The text-size pill clipped its focus ring — fixed (M3).** | Keep screen on is a `role="switch"`; ingredients are native checkboxes; the current step is `aria-current="step"`. | Checkboxes 44 px rows. | — |
|
||||
| `/settings` | Rows are links; on tablet, a `tablist` with arrow keys. | Global ring. | Tabs `role="tab"` / `tabpanel`. | Covered by Settings tests. | — |
|
||||
| `/settings/notifications` | Every channel is a switch; reminder time button; webhook form. | Global ring. | Each switch named "*Event*: *Channel*"; the matrix is a named group; the webhook sheet is labelled. | Toggle hit box 44 px. | — |
|
||||
| `/settings/ai` | Selects, slider, Add/Replace key. | Global ring. | Native `select`s and range input with labels; key sheet labelled. | Covered by AI settings tests. | — |
|
||||
| `/settings/household` | Selects; time zone list of buttons. | Global ring. | The chosen time zone is `aria-pressed`. | Zone options 48 px. | — |
|
||||
| `/settings/regulars` | Add, edit and favourite buttons; method selects; recipe search. | Global ring. | Favourite is `aria-pressed`; edit sheet labelled. | Covered by Regulars tests. | — |
|
||||
| `/settings/calendar` | Connect link; calendar select. | Global ring. | Reconnect problems are `role="alert"`. | — | — |
|
||||
|
||||
## Text contrast
|
||||
|
||||
WCAG AA asks 4.5:1 for normal-size text. Every colour a stylesheet gives text,
|
||||
on every ground it is drawn on:
|
||||
|
||||
| Text token | Grounds | Lowest ratio |
|
||||
| --- | --- | --- |
|
||||
| `--color-text` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200, accent-2-300 | 11.10 (on accent-2-300) |
|
||||
| `--color-neutral-700` | bg, surface, neutral-100, neutral-200, accent-100, accent-200, accent-2-100, accent-2-200 | 5.22 (on accent-200) |
|
||||
| `--color-neutral-800` | the same light grounds | 7.90 |
|
||||
| `--color-neutral-900` | the same light grounds | 11.18 |
|
||||
| `--color-accent` | the same light grounds | 7.16 |
|
||||
| `--color-accent-700` | the same light grounds | 5.76 |
|
||||
| `--color-accent-800` | the same light grounds | 8.69 |
|
||||
| `--color-accent-2-800` | the same light grounds | 7.51 |
|
||||
| `--color-accent-2-900` | the light grounds and accent-2-300 | 9.51 (on accent-2-300) |
|
||||
| `--on-accent` | accent, accent-800, neutral-800 | 8.39 (on accent) |
|
||||
| `--color-bg` | text, neutral-800 | 9.26 (on neutral-800) |
|
||||
| `--color-neutral-400` | text (the bottom bar's inactive items) | 8.28 |
|
||||
|
||||
`--color-neutral-600` (3.5:1 on surface, 4.0:1 on bg) was used for muted text
|
||||
in five places and failed — fixed (M1). [contrast.test.ts](../web/src/contrast.test.ts)
|
||||
checks every pair above from tokens.css and fails if any stylesheet gives text a
|
||||
colour token not in this table.
|
||||
|
||||
Non-text: the teal Try it button and teal "+" buttons draw a light icon on
|
||||
`--color-accent-2-600` at 3.73:1, which meets the 3:1 asked of icons; their
|
||||
names are text elsewhere (the shown label, or `aria-label`).
|
||||
|
||||
## Findings
|
||||
|
||||
### Fixed in this pass
|
||||
|
||||
| # | Severity | Route | Finding | Fix | Regression test |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| M1 | Medium | `/plan`, `/dinner/:id`, `/cook-from-pantry` | Muted text in `--color-neutral-600` at 3.5–4.0:1: the swapping day's dinner and start-by time, Regulars already this week in the swap sheet, ingredients set out and steps done in Kitchen reading mode, and the ingredient field's placeholder. | `--color-neutral-700` (≥ 5.4:1 on bg and surface); still muted against `--color-text`, and set-out ingredients keep their strike-through. | [contrast.test.ts](../web/src/contrast.test.ts); the swapping-tile and done-step colour assertions in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx) and [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||||
| M2 | Medium | Every route (phone) | The bottom bar's focus ring was the global grape ring on the near-black bar: 1.8:1, nearly invisible. | The bar's items ring in `--on-accent` (15.4:1 on the bar). | "rings a focused item in on-accent ink" in [BottomBar.test.tsx](../web/src/shell/BottomBar.test.tsx). |
|
||||
| M3 | Medium | `/dinner/:id` Kitchen reading mode | The text-size pill hides overflow, so the focus ring of the chosen size — drawn outside it — was cut off top and bottom, and in grape on the grape fill. | The ring sits 4 px inside the size, in `--on-accent` (8.4:1 on grape). Only the chosen size takes focus (roving tab stop). | "rings the focused size inside the clipped pill" in [KitchenReading.test.tsx](../web/src/dinners/KitchenReading.test.tsx). |
|
||||
| M4 | Medium | `/plan` (tablet portrait, 768–1179 px) | The Quest log strip's draggable chips were 38 px tall (28 px photo, 3 px padding, 2 px border). | The chip button keeps a 44 × 44 px minimum. | The strip test's `expectMinHitArea` in [PlanPage.test.tsx](../web/src/plan/PlanPage.test.tsx). |
|
||||
|
||||
No high findings.
|
||||
|
||||
### Follow-ups (low)
|
||||
|
||||
| # | Route | Finding | Suggested follow-up |
|
||||
| --- | --- | --- | --- |
|
||||
| L1 | Every route with a checkbox | An unchecked checkbox's `--color-neutral-400` border is 1.9:1 on bg — below the 3:1 WCAG asks of a control's boundary. Its label row still names and reveals it. | Darken the border (for example `--color-neutral-600`, 4.0:1) with the design owner. |
|
||||
| L2 | Settings, Kitchen reading mode | An off toggle's `--color-neutral-300` track is 1.4:1 on bg; the knob's position and the switch's name carry the state. | Give the off track a ≥ 3:1 edge with the design owner. |
|
||||
| L3 | `/discover` | The ← / → shortcuts are not announced to screen readers. | Add `aria-keyshortcuts` to Pass and Try it. |
|
||||
| L4 | `/discover` | Deciding the last card replaces Pass and Try it with "All caught up", and focus falls back to the page. | Move focus to "Get more now" when the deck empties. |
|
||||
| L5 | `/discover` | A decision is not announced; the next card's name appears silently. | A polite status ("Added to the Quest log" / "Passed") after each decision. |
|
||||
| L6 | `/plan` (mobile) | A day tile's name is its day and dinner ("MON Beef tacos"); it does not say it opens the swap sheet. | Add "Swap" to the tile's accessible name. |
|
||||
+20
-6
@@ -319,15 +319,22 @@ automatic** (D-021).
|
||||
suggestion with the name it is suggested under, the reason and
|
||||
`cited_regular_ids` — unless, since the job read its input, a dinner
|
||||
it repeats was added, a suggestion it repeats passed, or a Regular it
|
||||
cites renamed or archived (checked in the storing transaction). The job
|
||||
cites renamed or archived (checked in the storing transaction). A
|
||||
notifying batch also rechecks the Side quests it holds after each round
|
||||
and lets go of those no longer eligible, so a later round grounds a
|
||||
spare in place of each; only a change committed between the last
|
||||
recheck and the store can leave it short of 5 with spares unused. The job
|
||||
publishes `{batch_id, stage, suggestion_ids, dropped}` as it goes (`suggesting`,
|
||||
`grounding`, `done`). It fails with `no_regulars`, `ai_failed` (the
|
||||
skill or `normalize_recipe` failed; no Side quest is made up instead,
|
||||
D-017), `search_not_configured` or `search_failed`, and the batch is set
|
||||
`failed`. A failed `scheduled` or `manual` batch has stored nothing; a
|
||||
`failed`. A `scheduled` or `manual` batch that fails before its final
|
||||
store (the AI, grounding, or the store itself) has stored nothing; a
|
||||
failed `replenish` batch's Side quests stored before the failure stay
|
||||
in the deck. If
|
||||
recording the batch's end fails, its job fails; the job runner retries
|
||||
recording the batch's end fails, its job fails, even after the final
|
||||
store committed: a notifying batch's Side quests and their
|
||||
`new_suggestions` then stay; the job runner retries
|
||||
a job's final status write with backoff until it lands, each attempt
|
||||
bounded at 5 seconds; at shutdown an attempt that fails or times out is
|
||||
not retried, and the next start fails the job as interrupted. Recording
|
||||
@@ -1120,7 +1127,10 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
- The week's Supply run (Feature 17) is made from a pushed plan: after
|
||||
a successful `Push`, the handler runs `shopping.GeneratePushed` for
|
||||
the week at the plan revision `Push` pushed, and the 200 answer
|
||||
carries `supply_run: {status, error}`. `status` is `generated`;
|
||||
carries `supply_run: {status, error, title_job_id}`. `title_job_id` is
|
||||
the `title_supply_run` job titling a generated list, as for `POST
|
||||
/shopping/{weekStart}/generate`; null when the list already has its
|
||||
title or was not generated. `status` is `generated`;
|
||||
`archived` when the week's Supply run is in the Supply archive and was
|
||||
left as it was; or `failed` — error `plan_changed` (retryable, logged
|
||||
as a warning) when the plan was edited between the push and
|
||||
@@ -1143,8 +1153,12 @@ Fake-clock tests of a schedule live in `internal/scheduler`, where
|
||||
when the week has no Supply run, 400 `invalid_week`.
|
||||
- `POST /api/v1/shopping/{weekStart}/generate` makes or remakes the list
|
||||
through `shopping.Generate` and answers with the same shape plus
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]`. 404 when the week
|
||||
has no plan, 409 `archived` when its list is in the Supply archive.
|
||||
`skipped: [{date, dinner_id, dinner_name, reason}]` and
|
||||
`title_job_id`, the `title_supply_run` job titling the list, which
|
||||
`GET /api/v1/jobs/{id}` reports on (failed `title_failed` when the title
|
||||
could not be written); null when the list already has its title. 404
|
||||
when the week has no plan, 409 `archived` when its list is in the Supply
|
||||
archive.
|
||||
- `POST /api/v1/shopping/{weekStart}/send-chat` (CSRF-checked) takes
|
||||
`{}` or `{"resend": true}` and posts the Supply run to Google Chat
|
||||
through `chat.PostSupplyRun`, in the week's thread: "Supply run · " and
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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). |
|
||||
+17
-2
@@ -79,6 +79,7 @@ type Auth struct {
|
||||
provider *oidc.Provider
|
||||
client *http.Client
|
||||
cookie loginCookie
|
||||
pending *PendingStates
|
||||
sessions Sessions
|
||||
allowed []string
|
||||
// endSessionURL is the provider's end_session_endpoint; logout ends
|
||||
@@ -157,6 +158,7 @@ func New(ctx context.Context, cfg Config, sessions Sessions, logger *slog.Logger
|
||||
client: client,
|
||||
allowed: slices.Clone(cfg.AllowedGroups),
|
||||
cookie: newLoginCookie(cfg.SecretKey),
|
||||
pending: NewPendingStates(loginCookieTTL),
|
||||
sessions: sessions,
|
||||
endSessionURL: discovered.EndSessionURI,
|
||||
logger: logger,
|
||||
@@ -250,7 +252,13 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
Verifier: oauth2.GenerateVerifier(),
|
||||
Return: returnTo,
|
||||
}
|
||||
if err := a.cookie.set(w, state, time.Now()); err != nil {
|
||||
now := time.Now()
|
||||
if err := a.pending.Add(state.State, now); err != nil {
|
||||
a.logger.WarnContext(r.Context(), "login refused", "error", err)
|
||||
http.Error(w, "too many sign-ins in progress; try again later", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if err := a.cookie.set(w, state, now); err != nil {
|
||||
a.logger.ErrorContext(r.Context(), "set login cookie", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -262,7 +270,7 @@ func (a *Auth) login(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// callback completes the flow: it checks state against the login cookie,
|
||||
// exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// consumes it, exchanges the code, verifies the ID token and nonce, then starts a session
|
||||
// if the account is in an allowed group. Membership is checked on every
|
||||
// login, so an account removed from the group is refused at its next one.
|
||||
func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -283,6 +291,13 @@ func (a *Auth) callback(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !a.pending.Consume(state.State) {
|
||||
a.logger.WarnContext(ctx, "login callback state already used")
|
||||
http.Error(w, "sign-in state mismatch; start again", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
// error and error_description are provider-supplied text; only the
|
||||
// fixed category is logged.
|
||||
|
||||
@@ -497,6 +497,59 @@ func TestCallbackDefaultReturnPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A saved copy of the login cookie still verifies until it expires, so a
|
||||
// replay with a second valid code is refused only because the server
|
||||
// consumed the state on first use.
|
||||
func TestCallbackRefusesReplayedLoginState(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
idToken := h.provider.signRS256(h.provider.claims(l.query.Get("nonce")))
|
||||
|
||||
if resp := h.callback(l, l.query.Get("state"), idToken); resp.StatusCode != http.StatusSeeOther {
|
||||
t.Fatalf("first callback status = %d, want 303", resp.StatusCode)
|
||||
}
|
||||
resp := h.callback(l, l.query.Get("state"), idToken)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("replayed callback status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 1 {
|
||||
t.Errorf("%d sessions started, want 1", n)
|
||||
}
|
||||
assertLoginCookieCleared(t, resp)
|
||||
}
|
||||
|
||||
// Pending login states live in memory, so a login started before a restart
|
||||
// fails closed after it even though its cookie still verifies.
|
||||
func TestCallbackRefusesLoginStartedBeforeRestart(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
l := h.login(t, LoginPath)
|
||||
|
||||
restarted, err := New(context.Background(), Config{
|
||||
Issuer: h.provider.issuer(),
|
||||
ClientID: testClientID,
|
||||
ClientSecret: testClientSecret,
|
||||
PublicURL: testPublicURL,
|
||||
SecretKey: []byte("server-secret-key"),
|
||||
AllowedGroups: testAllowedGroups,
|
||||
}, h.sessions, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := chi.NewRouter()
|
||||
restarted.Mount(r)
|
||||
h.router = r
|
||||
|
||||
resp := h.callback(l, l.query.Get("state"), h.provider.signRS256(h.provider.claims(l.query.Get("nonce"))))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("callback after restart status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if n := len(h.sessions.started()); n != 0 {
|
||||
t.Errorf("%d sessions started, want none", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallbackRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -819,6 +872,44 @@ func TestLoginCookieExpires(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingStates(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := NewPendingStates(time.Minute)
|
||||
now := time.Now()
|
||||
if err := p.Add("first", now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.Consume("first") {
|
||||
t.Error("Consume of a pending state = false")
|
||||
}
|
||||
if p.Consume("first") {
|
||||
t.Error("second Consume of a state = true")
|
||||
}
|
||||
if p.Consume("never-added") {
|
||||
t.Error("Consume of an unknown state = true")
|
||||
}
|
||||
|
||||
for i := range maxPendingStates - 1 {
|
||||
if err := p.Add(fmt.Sprint("s", i), now); err != nil {
|
||||
t.Fatalf("Add %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
// "first" was consumed but has not expired, so it still counts.
|
||||
if err := p.Add("over", now); !errors.Is(err, ErrTooManyPending) {
|
||||
t.Errorf("Add past the bound = %v, want ErrTooManyPending", err)
|
||||
}
|
||||
// Once the earlier states expire, they are forgotten and a new one fits.
|
||||
if err := p.Add("later", now.Add(time.Minute)); err != nil {
|
||||
t.Errorf("Add after expiry: %v", err)
|
||||
}
|
||||
if p.Consume("s0") {
|
||||
t.Error("an expired state was still pending")
|
||||
}
|
||||
if !p.Consume("later") {
|
||||
t.Error("the state added after expiry is not pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRejectsOffOriginReturn(t *testing.T) {
|
||||
t.Parallel()
|
||||
h := newHarness(t)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -153,6 +154,73 @@ func (c FlowCookie) Read(r *http.Request, v flowValue, now time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxPendingStates bounds each flow's pending states. The login route is
|
||||
// unauthenticated, so without a bound anyone could grow the set without
|
||||
// limit; one household never has close to this many flows in progress.
|
||||
const maxPendingStates = 10000
|
||||
|
||||
// ErrTooManyPending means a flow's pending states are at maxPendingStates,
|
||||
// so a new flow is refused until older ones expire.
|
||||
var ErrTooManyPending = errors.New("too many sign-in flows in progress")
|
||||
|
||||
// PendingStates holds the state of every redirect flow started and not yet
|
||||
// completed, until its flow cookie expires. A copy of a flow cookie verifies
|
||||
// until it expires, so a flow is single-use only because its callback
|
||||
// consumes the state here. The states live in memory — MealQuest runs as one
|
||||
// process — so a flow started before a restart fails closed after it.
|
||||
type PendingStates struct {
|
||||
ttl time.Duration
|
||||
mu sync.Mutex
|
||||
states map[string]time.Time
|
||||
// order is every added state in the order added. Every state is pending
|
||||
// for the same ttl, so it is also expiry order and Add forgets expired
|
||||
// states from its front.
|
||||
order []pendingState
|
||||
}
|
||||
|
||||
type pendingState struct {
|
||||
state string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// NewPendingStates returns an empty set whose states are pending for ttl,
|
||||
// the flow cookie's lifetime.
|
||||
func NewPendingStates(ttl time.Duration) *PendingStates {
|
||||
return &PendingStates{ttl: ttl, states: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
// Add records state, started at now, as pending and forgets expired ones.
|
||||
// It fails with ErrTooManyPending when maxPendingStates are pending.
|
||||
func (p *PendingStates) Add(state string, now time.Time) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
expired := 0
|
||||
for expired < len(p.order) && !now.Before(p.order[expired].expires) {
|
||||
delete(p.states, p.order[expired].state)
|
||||
expired++
|
||||
}
|
||||
p.order = p.order[expired:]
|
||||
// The bound counts every unexpired state, consumed or not: a consumed
|
||||
// state leaves states at once but order only when it expires.
|
||||
if len(p.order) >= maxPendingStates {
|
||||
return ErrTooManyPending
|
||||
}
|
||||
expires := now.Add(p.ttl)
|
||||
p.states[state] = expires
|
||||
p.order = append(p.order, pendingState{state: state, expires: expires})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume removes state from the pending states, reporting whether it was
|
||||
// there.
|
||||
func (p *PendingStates) Consume(state string) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_, ok := p.states[state]
|
||||
delete(p.states, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// returnPath validates the optional post-login return path. Empty means "/".
|
||||
// Only same-origin absolute paths are allowed: no scheme, host, protocol-
|
||||
// relative "//" prefix, backslashes or control characters.
|
||||
|
||||
@@ -137,10 +137,9 @@ type Service struct {
|
||||
account sync.Mutex
|
||||
|
||||
// pending holds the state of every connect started and not yet
|
||||
// completed, with when it expires. Complete consumes a state once, so a
|
||||
// replayed cookie finds it gone.
|
||||
pendingMu sync.Mutex
|
||||
pending map[string]time.Time
|
||||
// completed. Complete consumes a state once, so a replayed cookie finds
|
||||
// it gone.
|
||||
pending *auth.PendingStates
|
||||
}
|
||||
|
||||
// NewService returns the service over st, sealing refresh tokens with box.
|
||||
@@ -175,7 +174,7 @@ func NewService(st *store.Store, box *secretbox.Box, cfg Config) (*Service, erro
|
||||
cookie: auth.NewFlowCookie(connectCookieName, connectCookieKeyLabel, connectCookieTTL, cfg.SecretKey),
|
||||
retryDelay: firstRetryDelay,
|
||||
now: time.Now,
|
||||
pending: map[string]time.Time{},
|
||||
pending: auth.NewPendingStates(connectCookieTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -210,10 +209,12 @@ func (s *Service) Connect(w http.ResponseWriter, session auth.Session) (string,
|
||||
Session: sessionBinding(session),
|
||||
}
|
||||
now := s.now()
|
||||
if err := s.pending.Add(state.State, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: %w", err)
|
||||
}
|
||||
if err := s.cookie.Set(w, &state, now); err != nil {
|
||||
return "", fmt.Errorf("gcal: set connect cookie: %w", err)
|
||||
}
|
||||
s.addPending(state.State, now)
|
||||
return s.oauth.AuthCodeURL(state.State,
|
||||
oauth2.AccessTypeOffline,
|
||||
oauth2.ApprovalForce,
|
||||
@@ -243,7 +244,7 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
}
|
||||
// A copy of the cookie verifies until it expires, so the state is
|
||||
// single-use only because it is consumed here.
|
||||
if !s.consumePending(state.State) {
|
||||
if !s.pending.Consume(state.State) {
|
||||
return fmt.Errorf("%w: already used", ErrBadState)
|
||||
}
|
||||
if q.Get("error") != "" {
|
||||
@@ -276,29 +277,6 @@ func (s *Service) Complete(w http.ResponseWriter, r *http.Request, session auth.
|
||||
return s.store(ctx, sealed)
|
||||
}
|
||||
|
||||
// addPending records state, started at now, as pending until the connect
|
||||
// cookie expires, and forgets expired ones.
|
||||
func (s *Service) addPending(state string, now time.Time) {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
for k, expires := range s.pending {
|
||||
if !now.Before(expires) {
|
||||
delete(s.pending, k)
|
||||
}
|
||||
}
|
||||
s.pending[state] = now.Add(connectCookieTTL)
|
||||
}
|
||||
|
||||
// consumePending removes state from the pending states, reporting whether
|
||||
// it was there.
|
||||
func (s *Service) consumePending(state string) bool {
|
||||
s.pendingMu.Lock()
|
||||
defer s.pendingMu.Unlock()
|
||||
_, ok := s.pending[state]
|
||||
delete(s.pending, state)
|
||||
return ok
|
||||
}
|
||||
|
||||
// checkScopes fails with ErrScopes unless token's granted scopes include
|
||||
// every scope in Scopes. Google lets the household untick a permission on
|
||||
// the consent screen and reports what was granted in "scope".
|
||||
|
||||
@@ -94,7 +94,7 @@ func newEnv(t *testing.T) *env {
|
||||
draft := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
plans, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -222,16 +222,44 @@ func TestTonightSwitchesAtLocalMidnight(t *testing.T) {
|
||||
func TestTonightDinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newEnv(t)
|
||||
e.planWeek(t)
|
||||
ctx := t.Context()
|
||||
planID := e.planWeek(t)
|
||||
planned := func(date string) string {
|
||||
t.Helper()
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRowContext(ctx, `SELECT dinner_id FROM plan_days WHERE plan_id = ? AND date = ?`, planID, date).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
h := e.at(t, "2026-10-13 18:00")
|
||||
if h.Tonight == nil || h.Tonight.ID == "" || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-13") || h.Tonight.Origin != "regular" || h.Tonight.Photo != nil ||
|
||||
h.Tonight.MinutesTotal == nil || *h.Tonight.MinutesTotal != 30 {
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular with no photo", h.Tonight)
|
||||
t.Errorf("tonight = %+v, want Tuesday's 30-minute Regular %s with no photo", h.Tonight, planned("2026-10-13"))
|
||||
}
|
||||
h = e.at(t, "2026-10-14 18:00")
|
||||
if h.Tonight == nil || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest of unknown minutes", h.Tonight)
|
||||
if h.Tonight == nil || h.Tonight.ID != planned("2026-10-14") || h.Tonight.Origin != "side_quest" || h.Tonight.MinutesTotal != nil {
|
||||
t.Errorf("tonight = %+v, want Wednesday's Side quest %s of unknown minutes", h.Tonight, planned("2026-10-14"))
|
||||
}
|
||||
|
||||
// Thursday's dinner has a photo: Home serves its list-sized copy.
|
||||
now := store.Now().String()
|
||||
photo, err := db.New(e.st.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
|
||||
ID: store.NewID(), SourceUrl: "https://example.com/thursday.jpg", LocalPath: "photos/thursday.jpg",
|
||||
Width: 800, Height: 600, Sha256: "thursday", FetchedAt: now, CreatedAt: now, UpdatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
thursday := planned("2026-10-15")
|
||||
if _, err := e.st.Writer.ExecContext(ctx, `UPDATE dinners SET photo_id = ? WHERE id = ?`, photo.ID, thursday); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h = e.at(t, "2026-10-15 18:00")
|
||||
wantPhoto := "/media/photos/" + photo.ID + "-400.jpg"
|
||||
if h.Tonight == nil || h.Tonight.ID != thursday || h.Tonight.Photo == nil || *h.Tonight.Photo != wantPhoto {
|
||||
t.Errorf("tonight = %+v, want Thursday's dinner %s with photo %s", h.Tonight, thursday, wantPhoto)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ func (s *Service) revision(ctx context.Context, weekStart store.LocalDate) (int6
|
||||
// the household context, the pool, last week's dinners and the dinner
|
||||
// time.
|
||||
func (s *Service) request(ctx context.Context, weekStart store.LocalDate) (skills.WeekRequest, error) {
|
||||
hctx, err := harness.BuildContext(ctx, s.st, time.Now(), ContextBudget)
|
||||
hctx, err := harness.BuildContext(ctx, s.st, s.now(), ContextBudget)
|
||||
if err != nil {
|
||||
return skills.WeekRequest{}, fmt.Errorf("plan: %w", err)
|
||||
}
|
||||
|
||||
+10
-5
@@ -149,16 +149,19 @@ type Service struct {
|
||||
// publicURL is MEALQUEST_PUBLIC_URL, under which a pushed event links
|
||||
// to its dinner.
|
||||
publicURL *url.URL
|
||||
// now is the clock today is read from: the scheduler's.
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewService returns a service over st that drafts with draft, pushes to
|
||||
// cal with links under publicURL, tells the household through notifier,
|
||||
// which must be over st, calls wake (the scheduler's Wake) after every
|
||||
// committed change to a plan's days, and registers the KindDraftWeek job on
|
||||
// runner, which must not have started yet. The kind is service-only:
|
||||
// committed change to a plan's days, reads today from now (the scheduler's
|
||||
// Now) and registers the KindDraftWeek job on runner, which must not have
|
||||
// started yet. The kind is service-only:
|
||||
// StartDraft and StartScheduledDraft, which keep one draft in flight per
|
||||
// week, are the only way to enqueue it.
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, logger *slog.Logger) (*Service, error) {
|
||||
func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calendar, notifier *notify.Service, wake func(), publicURL *url.URL, now func() time.Time, logger *slog.Logger) (*Service, error) {
|
||||
switch {
|
||||
case st == nil:
|
||||
return nil, errors.New("plan: nil store")
|
||||
@@ -174,10 +177,12 @@ func NewService(st *store.Store, draft Drafter, runner *jobs.Runner, cal Calenda
|
||||
return nil, errors.New("plan: nil wake")
|
||||
case publicURL == nil:
|
||||
return nil, errors.New("plan: nil public URL")
|
||||
case now == nil:
|
||||
return nil, errors.New("plan: nil clock")
|
||||
case logger == nil:
|
||||
return nil, errors.New("plan: nil logger")
|
||||
}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL}
|
||||
s := &Service{st: st, draft: draft, cal: cal, notifier: notifier, wake: wake, logger: logger, publicURL: publicURL, now: now}
|
||||
kind, err := jobs.Register(runner, KindDraftWeek, s.runDraft, Reasons...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -501,7 +506,7 @@ func (s *Service) Tonight(ctx context.Context) (week, today store.LocalDate, err
|
||||
if err != nil {
|
||||
return store.LocalDate{}, store.LocalDate{}, fmt.Errorf("plan: household settings: %w", err)
|
||||
}
|
||||
today = household.Today(time.Now(), zone)
|
||||
today = household.Today(s.now(), zone)
|
||||
week = household.WeekStart(today)
|
||||
_, err = q.GetWeekPlan(ctx, week.String())
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
|
||||
@@ -146,7 +146,7 @@ func newEnv(t *testing.T) *env {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, logger)
|
||||
e.svc, err = plan.NewService(st, drafter, e.runner, e.gcal, notifytest.New(t, st), func() {}, publicURL, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -85,7 +86,21 @@ func (c noCalendar) PushEvents(context.Context, []gcal.Event, func() error) erro
|
||||
// pass the fake clock's waits do not expect.
|
||||
func (e *env) plans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {})
|
||||
return e.newPlans(ids, func() {}, time.Now)
|
||||
}
|
||||
|
||||
// scheduledPlans is plans whose today is read from the clock of the
|
||||
// scheduler start last started, as in the server, so a draft's plan_ready
|
||||
// title follows the fake clock. It drafts only once a scheduler started.
|
||||
func (e *env) scheduledPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, func() {}, func() time.Time {
|
||||
s := e.sched.Load()
|
||||
if s == nil {
|
||||
panic("plan service read the clock before a scheduler started")
|
||||
}
|
||||
return s.Now()
|
||||
})
|
||||
}
|
||||
|
||||
// wakingPlans is plans whose changes wake the scheduler, as in the server.
|
||||
@@ -93,11 +108,12 @@ func (e *env) plans(ids map[string]string) *planEnv {
|
||||
// the wake's pass with changed.
|
||||
func (e *env) wakingPlans(ids map[string]string) *planEnv {
|
||||
e.t.Helper()
|
||||
return e.newPlans(ids, e.wake)
|
||||
return e.newPlans(ids, e.wake, time.Now)
|
||||
}
|
||||
|
||||
// newPlans is plans whose service calls wake after a plan change.
|
||||
func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
// newPlans is plans whose service calls wake after a plan change and reads
|
||||
// today from now.
|
||||
func (e *env) newPlans(ids map[string]string, wake func(), now func() time.Time) *planEnv {
|
||||
e.t.Helper()
|
||||
p := &planEnv{e: e, model: &llm.Fake{}, ids: ids, logs: &syncBuffer{}}
|
||||
guard, err := harness.NewGuard(harness.DefaultMaxCalls)
|
||||
@@ -118,7 +134,7 @@ func (e *env) newPlans(ids map[string]string, wake func()) *planEnv {
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, logger)
|
||||
p.svc, err = plan.NewService(e.st, drafter, runner, noCalendar{e.t}, notifytest.New(e.t, e.st), wake, publicURL, now, logger)
|
||||
if err != nil {
|
||||
e.t.Fatal(err)
|
||||
}
|
||||
@@ -235,6 +251,41 @@ func (e *env) planReady() []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// readyRow is a stored plan_ready notification as these tests check it.
|
||||
type readyRow struct {
|
||||
Key string
|
||||
Channel notify.Channel
|
||||
Title string
|
||||
}
|
||||
|
||||
// planReadyRows returns every plan_ready notification stored, oldest
|
||||
// first, each week's Google Chat row before its push row.
|
||||
func (e *env) planReadyRows() []readyRow {
|
||||
e.t.Helper()
|
||||
var out []readyRow
|
||||
for _, r := range notifytest.Rows(e.t, e.st, notify.PlanReady) {
|
||||
out = append(out, readyRow{Key: r.Key, Channel: r.Channel, Title: r.Payload.Title})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// nextWeekReady is the plan_ready rows of a draft of the week starting
|
||||
// week, drafted the week before: one to Google Chat and one to push.
|
||||
func nextWeekReady(week string) []readyRow {
|
||||
const title = "Next week's plan is ready"
|
||||
key := string(notify.PlanReady) + ":" + week
|
||||
return []readyRow{{key, notify.ChannelChat, title}, {key, notify.ChannelPush, title}}
|
||||
}
|
||||
|
||||
// wantReady fails unless the plan_ready rows are got.
|
||||
func wantReady(t *testing.T, what string, got []readyRow, want ...[]readyRow) {
|
||||
t.Helper()
|
||||
all := slices.Concat(want...)
|
||||
if !slices.Equal(got, all) {
|
||||
t.Fatalf("%s: plan_ready rows = %+v, want %+v", what, got, all)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
succeeded = draftJob{Status: string(jobs.StatusSucceeded)}
|
||||
aiFailed = draftJob{Status: string(jobs.StatusFailed), Error: plan.ReasonAIFailed}
|
||||
@@ -257,7 +308,7 @@ func mustLocalDate(s string) store.LocalDate {
|
||||
// by its Monday once.
|
||||
func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w44)
|
||||
|
||||
@@ -271,6 +322,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "at Friday 09:00", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans at Friday 09:00", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready of W43", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of W43", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
got, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -283,6 +335,7 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
wantDrafts(t, "W44", e.drafts(), succeeded, succeeded)
|
||||
wantKeys(t, "plans", e.weekPlans(), []string{"2026-10-19", "2026-10-26"})
|
||||
wantKeys(t, "plan_ready of W43 and W44", e.planReady(), []string{"plan_ready:2026-10-19", "plan_ready:2026-10-26"})
|
||||
wantReady(t, "plan_ready rows of W43 and W44", e.planReadyRows(), nextWeekReady("2026-10-19"), nextWeekReady("2026-10-26"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43", "2026-W44"})
|
||||
}
|
||||
|
||||
@@ -291,23 +344,25 @@ func TestPlanDraftDraftsNextWeek(t *testing.T) {
|
||||
// notifies no one and says so.
|
||||
func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p.reply(w43)
|
||||
p.reply(w43) // a second draft would succeed too
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
if _, err := p.svc.StartDraft(context.Background(), w43, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantDrafts(t, "manual draft", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready of the manual draft", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows of the manual draft", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
before, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "with a plan", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready with a plan", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows with a plan", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
after, err := p.svc.Get(context.Background(), w43)
|
||||
if err != nil {
|
||||
@@ -327,23 +382,25 @@ func TestPlanDraftLeavesAnExistingPlan(t *testing.T) {
|
||||
func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p := e.scheduledPlans(ids)
|
||||
p.reply(w43)
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
wantDrafts(t, "first process", e.drafts(), succeeded)
|
||||
wantReady(t, "plan_ready rows of the first process", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
r.stop()
|
||||
p.stop()
|
||||
|
||||
// The restarted process has a new job runner, plan service and
|
||||
// scheduler; it would answer a second draft if one were started.
|
||||
p2 := e.plans(ids)
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2.reply(w43)
|
||||
r2 := e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
r2.runUntil(at(2026, 10, 23, 8, 59))
|
||||
wantDrafts(t, "after restart", e.drafts(), succeeded)
|
||||
wantKeys(t, "plans after restart", e.weekPlans(), []string{"2026-10-19"})
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
}
|
||||
|
||||
@@ -354,7 +411,7 @@ func TestPlanDraftNoDuplicateAfterRestart(t *testing.T) {
|
||||
func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
ids := e.pool()
|
||||
p := e.plans(ids)
|
||||
p := e.scheduledPlans(ids)
|
||||
p.model.QueueGenerate(llm.GenerateResult{Err: errors.New("provider down")})
|
||||
|
||||
r := e.start(at(2026, 10, 16, 8, 0), p.svc.DraftSchedule())
|
||||
@@ -362,6 +419,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
wantDrafts(t, "after the AI failed", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plans after the AI failed", e.weekPlans(), nil)
|
||||
wantKeys(t, "plan_ready after the AI failed", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after the AI failed", e.planReadyRows())
|
||||
wantKeys(t, "schedule_runs", e.claimed(scheduler.PlanDraft), []string{"2026-W42", "2026-W43"})
|
||||
|
||||
var status, msg string
|
||||
@@ -377,11 +435,12 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
|
||||
r.stop()
|
||||
p.stop()
|
||||
p2 := e.plans(ids)
|
||||
p2 := e.scheduledPlans(ids)
|
||||
p2.reply(w43)
|
||||
e.start(at(2026, 10, 16, 10, 0), p2.svc.DraftSchedule())
|
||||
wantDrafts(t, "after restart", e.drafts(), aiFailed)
|
||||
wantKeys(t, "plan_ready after restart", e.planReady(), nil)
|
||||
wantReady(t, "plan_ready rows after restart", e.planReadyRows())
|
||||
}
|
||||
|
||||
// TestPlanDraftFollowsSettingsWithoutRestart: a plan_draft_dow and
|
||||
@@ -390,7 +449,7 @@ func TestPlanDraftAIFailureEmitsNothing(t *testing.T) {
|
||||
// week.
|
||||
func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
p := e.plans(e.pool())
|
||||
p := e.scheduledPlans(e.pool())
|
||||
p.reply(w43)
|
||||
|
||||
r := e.start(at(2026, 10, 12, 12, 0), p.svc.DraftSchedule()) // Monday
|
||||
@@ -401,6 +460,7 @@ func TestPlanDraftFollowsSettingsWithoutRestart(t *testing.T) {
|
||||
r.runUntil(at(2026, 10, 14, 18, 30))
|
||||
wantDrafts(t, "at Wednesday 18:30", e.drafts(), succeeded)
|
||||
wantKeys(t, "plan_ready", e.planReady(), []string{"plan_ready:2026-10-19"})
|
||||
wantReady(t, "plan_ready rows", e.planReadyRows(), nextWeekReady("2026-10-19"))
|
||||
|
||||
// Friday 09:00, the old fire time, drafts nothing more.
|
||||
r.runUntil(at(2026, 10, 16, 9, 0))
|
||||
|
||||
@@ -378,6 +378,11 @@ func (s *Scheduler) Wake() {
|
||||
s.signal()
|
||||
}
|
||||
|
||||
// Now is the time on the scheduler's clock, by which its schedules fire.
|
||||
func (s *Scheduler) Now() time.Time {
|
||||
return s.now()
|
||||
}
|
||||
|
||||
// signal leaves one token in wake for the loop.
|
||||
func (s *Scheduler) signal() {
|
||||
select {
|
||||
|
||||
@@ -73,7 +73,7 @@ func newIdeaActionsRouter(t *testing.T, model, normalize *llm.Fake, search *reci
|
||||
t.Error("draft_week ran: an idea action must never draft a plan")
|
||||
return skills.Week{}, context.Canceled
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -111,10 +111,13 @@ type pushedPlan struct {
|
||||
// plan Calendar does not show and pushing again makes it; or
|
||||
// supply_run_failed when it could not be made, and POST
|
||||
// /shopping/{weekStart}/generate makes it again. error is null when status
|
||||
// is generated.
|
||||
// is generated. title_job_id is the job titling a generated Supply run,
|
||||
// which GET /jobs/{id} reports on; null when it already has its title or
|
||||
// was not generated.
|
||||
type pushedSupplyRun struct {
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
Status string `json:"status"`
|
||||
Error *apiError `json:"error"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// postPlanPush pushes the week's plan to the chosen Google calendar
|
||||
@@ -143,7 +146,9 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
// gone.
|
||||
ctx := context.WithoutCancel(r.Context())
|
||||
run := pushedSupplyRun{Status: "generated"}
|
||||
if _, err := shop.GeneratePushed(ctx, week, revision); errors.Is(err, shopping.ErrFrozen) {
|
||||
if g, err := shop.GeneratePushed(ctx, week, revision); err == nil {
|
||||
run.TitleJobID = g.TitleJobID
|
||||
} else if errors.Is(err, shopping.ErrFrozen) {
|
||||
logger.InfoContext(ctx, "Supply run in the Supply archive left as it was after Push to Calendar", "week", week.String())
|
||||
run = pushedSupplyRun{Status: "archived", Error: &apiError{Code: "archived", Message: err.Error()}}
|
||||
} else if errors.Is(err, shopping.ErrPlanChanged) {
|
||||
@@ -153,7 +158,7 @@ func postPlanPush(logger *slog.Logger, svc *plan.Service, shop *shopping.Service
|
||||
Message: "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
Retryable: true,
|
||||
}}
|
||||
} else if err != nil {
|
||||
} else {
|
||||
logger.ErrorContext(ctx, "generate Supply run after Push to Calendar", "week", week.String(), "error", err)
|
||||
run = pushedSupplyRun{Status: "failed", Error: &apiError{
|
||||
Code: "supply_run_failed",
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
||||
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/skills"
|
||||
@@ -93,7 +94,7 @@ func newPlansRouterTitled(t *testing.T, title shopping.Titler) plansEnv {
|
||||
t.Fatal(err)
|
||||
}
|
||||
notifier := notifytest.New(t, st)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -240,7 +241,7 @@ func newTestPlan(t *testing.T) *plan.Service {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no model in this test")
|
||||
}
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, logger)
|
||||
svc, err := plan.NewService(st, fail, runner, newTestGoogle(t), notifytest.New(t, st), func() {}, &url.URL{Scheme: "https", Host: "mealquest.example"}, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -303,7 +304,7 @@ func newTestHomeOn(t *testing.T, st *store.Store) *home.Service {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, logger)
|
||||
planSvc, err := plan.NewService(st, draft, runner, googleSvc, notifier, func() {}, pub, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -60,10 +60,13 @@ type supplyRunItem struct {
|
||||
}
|
||||
|
||||
// generatedSupplyRun is the POST /shopping/{weekStart}/generate answer:
|
||||
// the Supply run as made, and the plan days with nothing to shop for.
|
||||
// the Supply run as made, the plan days with nothing to shop for, and the
|
||||
// title_job_id of the job titling it, which GET /jobs/{id} reports on;
|
||||
// null when the Supply run already has its title.
|
||||
type generatedSupplyRun struct {
|
||||
supplyRun
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
Skipped []shopping.SkippedDay `json:"skipped"`
|
||||
TitleJobID *string `json:"title_job_id"`
|
||||
}
|
||||
|
||||
// sendChatRequest is the POST /shopping/{weekStart}/send-chat body:
|
||||
@@ -108,7 +111,7 @@ func getSupplyRun(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
}
|
||||
|
||||
// postSupplyRunGenerate makes, or remakes, the week's Supply run from its
|
||||
// plan and answers with it and the plan days it skipped.
|
||||
// plan and answers with it, the plan days it skipped and its title job.
|
||||
func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
week, ok := weekStartOrFail(w, r, logger)
|
||||
@@ -129,7 +132,7 @@ func postSupplyRunGenerate(logger *slog.Logger, svc *shopping.Service) http.Hand
|
||||
if skipped == nil {
|
||||
skipped = []shopping.SkippedDay{}
|
||||
}
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped})
|
||||
writeJSON(w, r, logger, http.StatusOK, generatedSupplyRun{supplyRun: out, Skipped: skipped, TitleJobID: g.TitleJobID})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, raw)
|
||||
}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "total_count", "weekStart"}
|
||||
wantTop := []string{"chat_send_unconfirmed_at", "groups", "label", "needed_count", "sent_to_chat_at", "skipped", "status", "title", "title_job_id", "total_count", "weekStart"}
|
||||
if got := keys(raw); !reflect.DeepEqual(got, wantTop) {
|
||||
t.Errorf("generate fields = %v, want %v", got, wantTop)
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func TestSupplyRunShape(t *testing.T) {
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("GET = %d %v", status, raw)
|
||||
}
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" })) {
|
||||
if k := keys(raw); !reflect.DeepEqual(k, slices.DeleteFunc(slices.Clone(wantTop), func(s string) bool { return s == "skipped" || s == "title_job_id" })) {
|
||||
t.Errorf("GET fields = %v", k)
|
||||
}
|
||||
group := raw["groups"].([]any)[0].(map[string]any)
|
||||
@@ -376,8 +376,12 @@ func TestPushPlanMakesSupplyRun(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if k := keys(run); run["status"] != "generated" || run["error"] != nil || !reflect.DeepEqual(k, []string{"error", "status", "title_job_id"}) {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
if job := titleJob(t, e, run["title_job_id"]); job["status"] != "queued" {
|
||||
t.Errorf("title job = %v, want queued: no runner in this test", job)
|
||||
}
|
||||
var l supplyRunJSON
|
||||
if status, got := callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l); status != http.StatusOK {
|
||||
@@ -467,9 +471,11 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
if rec.Code != http.StatusOK || body["status"] != "synced" {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
if want := map[string]any{"status": "generated", "error": nil}; !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if run["status"] != "generated" || run["error"] != nil {
|
||||
t.Errorf("supply_run = %v, want generated with no error", run)
|
||||
}
|
||||
titleJobID, _ := run["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
var l supplyRunJSON
|
||||
callShopping(t, e, http.MethodGet, shoppingPath+"2026-10-12", "", &l)
|
||||
@@ -478,6 +484,7 @@ func TestPushPlanDoesNotWaitForTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
@@ -493,7 +500,8 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
e.startRunner(t)
|
||||
|
||||
var l supplyRunJSON
|
||||
if status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l); status != http.StatusOK {
|
||||
status, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", &l)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("generate = %d %v", status, body)
|
||||
}
|
||||
if l.Title != nil {
|
||||
@@ -502,12 +510,66 @@ func TestSupplyRunGenerateDoesNotWaitForTitle(t *testing.T) {
|
||||
if !reflect.DeepEqual(l.render(), weekLines) {
|
||||
t.Errorf("generated = %v, want %v", l.render(), weekLines)
|
||||
}
|
||||
titleJobID, _ := body["title_job_id"].(string)
|
||||
held.waitEntered(t)
|
||||
|
||||
close(held.release)
|
||||
wantTitleJobSucceeded(t, e, titleJobID)
|
||||
if got := e.waitTitled(t); got != "Chili and Friends" {
|
||||
t.Errorf("title = %q, want the title job's", got)
|
||||
}
|
||||
if _, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil); body["title_job_id"] != nil {
|
||||
t.Errorf("remake title_job_id = %v, want null: the Supply run has its title", body["title_job_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupplyRunGenerateTitleJobFails: a title job that cannot title the
|
||||
// Supply run is reported failed with title_failed by GET /jobs/{id}, and
|
||||
// the week's next generation answers with a new title job.
|
||||
func TestSupplyRunGenerateTitleJobFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
e := newPlansRouter(t)
|
||||
e.groundWeek(t)
|
||||
e.startRunner(t)
|
||||
|
||||
_, body := callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
first := titleJob(t, e, body["title_job_id"])
|
||||
job := waitJob(t, e.h, first["id"].(string))
|
||||
if job["status"] != "failed" || job["error"] != shopping.ReasonTitleFailed {
|
||||
t.Errorf("title job = %v, want failed %s", job, shopping.ReasonTitleFailed)
|
||||
}
|
||||
|
||||
_, body = callShopping(t, e, http.MethodPost, shoppingPath+"2026-10-12/generate", "", nil)
|
||||
if second := titleJob(t, e, body["title_job_id"]); second["id"] == first["id"] {
|
||||
t.Errorf("retry title_job_id = %v, want a new job", second["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// titleJob returns the title_supply_run job id, as GET /jobs/{id} reports
|
||||
// it; id must be a job id string.
|
||||
func titleJob(t *testing.T, e plansEnv, id any) map[string]any {
|
||||
t.Helper()
|
||||
s, ok := id.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("title_job_id = %v, want a job id", id)
|
||||
}
|
||||
rec, job := callDinners(t, e.h, http.MethodGet, "/api/v1/jobs/"+s, "")
|
||||
if rec.Code != http.StatusOK || job["kind"] != shopping.KindTitleSupplyRun {
|
||||
t.Fatalf("GET title job = %d %s, want a %s job", rec.Code, rec.Body, shopping.KindTitleSupplyRun)
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// wantTitleJobSucceeded waits for title job id to finish and fails t
|
||||
// unless GET /jobs/{id} reports it succeeded with the title written.
|
||||
func wantTitleJobSucceeded(t *testing.T, e plansEnv, id string) {
|
||||
t.Helper()
|
||||
titleJob(t, e, id)
|
||||
job := waitJob(t, e.h, id)
|
||||
result, _ := job["result"].(map[string]any)
|
||||
if job["status"] != "succeeded" || result["title"] != "Chili and Friends" {
|
||||
t.Errorf("title job = %v, want succeeded with the title", job)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedPushMakesNoSupplyRun: a push Calendar refused makes no Supply
|
||||
@@ -546,7 +608,7 @@ func TestPushPlanSupplyRunFails(t *testing.T) {
|
||||
"code": "supply_run_failed",
|
||||
"message": "the plan is on Calendar, but its Supply run could not be made; make it again from the Supply run",
|
||||
"retryable": true,
|
||||
}}
|
||||
}, "title_job_id": nil}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -599,7 +661,7 @@ func TestPushPlanEditedBeforeSupplyRun(t *testing.T) {
|
||||
"code": "plan_changed",
|
||||
"message": "the plan is on Calendar as pushed, but it was edited before its Supply run was made, so no Supply run was made from it; push again",
|
||||
"retryable": true,
|
||||
}}
|
||||
}, "title_job_id": nil}
|
||||
if !reflect.DeepEqual(body["supply_run"], want) {
|
||||
t.Errorf("supply_run = %v, want %v", body["supply_run"], want)
|
||||
}
|
||||
@@ -632,7 +694,7 @@ func TestPushPlanLeavesArchivedSupplyRun(t *testing.T) {
|
||||
t.Fatalf("push = %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
run, _ := body["supply_run"].(map[string]any)
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" {
|
||||
if apiErr, _ := run["error"].(map[string]any); run["status"] != "archived" || apiErr["code"] != "archived" || run["title_job_id"] != nil {
|
||||
t.Errorf("supply_run = %v, want archived", body["supply_run"])
|
||||
}
|
||||
var l supplyRunJSON
|
||||
|
||||
@@ -311,3 +311,38 @@ func TestGenerateTitleOverlapAcrossRename(t *testing.T) {
|
||||
t.Errorf("stored title = %v, want one of the two titles", deref(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTitleNotStoredOnReplacedList: a list replaced by a new
|
||||
// untitled list of the same week once archive_title has answered, before
|
||||
// its title is stored, keeps the replacement untitled; the old list's
|
||||
// title job fails, and the failure is logged.
|
||||
func TestGenerateTitleNotStoredOnReplacedList(t *testing.T) {
|
||||
e := titleWeek(t)
|
||||
e.model.QueueGenerate(titled("Noodles, Tacos and Chili"))
|
||||
e.afterTitle = func() {
|
||||
for _, stmt := range []string{
|
||||
`DELETE FROM shopping_items WHERE group_id IN (SELECT id FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1))`,
|
||||
`DELETE FROM shopping_groups WHERE list_id IN (SELECT id FROM shopping_lists WHERE week_start = ?1)`,
|
||||
`UPDATE shopping_lists SET id = 'replacement' WHERE week_start = ?1`,
|
||||
} {
|
||||
if _, err := e.st.Writer.Exec(stmt, week.String()); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g := e.generate(t)
|
||||
wantJob(t, e.waitTitle(t, g), jobs.StatusFailed, shopping.ReasonTitleFailed)
|
||||
|
||||
var id string
|
||||
if err := e.st.Reader.QueryRow(`SELECT id FROM shopping_lists WHERE week_start = ?`, week.String()).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "replacement" || id == g.List.ID {
|
||||
t.Fatalf("week's list = %s, want the replacement of %s", id, g.List.ID)
|
||||
}
|
||||
wantUntitled(t, "replacement's title", e.storedTitle(t))
|
||||
if !strings.Contains(e.log.String(), "the Supply run was frozen or replaced before its title was stored") {
|
||||
t.Errorf("log lacks the failure:\n%s", e.log.String())
|
||||
}
|
||||
}
|
||||
|
||||
+85
-29
@@ -54,7 +54,8 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// no recipe grounds is dropped; any other grounding failure fails the
|
||||
// batch, as does suggest_side_quests failing, with no Side quest made up
|
||||
// instead. The batch ends done or failed. When recording the batch's end
|
||||
// fails, so does the job; when it times out at shutdown, the job is left
|
||||
// fails, so does the job, with what the batch stored, and any
|
||||
// new_suggestions it emitted, kept; when it times out at shutdown, the job is left
|
||||
// running and the batch with it, for the next start to fail.
|
||||
//
|
||||
// When a batch stores its Side quests depends on whether it notifies (see
|
||||
@@ -64,7 +65,12 @@ func (r batchResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
|
||||
// Side quest stored. So the household is told of exactly what the batch
|
||||
// added, once; and a batch that fails at any round, including a later
|
||||
// round's normalize_recipe, stores nothing and sends nothing, leaving no
|
||||
// Side quest in the deck that was never announced. A replenish batch,
|
||||
// Side quest in the deck that was never announced. After each round it
|
||||
// rechecks every Side quest it holds (see eligible) and lets go of those
|
||||
// the household's changes made ineligible, so a later round grounds a
|
||||
// spare Side quest in place of each; only a change committed between the
|
||||
// last recheck and the store can still leave the batch short of
|
||||
// BatchSize while spare Side quests remain. A replenish batch,
|
||||
// which tops up the deck as the household swipes and never notifies,
|
||||
// stores each round's Side quests before the next round starts, so the
|
||||
// deck fills as it goes; those stored before a failure stay in the deck.
|
||||
@@ -146,7 +152,13 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
}
|
||||
res.Dropped += len(round) - len(grounded)
|
||||
if note != nil {
|
||||
held = append(held, grounded...)
|
||||
// Recheck every held Side quest, not just this round's, so
|
||||
// one the household's changes made ineligible while this
|
||||
// round grounded is replaced by a later round, not dropped by
|
||||
// the store once no round is left to replace it.
|
||||
if held, err = s.recheck(ctx, &res, append(held, grounded...)); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
} else if err := s.storeInto(ctx, &res, nil, grounded); err != nil {
|
||||
return batchResult{}, err
|
||||
}
|
||||
@@ -163,6 +175,22 @@ func (s *Service) fill(ctx context.Context, trigger string, job BatchJob, publis
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// recheck returns, in order, those of a notifying batch's held Side
|
||||
// quests that store would still keep (see eligible), adding those left
|
||||
// out to res.Dropped.
|
||||
func (s *Service) recheck(ctx context.Context, res *batchResult, held []groundedQuest) ([]groundedQuest, error) {
|
||||
kept, err := s.eligible(ctx, db.New(s.st.Reader), res.BatchID, held)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Dropped += len(held) - len(kept)
|
||||
out := make([]groundedQuest, len(kept))
|
||||
for i, k := range kept {
|
||||
out[i] = k.groundedQuest
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// storeInto stores quests for res's batch with note (see store) and adds
|
||||
// the suggestions stored, and the Side quests left out, to res.
|
||||
func (s *Service) storeInto(ctx context.Context, res *batchResult, note *notice, quests []groundedQuest) error {
|
||||
@@ -277,11 +305,11 @@ func (s *Service) ground(ctx context.Context, quests []skills.SideQuest) ([]grou
|
||||
// store stores each grounded Side quest of batchID as a dinner with status
|
||||
// suggested and origin side_quest, grounded in its recipe, and a pending
|
||||
// suggestion citing its Regulars by id, all in one transaction, and
|
||||
// returns the suggestion ids. A Side quest is left out when, since
|
||||
// suggest_side_quests ran, a dinner it repeats was added, a suggestion it
|
||||
// repeats passed or a Rotation check dropped, or a Regular it cites renamed
|
||||
// or archived. Passed names are read in the same transaction as the store,
|
||||
// so a Pass or drop committed before it is never missed.
|
||||
// returns the suggestion ids. A Side quest eligible leaves out is not
|
||||
// stored: since suggest_side_quests ran, a dinner it repeats was added, a
|
||||
// suggestion it repeats passed or a Rotation check dropped, or a Regular
|
||||
// it cites renamed or archived. eligible reads in the same transaction as
|
||||
// the store, so a Pass or drop committed before it is never missed.
|
||||
//
|
||||
// When note is not nil and a Side quest is stored, new_suggestions keyed by
|
||||
// note, counting and naming those stored, is emitted in the same
|
||||
@@ -295,28 +323,13 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
var stored []string
|
||||
err := s.st.WithTx(ctx, func(tx *sql.Tx) error {
|
||||
q := db.New(tx)
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
kept, err := s.eligible(ctx, q, batchID, quests)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
return err
|
||||
}
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
citedJSON, err := json.Marshal(cited)
|
||||
for _, k := range kept {
|
||||
gq := k.groundedQuest
|
||||
citedJSON, err := json.Marshal(k.cited)
|
||||
if err != nil {
|
||||
return fmt.Errorf("suggest: encode cited Regulars: %w", err)
|
||||
}
|
||||
@@ -354,7 +367,6 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
}); err != nil {
|
||||
return fmt.Errorf("suggest: create suggestion: %w", err)
|
||||
}
|
||||
names = append(names, d.Name)
|
||||
ids = append(ids, id)
|
||||
stored = append(stored, d.Name)
|
||||
}
|
||||
@@ -372,6 +384,50 @@ func (s *Service) store(ctx context.Context, batchID string, note *notice, quest
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// eligibleQuest is a Side quest store would keep, with the ids of the
|
||||
// Regulars it cites.
|
||||
type eligibleQuest struct {
|
||||
groundedQuest
|
||||
cited []string
|
||||
}
|
||||
|
||||
// eligible returns, in order, those of batchID's quests that q's dinners
|
||||
// and passed names still allow, each with the ids of the Regulars it
|
||||
// cites. A Side quest is left out when a dinner it repeats is unarchived,
|
||||
// a suggestion it repeats passed or a Rotation check dropped, a Regular it
|
||||
// cites is renamed or archived, or an earlier Side quest of quests kept
|
||||
// repeats it. store checks with this in its transaction; a notifying
|
||||
// batch checks the Side quests it holds with it after each round (see
|
||||
// fill).
|
||||
func (s *Service) eligible(ctx context.Context, q *db.Queries, batchID string, quests []groundedQuest) ([]eligibleQuest, error) {
|
||||
existing, err := q.ListUnarchivedDinners(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list dinners for dedupe: %w", err)
|
||||
}
|
||||
names, err := q.ListPassedNames(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("suggest: list passed names for dedupe: %w", err)
|
||||
}
|
||||
regulars := map[string][]string{}
|
||||
for _, row := range existing {
|
||||
names = append(names, row.Dinner.Name)
|
||||
if row.Dinner.Status == dinners.StatusRegular {
|
||||
regulars[row.Dinner.Name] = append(regulars[row.Dinner.Name], row.Dinner.ID)
|
||||
}
|
||||
}
|
||||
var kept []eligibleQuest
|
||||
for _, gq := range quests {
|
||||
cited, ok := citedIDs(gq.quest.CitedRegulars, regulars)
|
||||
if !ok || repeats(gq.quest.Name, names) {
|
||||
s.logger.InfoContext(ctx, "suggested dinner dropped: changed while grounding", "batch_id", batchID)
|
||||
continue
|
||||
}
|
||||
names = append(names, gq.quest.Name)
|
||||
kept = append(kept, eligibleQuest{groundedQuest: gq, cited: cited})
|
||||
}
|
||||
return kept, nil
|
||||
}
|
||||
|
||||
// citedIDs returns the ids of the Regulars named cited, false when one
|
||||
// names no Regular or more than one.
|
||||
func citedIDs(cited []string, regulars map[string][]string) ([]string, bool) {
|
||||
|
||||
@@ -660,6 +660,74 @@ func TestBatchKeepsAtMostBatchSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBatchReplacesSideQuestMadeIneligibleWhileHeld: a notifying batch
|
||||
// holds each round's Side quests until its last round is done; when the
|
||||
// household adds a dinner repeating one held from an earlier round while a
|
||||
// later round grounds, the batch lets it go and grounds a spare Side quest
|
||||
// in its place, so it still stores BatchSize and announces them once.
|
||||
func TestBatchReplacesSideQuestMadeIneligibleWhileHeld(t *testing.T) {
|
||||
for _, trigger := range []string{suggest.TriggerScheduled, suggest.TriggerManual} {
|
||||
t.Run(trigger, func(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
// Fajita Dish B is ungroundable, so the first round holds
|
||||
// BatchSize-1 and a second round grounds the next; the last is
|
||||
// the spare a third round grounds in place of Fajita Dish A.
|
||||
var quests, want []string
|
||||
for i := range suggest.BatchSize + 2 {
|
||||
name := fmt.Sprintf("Fajita Dish %c", 'A'+i)
|
||||
e.search.Results[name] = nil
|
||||
if i != 1 {
|
||||
e.recipe(t, name)
|
||||
}
|
||||
quests = append(quests, sideQuest(name, "Like your Chicken Fajitas.", "Chicken Fajitas"))
|
||||
if i > 1 {
|
||||
want = append(want, name)
|
||||
}
|
||||
}
|
||||
e.reply(quests...)
|
||||
// The second round's search waits, so the household adds a
|
||||
// dinner repeating Fajita Dish A, held from the first round,
|
||||
// while that round grounds.
|
||||
second := fmt.Sprintf("Fajita Dish %c", 'A'+suggest.BatchSize)
|
||||
gate := make(chan struct{})
|
||||
e.search.Gates = map[string]chan struct{}{second: gate}
|
||||
|
||||
b, key := e.startBatch(t, trigger)
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for !slices.Contains(e.search.Queries(), second) {
|
||||
if time.Now().After(deadline) {
|
||||
close(gate)
|
||||
t.Fatalf("search for %s never ran", second)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
e.dinner(t, "Fajita Dish A", "regular")
|
||||
close(gate)
|
||||
|
||||
job := e.waitJob(t, b.JobID)
|
||||
if job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("batch job %s, error %v", job.Status, job.Error)
|
||||
}
|
||||
var res batchResult
|
||||
if err := json.Unmarshal(job.Result, &res); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.SuggestionIDs) != suggest.BatchSize || res.Dropped != 2 {
|
||||
t.Errorf("batch result = %+v, want %d stored and 2 dropped", res, suggest.BatchSize)
|
||||
}
|
||||
if got := e.deckNames(t); !slices.Equal(got, want) {
|
||||
t.Errorf("deck = %v, want %v", got, want)
|
||||
}
|
||||
rows := notifytest.Rows(t, e.st, notify.NewSuggestions)
|
||||
title := fmt.Sprintf("%d new Side quests are in", suggest.BatchSize)
|
||||
if len(rows) != 1 || rows[0].Key != key || rows[0].Payload.Title != title {
|
||||
t.Errorf("new_suggestions rows = %+v, want one keyed %s titled %q", rows, key, title)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeckIsOldestFirst(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.start(t)
|
||||
@@ -1157,7 +1225,7 @@ func TestLaterBatchNeverResuggestsRenamedDrop(t *testing.T) {
|
||||
fail := func(context.Context, string, skills.WeekRequest) (skills.Week, error) {
|
||||
return skills.Week{}, errors.New("no draft in this test")
|
||||
}
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, logger)
|
||||
planSvc, err := plan.NewService(e.st, fail, e.runner, noCalendar{}, notifytest.New(t, e.st), func() {}, &url.URL{Scheme: "https", Host: "meals.example.com"}, time.Now, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Text contrast over the design tokens (docs/A11Y.md, "Text contrast"). Every
|
||||
// colour a stylesheet gives text must be a token listed in TEXT_GROUNDS, and
|
||||
// every pair listed there must reach WCAG AA's 4.5:1 for normal-size text.
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import tokens from './styles/tokens.css?raw'
|
||||
|
||||
const stylesheets = import.meta.glob<string>(['./**/*.css', '!./styles/tokens.css'], {
|
||||
query: '?raw',
|
||||
import: 'default',
|
||||
eager: true,
|
||||
})
|
||||
|
||||
// The grounds MealQuest's pages, cards, rows and tinted fills use.
|
||||
const LIGHT_GROUNDS = [
|
||||
'--color-bg',
|
||||
'--color-surface',
|
||||
'--color-neutral-100',
|
||||
'--color-neutral-200',
|
||||
'--color-accent-100',
|
||||
'--color-accent-200',
|
||||
'--color-accent-2-100',
|
||||
'--color-accent-2-200',
|
||||
]
|
||||
|
||||
// Each text colour token, with every ground it is drawn on.
|
||||
const TEXT_GROUNDS: Record<string, string[]> = {
|
||||
'--color-text': [...LIGHT_GROUNDS, '--color-accent-2-300'],
|
||||
'--color-neutral-700': LIGHT_GROUNDS,
|
||||
'--color-neutral-800': LIGHT_GROUNDS,
|
||||
'--color-neutral-900': LIGHT_GROUNDS,
|
||||
'--color-accent': LIGHT_GROUNDS,
|
||||
'--color-accent-700': LIGHT_GROUNDS,
|
||||
'--color-accent-800': LIGHT_GROUNDS,
|
||||
'--color-accent-2-800': LIGHT_GROUNDS,
|
||||
'--color-accent-2-900': [...LIGHT_GROUNDS, '--color-accent-2-300'],
|
||||
// Light ink on the grape fills (buttons, the active nav pill, the chosen
|
||||
// text size) and on the dark ones (the Offline pill).
|
||||
'--on-accent': ['--color-accent', '--color-accent-800', '--color-neutral-800'],
|
||||
// The dark pill button and the chosen settings tab.
|
||||
'--color-bg': ['--color-text', '--color-neutral-800'],
|
||||
// The bottom bar's inactive items, on the dark bar.
|
||||
'--color-neutral-400': ['--color-text'],
|
||||
}
|
||||
|
||||
function hex(name: string): string {
|
||||
const value = new RegExp(`^\\s*${name}:\\s*(#[0-9a-fA-F]{6})\\b`, 'm').exec(tokens)?.[1]
|
||||
if (value === undefined) throw new Error(`${name} is not a hex colour in tokens.css`)
|
||||
return value
|
||||
}
|
||||
|
||||
// Relative luminance, per WCAG 2.
|
||||
function luminance(colour: string): number {
|
||||
const [r, g, b] = [1, 3, 5].map((i) => {
|
||||
const c = Number.parseInt(colour.slice(i, i + 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
})
|
||||
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!
|
||||
}
|
||||
|
||||
function contrast(a: string, b: string): number {
|
||||
const [light, dark] = [luminance(hex(a)), luminance(hex(b))].sort((x, y) => y - x)
|
||||
return (light! + 0.05) / (dark! + 0.05)
|
||||
}
|
||||
|
||||
describe('text contrast', () => {
|
||||
const pairs = Object.entries(TEXT_GROUNDS).flatMap(([text, grounds]) => grounds.map((ground) => [text, ground] as const))
|
||||
|
||||
it.each(pairs)('%s on %s reaches 4.5:1', (text, ground) => {
|
||||
expect(contrast(text, ground)).toBeGreaterThanOrEqual(4.5)
|
||||
})
|
||||
|
||||
it('gives text only the colour tokens checked above', () => {
|
||||
expect(Object.keys(stylesheets).length).toBeGreaterThan(0)
|
||||
const unchecked = Object.entries(stylesheets).flatMap(([path, css]) =>
|
||||
Array.from(css.matchAll(/(?<![\w-])color:\s*([^;}]+)/g))
|
||||
.map((match) => match[1]!.trim())
|
||||
.filter((value) => value !== 'inherit')
|
||||
.filter((value) => {
|
||||
const token = /^var\((--[\w-]+)\)$/.exec(value)?.[1]
|
||||
return token === undefined || !(token in TEXT_GROUNDS)
|
||||
})
|
||||
.map((value) => `${path}: color: ${value}`),
|
||||
)
|
||||
expect(unchecked).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -341,6 +341,13 @@
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* The pill clips anything outside it, so the focus ring sits inside the
|
||||
size, drawn in on-accent ink: only the chosen size takes focus. */
|
||||
.readingHeader .textSize > :focus-visible {
|
||||
outline-color: var(--on-accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
.readingHeader .textSize > :nth-child(1) {
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -433,7 +440,7 @@
|
||||
}
|
||||
|
||||
.setOut {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -515,7 +522,7 @@
|
||||
}
|
||||
|
||||
.stepDone {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.stepCurrent {
|
||||
|
||||
@@ -325,7 +325,7 @@ describe('Kitchen reading mode', () => {
|
||||
// The step done is muted, its number a check.
|
||||
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
||||
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
|
||||
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
||||
|
||||
fireEvent.click(next)
|
||||
@@ -381,6 +381,21 @@ describe('Kitchen reading mode', () => {
|
||||
})
|
||||
|
||||
describe('text size', () => {
|
||||
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
|
||||
serve()
|
||||
await renderPage()
|
||||
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
||||
// A key press makes the next focus a keyboard one, which
|
||||
// :focus-visible matches.
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
medium.focus()
|
||||
expect(medium.matches(':focus-visible')).toBe(true)
|
||||
// The pill hides overflow, so a ring outside the size would be cut off.
|
||||
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
|
||||
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
|
||||
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
|
||||
})
|
||||
|
||||
it('is remembered on this device', async () => {
|
||||
serve()
|
||||
const view = await renderPage()
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.notice {
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
|
||||
.swapping .dinner,
|
||||
.swapping .startBy {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.rebuildCell {
|
||||
@@ -410,6 +410,12 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* An upright tablet's week: four days over three, every column wide enough
|
||||
that nothing scrolls sideways. */
|
||||
.portraitPage .week {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
|
||||
@@ -8,6 +8,7 @@ import { SupplyRunPage } from '../shopping/SupplyRunPage.tsx'
|
||||
import { cacheFallbackHeader } from '../sw/routes.ts'
|
||||
import { HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { ago, cooking, duration, PlanPage } from './PlanPage.tsx'
|
||||
@@ -1222,7 +1223,7 @@ describe('PlanPage', () => {
|
||||
expect(declaredStyle(friday).background).toBe('var(--color-bg)')
|
||||
expect(declaredStyle(friday).boxShadow).toBe('inset 0 0 0 2px var(--color-accent)')
|
||||
expect(declaredStyle(within(friday).getByText('FRI · SWAPPING…')).color).toBe('var(--color-accent-700)')
|
||||
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-600)')
|
||||
expect(declaredStyle(within(friday).getByText('Pizza night')).color).toBe('var(--color-neutral-700)')
|
||||
|
||||
// "Quest log · N | Regulars · N", on the Quest log first.
|
||||
expect(await within(sheet).findByRole('radio', { name: 'Quest log · 1', checked: true })).toBeInTheDocument()
|
||||
@@ -1391,6 +1392,64 @@ describe('PlanPage', () => {
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("still shows a swap's failure on coming back to the week, through another, and asks for the swap again only on Retry", async () => {
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, planBody('draft')),
|
||||
[`GET ${NEXT_WEEK}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/2026-10-19`]: () => errorResponse(404, 'not_found'),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_not_plannable'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
fireEvent.click(await within(await openFriday()).findByRole('button', { name: /Lemon orzo/ }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Next week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-19'))
|
||||
expect(await screen.findByRole('button', { name: 'Draft this week' })).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
await waitFor(() => expect(screen.getByTestId('search')).toHaveTextContent('?week=2026-10-12'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Previous week' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't swap Friday's dinner. dinner_not_plannable message")
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day, and offers to choose another rather than send it again', async () => {
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
let read = planBody('draft')
|
||||
const fetchMock = stubServer({
|
||||
[`GET ${THIS_WEEK}`]: () => jsonResponse(200, read),
|
||||
...lists,
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
await renderPage()
|
||||
|
||||
const sheet = await openFriday()
|
||||
read = planBody('draft')
|
||||
read.days[2] = { ...read.days[2]!, dinner: { ...read.days[2]!.dinner, id: 'q1', name: 'Lemon orzo' }, is_new: true }
|
||||
fireEvent.click(await within(sheet).findByRole('button', { name: /Lemon orzo/ }))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(alert).toHaveTextContent("Couldn't swap Friday's dinner. Lemon orzo is already on another day this week.")
|
||||
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(within(tile('WED · NEW')).getByText('Lemon orzo')).toBeInTheDocument())
|
||||
expect(within(tile('FRI')).getByText('Pizza night')).toBeInTheDocument()
|
||||
|
||||
// Choosing again opens Friday's sheet on the plan read, where lemon
|
||||
// orzo is already this week; nothing is sent again.
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Choose another dinner' }))
|
||||
const again = screen.getByRole('dialog', { name: 'Swap Friday' })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
const orzo = within(within(again).getByRole('list', { name: 'Quest log' })).getByRole('listitem')
|
||||
expect(within(orzo).getByText('Already this week')).toBeInTheDocument()
|
||||
expect(within(orzo).queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(puts(fetchMock)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('waits for a pending swap before Rebuild week, keeping its failure', async () => {
|
||||
let refuse!: () => void
|
||||
const fetchMock = stubServer({
|
||||
@@ -1644,6 +1703,9 @@ describe.each([
|
||||
const chip = within(within(strip).getByRole('list', { name: 'Quest log dinners' })).getByRole('listitem')
|
||||
expect(chip).toHaveTextContent('Lemon orzo')
|
||||
expect(declaredStyle(within(chip).getByRole('img', { hidden: true, name: 'Lemon orzo' })).width).toBe('28px')
|
||||
// The 28 px photo makes a 38 px chip; the chip is a button, so it
|
||||
// keeps the 44 px tap minimum.
|
||||
expectMinHitArea(within(chip).getByRole('button', { name: /Lemon orzo/ }))
|
||||
// The strip follows the grid.
|
||||
const grid = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
expect(grid.compareDocumentPosition(strip) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Confirm } from '../settings/LeaveGuard.tsx'
|
||||
import { methodLabels } from '../settings/RegularsSettingsPage.tsx'
|
||||
import { formatClockTime, useHouseholdSettings } from '../settings/api.ts'
|
||||
import { supplyRunScreen, useGenerateSupplyRun, useSupplyRun, WEEK_PARAM } from '../shopping/api.ts'
|
||||
import { useBreakpoint, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { useBreakpoint, useOrientation, type Breakpoint } from '../shell/useBreakpoint.ts'
|
||||
import { Button } from '../ui/Button.tsx'
|
||||
import buttonStyles from '../ui/Button.module.css'
|
||||
import { EmptyState } from '../ui/EmptyState.tsx'
|
||||
@@ -127,6 +127,12 @@ export function ago(at: Date, now: Date): string {
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
// alreadyPlanned says whether a swap was refused because its dinner is
|
||||
// already on another day of the week: sending it again cannot succeed.
|
||||
function alreadyPlanned(error: Error): boolean {
|
||||
return error instanceof ApiError && error.code === 'dinner_already_planned'
|
||||
}
|
||||
|
||||
// pushedToCalendar says whether drafting over a plan of this status needs
|
||||
// confirm: it was pushed to Calendar.
|
||||
function pushedToCalendar(status: PlanStatus): boolean {
|
||||
@@ -398,9 +404,18 @@ function WeekBoard({
|
||||
</ErrorBanner>
|
||||
)
|
||||
) : swap.failed !== null ? (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
alreadyPlanned(swap.failed.error) ? (
|
||||
// The plan is read again (useSwapDay): the household chooses another
|
||||
// dinner from it rather than sending the refused swap again.
|
||||
<ErrorBanner onRetry={() => openSwap(swap.failed!.swap.date)} retryLabel="Choose another dinner">
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.swap.dinner.name} is already on another day
|
||||
this week.
|
||||
</ErrorBanner>
|
||||
) : (
|
||||
<ErrorBanner onRetry={() => swapIn(swap.failed!.swap)}>
|
||||
Couldn't swap {weekdayName(swap.failed.swap.date)}'s dinner. {swap.failed.error.message}
|
||||
</ErrorBanner>
|
||||
)
|
||||
) : startDraft.isError ? (
|
||||
<ErrorBanner onRetry={rebuild}>Couldn't start drafting this week. {startDraft.error.message}</ErrorBanner>
|
||||
) : result.error !== null ? (
|
||||
@@ -673,8 +688,10 @@ function PushFailure({ error, cached, onRetry }: { error: Error; cached: boolean
|
||||
// week; the week buttons keep ?week= on the week shown. The board keeps its place in the tree across layouts, so crossing a
|
||||
// breakpoint keeps a draft being followed. From tablet up a dinner is
|
||||
// dragged from the strip or the panel onto a day of the grid to swap it in.
|
||||
// An upright tablet lays the week out four days over three.
|
||||
export function PlanPage() {
|
||||
const layout = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
const settings = useHouseholdSettings()
|
||||
const [params, setParams] = useSearchParams()
|
||||
const chosen = params.get(WEEK_PARAM)
|
||||
@@ -699,7 +716,11 @@ export function PlanPage() {
|
||||
}
|
||||
return (
|
||||
<PlanDragProvider>
|
||||
<div className={`${styles.page} ${styles[`${layout}Page`]}`}>
|
||||
<div
|
||||
className={[styles.page, styles[`${layout}Page`], layout === 'tablet' && orientation === 'portrait' && styles.portraitPage]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<div className={styles.board}>
|
||||
{content}
|
||||
{layout === 'tablet' && <QuestLogStrip />}
|
||||
|
||||
@@ -190,7 +190,12 @@
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* The 28 px photo makes a 38 px chip; the chip is a button, so it keeps the
|
||||
44 px tap minimum. */
|
||||
.chip.draggable {
|
||||
box-sizing: border-box;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 3px 12px 3px 3px;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
/* A dinner already this week: shown, muted, and not a button. */
|
||||
.already {
|
||||
color: var(--color-neutral-600);
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
.skeletonRow {
|
||||
|
||||
@@ -127,6 +127,17 @@ describe('usePlan', () => {
|
||||
['a method it does not know', (body: ReturnType<typeof planBody>) => (body.days[0]!.dinner.method = 'microwave'), 'method "microwave"'],
|
||||
['no method', (body: ReturnType<typeof planBody>) => Reflect.deleteProperty(body.days[0]!.dinner, 'method'), 'method undefined'],
|
||||
['a sync time that is no instant', (body: ReturnType<typeof planBody>) => (body.calendar_synced_at = 'yesterday'), 'calendar_synced_at "yesterday"'],
|
||||
// Date reads both, in this device's zone: neither says which instant.
|
||||
[
|
||||
'a sync time with no offset',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07T15:58:00'),
|
||||
'calendar_synced_at "2026-10-07T15:58:00" is not an instant',
|
||||
],
|
||||
[
|
||||
'a sync date with no time',
|
||||
(body: ReturnType<typeof planBody>) => (body.calendar_synced_at = '2026-10-07'),
|
||||
'calendar_synced_at "2026-10-07" is not an instant',
|
||||
],
|
||||
])('refuses a plan with %s', async (_, change, said) => {
|
||||
const body = planBody()
|
||||
change(body)
|
||||
@@ -208,6 +219,72 @@ describe('useSwapDay', () => {
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
})
|
||||
|
||||
it('leaves a day a later plan read landed, the very day the swap wrote, when the swap of it fails', async () => {
|
||||
const friday = deferred()
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// The read finds Friday lemon orzo, field for field the day the swap
|
||||
// wrote: the server has it, whatever this swap's PUT is answered with.
|
||||
const swapped = { date: '2026-10-09', dinner: ORZO.dinner, is_new: true, start_by: null }
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(plan().days[4]).toEqual(swapped))
|
||||
read = planBody({ 4: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
await act(() => result.current.plan.refetch())
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days[4]).toEqual(swapped)
|
||||
expect(result.current.swap.failed?.error.message).toBe('internal message')
|
||||
})
|
||||
|
||||
it('puts the day back when the swap of it fails with no plan read since, after a later swap of another day', async () => {
|
||||
const friday = deferred()
|
||||
const monday = deferred()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => friday.reply,
|
||||
[`PUT ${MONDAY}`]: () => monday.reply,
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
act(() => result.current.swap.mutate(FISH))
|
||||
await waitFor(() => expect(plan().days[0]!.dinner.name).toBe('Fish tacos'))
|
||||
await act(async () => friday.send(errorResponse(500, 'internal')))
|
||||
|
||||
await waitFor(() => expect(plan().days[4]!.dinner.name).toBe('Dinner 4'))
|
||||
expect(plan().days[0]!.dinner.name).toBe('Fish tacos')
|
||||
await act(async () => monday.send(errorResponse(500, 'internal')))
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(plan().days.map((day) => day.dinner.name)).toEqual(planBody().days.map((day) => day.dinner.name))
|
||||
})
|
||||
|
||||
it('reads the plan again when the dinner is already on another day of the week, and sends the swap only once', async () => {
|
||||
let read = planBody()
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, read),
|
||||
[`PUT ${FRIDAY}`]: () => errorResponse(409, 'dinner_already_planned'),
|
||||
})
|
||||
const { result, plan, queryClient } = await renderSwaps()
|
||||
|
||||
// Meanwhile lemon orzo was planned on Wednesday.
|
||||
read = planBody({ 2: { id: 'q1', name: 'Lemon orzo', is_new: true } })
|
||||
act(() => result.current.swap.mutate(ORZO))
|
||||
|
||||
await waitFor(() => expect(plan().days[2]!.dinner.name).toBe('Lemon orzo'))
|
||||
expect(plan().days[4]!.dinner.name).toBe('Dinner 4')
|
||||
await waitFor(() => expect(queryClient.isMutating()).toBe(0))
|
||||
expect(result.current.swap.failed?.error.message).toBe('dinner_already_planned message')
|
||||
const sent = vi.mocked(fetch).mock.calls.map(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}`)
|
||||
expect(sent.filter((call) => call === `PUT ${FRIDAY}`)).toHaveLength(1)
|
||||
expect(sent.filter((call) => call === `GET ${PLAN}`)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('refuses to dismiss a pending swap', async () => {
|
||||
const friday = deferred()
|
||||
stubServer({ [`GET ${PLAN}`]: () => jsonResponse(200, planBody()), [`PUT ${FRIDAY}`]: () => friday.reply })
|
||||
@@ -353,6 +430,61 @@ describe('week mutations', () => {
|
||||
expect(weekMutations(queryClient, LATER)).toEqual([])
|
||||
})
|
||||
|
||||
it("leaves behind a week's settled swaps but for the failure that speaks for it, which stays until its week leaves it behind", async () => {
|
||||
let refuse = true
|
||||
stubServer({
|
||||
[`GET ${PLAN}`]: () => jsonResponse(200, planBody()),
|
||||
[`PUT ${FRIDAY}`]: () => (refuse ? errorResponse(409, 'dinner_not_plannable') : jsonResponse(200, planBody())),
|
||||
[`PUT ${MONDAY}`]: () => jsonResponse(200, planBody({ 0: { id: 'r1', name: 'Fish tacos' } })),
|
||||
[`GET /api/v1/plans/${NEXT}`]: () => errorResponse(404, 'not_found'),
|
||||
[`GET /api/v1/plans/${LATER}`]: () => errorResponse(404, 'not_found'),
|
||||
})
|
||||
const api = createApiClient(fakeLocation('/plan'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
||||
await api.loadMe()
|
||||
const queryClient = createQueryClient()
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<ApiProvider client={api}>
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
</ApiProvider>
|
||||
)
|
||||
const hook = renderHook(({ week }) => ({ plan: usePlan(week), swap: useSwapDay(week) }), {
|
||||
wrapper,
|
||||
initialProps: { week: WEEK },
|
||||
})
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
|
||||
// Two swaps settle while the week is shown: the earlier is needed for
|
||||
// ordering no longer, and only the latest stays.
|
||||
act(() => hook.result.current.swap.mutate(FISH))
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(weekMutations(queryClient, WEEK)).toEqual(['swap error']))
|
||||
|
||||
// Leaving the week, through two others, keeps the failure alone.
|
||||
for (const week of [NEXT, LATER, NEXT]) {
|
||||
hook.rerender({ week })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
}
|
||||
|
||||
// Coming back shows it; it fails again, and again one stays.
|
||||
hook.rerender({ week: WEEK })
|
||||
expect(hook.result.current.swap.failed?.error.message).toBe('dinner_not_plannable message')
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.failed).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap error'])
|
||||
|
||||
// A swap that succeeds is left behind once the week is.
|
||||
refuse = false
|
||||
act(() => hook.result.current.swap.dismiss())
|
||||
act(() => hook.result.current.swap.mutate(ORZO))
|
||||
await waitFor(() => expect(hook.result.current.swap.succeeded).not.toBeNull())
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual(['swap success'])
|
||||
hook.rerender({ week: NEXT })
|
||||
await waitFor(() => expect(hook.result.current.plan.isSuccess).toBe(true))
|
||||
expect(weekMutations(queryClient, WEEK)).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps a push whose Supply run was not made, settled in a week no longer shown, to be shown on coming back', async () => {
|
||||
const nextPush = deferred()
|
||||
stubServer({
|
||||
|
||||
+102
-35
@@ -1,5 +1,14 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useMutation, useMutationState, useQuery, useQueryClient, type Mutation, type QueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
useMutation,
|
||||
useMutationState,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type Mutation,
|
||||
type Query,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from '@tanstack/react-query'
|
||||
import { ApiError, useApi, type ApiRead } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { METHODS, oneOf, photoId, type Method } from '../dinners/api.ts'
|
||||
@@ -83,10 +92,16 @@ function parseDay(where: string, body: unknown): PlanDay {
|
||||
return { date: body.date, dinner, is_new: body.is_new, start_by: body.start_by }
|
||||
}
|
||||
|
||||
// RFC3339 is an RFC 3339 instant as the server writes it: a full date and
|
||||
// time, to the second or finer, and its offset, Z or ±hh:mm. A date alone,
|
||||
// or a time with no offset, which Date would read in this device's zone, is
|
||||
// not one.
|
||||
const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||
|
||||
// syncedAt reads calendar_synced_at, an RFC 3339 instant or null.
|
||||
function syncedAt(where: string, value: unknown): Date | null {
|
||||
if (value === null) return null
|
||||
const at = typeof value === 'string' ? new Date(value) : null
|
||||
const at = typeof value === 'string' && RFC3339.test(value) ? new Date(value) : null
|
||||
if (at === null || Number.isNaN(at.getTime())) {
|
||||
throw new Error(`${where}: calendar_synced_at ${JSON.stringify(value)} is not an instant`)
|
||||
}
|
||||
@@ -264,38 +279,54 @@ const weeksShown = new WeakMap<QueryClient, WeeksShown>()
|
||||
// time a week is shown, and is never removed, so a swap or push that settles
|
||||
// after its Draft board, or the whole Plan, is gone is still collected.
|
||||
//
|
||||
// When a week is shown, the settled ones of every other week are removed,
|
||||
// unless that week still has one pending: a pending one, and those an
|
||||
// earlier pending one must see, stay across leaving its week and coming
|
||||
// back. When one settles, the same is done, except that a week's latest,
|
||||
// when it failed, stays until a week is next shown, so that coming straight
|
||||
// back to it shows the failure. A week's latest push whose Supply run was
|
||||
// not made stays whatever is shown: the week has no shopping list until one
|
||||
// is made, which settles the push, or a push sent since replaces it, and
|
||||
// making it may still be under way when the week is left. The cache holds
|
||||
// at most the weeks shown's, those of weeks left pending, one push of each
|
||||
// week whose Supply run was not made, and one failure of each week whose
|
||||
// last pending one settled since a week was last shown.
|
||||
// A swap or push pending is kept, with every other of its kind and week:
|
||||
// an earlier pending one must see those sent after it, across leaving its
|
||||
// week and coming back. Once none of a kind and week is pending, only the
|
||||
// latest is needed, and the rest are removed, whenever a week is shown or
|
||||
// one settles. The latest itself stays while its week is shown. In a week
|
||||
// not shown it is removed, unless:
|
||||
//
|
||||
// - it is a failed swap: it stays, whatever is shown, until the week's
|
||||
// board leaves it behind (useWeekSwap, Rebuild week, Push to Calendar),
|
||||
// so coming back to the week shows the failure;
|
||||
// - it is a push whose Supply run was not made: the week has no shopping
|
||||
// list until one is made, which settles the push, or a push sent since
|
||||
// replaces it, and making it may still be under way when the week is
|
||||
// left;
|
||||
// - it is a failed push that settled after its week was left: it stays
|
||||
// until a week is next shown, so coming straight back shows it.
|
||||
//
|
||||
// So once none of its kind is pending, a week keeps at most one settled
|
||||
// swap and one settled push, and a week left keeps none but those above:
|
||||
// settled swaps and pushes never accumulate.
|
||||
function keepWeekMutations(queryClient: QueryClient): WeeksShown {
|
||||
const kept = weeksShown.get(queryClient)
|
||||
if (kept !== undefined) return kept
|
||||
const mutations = queryClient.getMutationCache()
|
||||
// How many times each week is shown at once.
|
||||
const shown = new Map<string, number>()
|
||||
// keep says whether the latest of its kind and week, none of which is
|
||||
// pending, stays; settled says a mutation settling swept, not a week
|
||||
// shown.
|
||||
function keep(mutation: Mutation, of: { kind: 'swap' | 'push'; week: string }, settled: boolean): boolean {
|
||||
if (shown.has(of.week)) return true
|
||||
if (of.kind === 'swap') return failed(mutation)
|
||||
return unmade(mutation) || (settled && failed(mutation))
|
||||
}
|
||||
function sweep(settled: boolean) {
|
||||
// The mutations of each kind of each week not shown, in the order sent.
|
||||
const left = new Map<string, Mutation[]>()
|
||||
// The mutations of each kind of each week, in the order sent.
|
||||
const sent = new Map<string, { of: { kind: 'swap' | 'push'; week: string }; week: Mutation[] }>()
|
||||
for (const mutation of mutations.getAll()) {
|
||||
const of = weekMutation(mutation)
|
||||
if (of === null || shown.has(of.week)) continue
|
||||
if (of === null) continue
|
||||
const id = `${of.kind} ${of.week}`
|
||||
left.set(id, [...(left.get(id) ?? []), mutation])
|
||||
sent.set(id, { of, week: [...(sent.get(id)?.week ?? []), mutation] })
|
||||
}
|
||||
for (const week of left.values()) {
|
||||
for (const { of, week } of sent.values()) {
|
||||
if (week.some((mutation) => mutation.state.status === 'pending')) continue
|
||||
const latest = week.at(-1)
|
||||
for (const mutation of week) {
|
||||
if (mutation === latest && (unmade(mutation) || (settled && failed(mutation)))) continue
|
||||
if (mutation === latest && keep(mutation, of, settled)) continue
|
||||
mutations.remove(mutation)
|
||||
}
|
||||
}
|
||||
@@ -324,6 +355,30 @@ function useWeekShown(weekStart: string) {
|
||||
useEffect(() => keepWeekMutations(queryClient).show(weekStart), [queryClient, weekStart])
|
||||
}
|
||||
|
||||
// swapWrites counts, for each plan query, its updates that were a swap's
|
||||
// own optimistic write or rollback, not a plan read from the server.
|
||||
const swapWrites = new WeakMap<Query, number>()
|
||||
|
||||
// serverReads is how many plans read from the server have landed in the
|
||||
// plan query: its updates, less the swaps' own writes. A plan read lands
|
||||
// whether or not it differs from the plan shown.
|
||||
function serverReads(query: Query): number {
|
||||
return query.state.dataUpdateCount - (swapWrites.get(query) ?? 0)
|
||||
}
|
||||
|
||||
// writeSwap updates the plan query as a swap's own write, which serverReads
|
||||
// does not count.
|
||||
function writeSwap(queryClient: QueryClient, query: Query, update: (plan: Plan | null | undefined) => Plan | null | undefined) {
|
||||
const before = query.state.dataUpdateCount
|
||||
queryClient.setQueryData<Plan | null>(query.queryKey, update)
|
||||
swapWrites.set(query, (swapWrites.get(query) ?? 0) + query.state.dataUpdateCount - before)
|
||||
}
|
||||
|
||||
// planQuery is the plan query keyed key, if the query cache has it.
|
||||
function planQuery(queryClient: QueryClient, key: QueryKey): Query | undefined {
|
||||
return queryClient.getQueryCache().find({ queryKey: key, exact: true })
|
||||
}
|
||||
|
||||
// swapKey keys the swaps of the week starting on weekStart in the mutation
|
||||
// cache, where they outlive the Draft board that sent them.
|
||||
function swapKey(weekStart: string) {
|
||||
@@ -345,9 +400,13 @@ export type SwapDay = {
|
||||
// (PUT /api/v1/plans/{weekStart}/days/{date}). The day shows the dinner at
|
||||
// once, NEW when it came from the Quest log, with no start-by time until the
|
||||
// server answers with the plan as swapped, which then replaces it. A refused
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile.
|
||||
// However the swap ends, the Quest log is read again: the server leaves a
|
||||
// dinner on the plan out of it.
|
||||
// swap puts the day back as it was, unless it has changed again meanwhile:
|
||||
// another swap wrote over it, or any plan read from the server landed
|
||||
// since, even one with the very day the swap wrote. A swap refused because
|
||||
// the dinner is already on another day of the week (409
|
||||
// dinner_already_planned) reads the plan again, so the household chooses
|
||||
// again from the plan as it is. However the swap ends, the Quest log is
|
||||
// read again: the server leaves a dinner on the plan out of it.
|
||||
//
|
||||
// The week's swaps live in the mutation cache, not in the component, so
|
||||
// leaving the week and coming back keeps them: pending and failed are read
|
||||
@@ -374,7 +433,7 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
function mutate(chosen: Swap) {
|
||||
// isLatest says whether no swap of the week was sent after this one.
|
||||
const isLatest = () => mutations.findAll({ mutationKey }).every((other) => other.mutationId <= mutation.mutationId)
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay }>(queryClient, {
|
||||
const mutation = mutations.build<Plan, Error, Swap, { previous: PlanDay; swapped: PlanDay; query: Query; reads: number }>(queryClient, {
|
||||
mutationKey,
|
||||
// Kept until dismissed or settled in a week not shown
|
||||
// (keepWeekMutations): a later swap must stay in the cache while an
|
||||
@@ -387,24 +446,32 @@ export function useSwapDay(weekStart: string): SwapDay {
|
||||
},
|
||||
onMutate: async ({ date, dinner, fromQuestLog }) => {
|
||||
await queryClient.cancelQueries({ queryKey: key })
|
||||
const plan = queryClient.getQueryData<Plan | null>(key)
|
||||
const query = planQuery(queryClient, key)
|
||||
const plan = query?.state.data as Plan | null | undefined
|
||||
const previous = plan?.days.find((day) => day.date === date)
|
||||
if (!plan || previous === undefined) throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
if (query === undefined || !plan || previous === undefined) {
|
||||
throw new Error(`The week of ${weekStart} has no plan day ${date} to swap.`)
|
||||
}
|
||||
const swapped: PlanDay = { date, dinner, is_new: fromQuestLog, start_by: null }
|
||||
queryClient.setQueryData<Plan>(key, { ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) })
|
||||
return { previous, swapped }
|
||||
writeSwap(queryClient, query, () => ({ ...plan, days: plan.days.map((day) => (day.date === date ? swapped : day)) }))
|
||||
return { previous, swapped, query, reads: serverReads(query) }
|
||||
},
|
||||
onSuccess: (plan) => {
|
||||
if (isLatest()) queryClient.setQueryData<Plan>(key, plan)
|
||||
},
|
||||
// The day goes back only while it is still, field for field, the day
|
||||
// this swap wrote: a later swap or plan read that replaced it is left
|
||||
// alone.
|
||||
onError: (_error, _swap, context) => {
|
||||
if (context === undefined) return
|
||||
queryClient.setQueryData<Plan | null>(key, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
// this swap wrote, and no plan read from the server has landed since
|
||||
// (serverReads): a later swap that replaced it, or a read that did,
|
||||
// even with the same day, is left alone.
|
||||
onError: (error, _swap, context) => {
|
||||
if (context !== undefined && planQuery(queryClient, key) === context.query && serverReads(context.query) === context.reads) {
|
||||
writeSwap(queryClient, context.query, (plan) =>
|
||||
plan && { ...plan, days: plan.days.map((day) => (sameDay(day, context.swapped) ? context.previous : day)) },
|
||||
)
|
||||
}
|
||||
if (error instanceof ApiError && error.code === 'dinner_already_planned') {
|
||||
void queryClient.invalidateQueries({ queryKey: key })
|
||||
}
|
||||
},
|
||||
onSettled: () => queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }),
|
||||
})
|
||||
|
||||
@@ -80,7 +80,10 @@ export function RegularsSettingsPage() {
|
||||
// Which sheet is open, for which Regular.
|
||||
type Open = { sheet: 'edit' | 'fix'; id: string } | null
|
||||
|
||||
function RegularsManager() {
|
||||
// RegularsManager lists, adds, edits and fixes the recipes of the
|
||||
// household's Regulars: this page's body, and the Settings "Regular
|
||||
// dinners" sub-tab at tablet and desktop widths.
|
||||
export function RegularsManager() {
|
||||
const regulars = useRegulars()
|
||||
const add = useAddRegular()
|
||||
const remove = useRemoveRegular()
|
||||
|
||||
@@ -104,7 +104,10 @@ describe('SettingsPage', () => {
|
||||
expect(await screen.findByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
expect(within(row('AI assistant')).getByText('Anthropic · Claude Sonnet')).toBeInTheDocument()
|
||||
expect(within(row('Household')).getByText('America/New_York · Dinner at 5:30 pm')).toBeInTheDocument()
|
||||
// The rotation count and the hidden Regular dinners sub-tab both read
|
||||
// the Regulars.
|
||||
expect(fetchMock.mock.calls.map(([input]) => String(input)).sort()).toEqual([
|
||||
REGULARS_PATH,
|
||||
REGULARS_PATH,
|
||||
CALENDAR_PATH,
|
||||
'/api/v1/settings/ai',
|
||||
@@ -117,7 +120,7 @@ describe('SettingsPage', () => {
|
||||
})
|
||||
|
||||
it('shows the not-yet-available states while the server has no endpoint for them', async () => {
|
||||
stubSettings()
|
||||
stubSettings({ [REGULARS_PATH]: () => errorResponse(404, 'not_found') })
|
||||
renderSettings()
|
||||
expect(await within(row('Regular dinners')).findByText('Rotation count unavailable')).toBeInTheDocument()
|
||||
expect(
|
||||
|
||||
@@ -83,6 +83,12 @@
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* An upright tablet stacks the columns: Notifications above the AI
|
||||
assistant and Recipe search. */
|
||||
.portrait .columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.column {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
@@ -95,24 +101,3 @@
|
||||
padding: 24px;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-family: var(--font-heading);
|
||||
font-size: 22px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
font-family: var(--font-body);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
color: var(--color-neutral-700);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
HOUSEHOLD_SETTINGS,
|
||||
NOTIFICATION_SETTINGS,
|
||||
RECIPE_SEARCH_SETTINGS,
|
||||
SETTINGS_REGULARS,
|
||||
} from '../test/ApiProviders.tsx'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
||||
import { fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { expectMinHitArea } from '../test/hitArea.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import { SettingsPage } from './SettingsPage.tsx'
|
||||
@@ -32,6 +34,7 @@ function stubAPI(responders: Record<string, Responder> = {}): Mock<typeof fetch>
|
||||
'GET /api/v1/settings/recipe-search': () => jsonResponse(200, RECIPE_SEARCH_SETTINGS),
|
||||
'GET /api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'GET /api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK),
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
...responders,
|
||||
}
|
||||
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
||||
@@ -121,7 +124,7 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
expect(tab('Regular dinners')).toHaveAttribute('aria-selected', 'true')
|
||||
expect(tab('Regular dinners')).toHaveFocus()
|
||||
expect(panel('Regular dinners')).toBeVisible()
|
||||
expect(within(panel('Regular dinners')).getByRole('region', { name: 'Regular dinners' })).toBeInTheDocument()
|
||||
expect(await within(panel('Regular dinners')).findByRole('heading', { name: 'On rotation · 1' })).toBeInTheDocument()
|
||||
expect(panel('Household')).not.toBeVisible()
|
||||
|
||||
fireEvent.keyDown(tab('Regular dinners'), { key: 'End' })
|
||||
@@ -165,6 +168,123 @@ describe.each([1440, 1180])('SettingsPage at %i px (mock 2e)', (width) => {
|
||||
})
|
||||
})
|
||||
|
||||
const TACOS = dinnerBody('d1', 'Beef tacos', { method: 'stovetop', minutes_total: 40, difficulty: 'easy' })
|
||||
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
||||
|
||||
// stubRegulars answers the Regulars from server.regulars, so a write's
|
||||
// responder changes what the next read sees.
|
||||
function stubRegulars(regulars: DinnerBody[], responders: (server: { regulars: DinnerBody[] }) => Record<string, Responder>) {
|
||||
const server = { regulars }
|
||||
const fetchMock = stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => jsonResponse(200, { dinners: server.regulars }),
|
||||
...responders(server),
|
||||
})
|
||||
return { server, fetchMock }
|
||||
}
|
||||
|
||||
// writes lists the requests other than GETs, as method, path and body.
|
||||
function writes(fetchMock: Mock<typeof fetch>) {
|
||||
return fetchMock.mock.calls
|
||||
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
||||
.map(([input, init]) => [init?.method, String(input), init?.body])
|
||||
}
|
||||
|
||||
// openRegulars shows the Regular dinners tab once its Regulars have loaded.
|
||||
async function openRegulars() {
|
||||
fireEvent.click(tab('Regular dinners'))
|
||||
const regulars = within(panel('Regular dinners'))
|
||||
await regulars.findByRole('heading', { level: 2, name: /^On rotation/ })
|
||||
return regulars
|
||||
}
|
||||
|
||||
function regularRow(name: string) {
|
||||
const item = within(screen.getByRole('list', { name: 'Regulars' }))
|
||||
.getAllByRole('listitem')
|
||||
.find((li) => within(li).queryByText(name) !== null)
|
||||
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
||||
return within(item)
|
||||
}
|
||||
|
||||
describe.each([1440, 1180])('SettingsPage Regular dinners tab at %i px (mock 2e)', (width) => {
|
||||
it('lists the Regulars as /settings/regulars does, under the page title rather than its own', async () => {
|
||||
stubRegulars([TACOS, PIZZA], () => ({}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
expect(regulars.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Stovetop · 40 min')).toBeInTheDocument()
|
||||
expect(regularRow('Beef tacos').getByText('Recipe found')).toBeInTheDocument()
|
||||
expect(regularRow('Pizza night').getByText('Needs a recipe')).toBeInTheDocument()
|
||||
expect(regulars.getByRole('textbox', { name: 'Add a Regular' })).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('heading', { level: 1 }).map((h) => h.textContent)).toEqual(['Settings'])
|
||||
expect(regulars.queryByRole('button', { name: 'Back to Settings' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a Regular by name', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'POST /api/v1/dinners': (body) => {
|
||||
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
||||
server.regulars = [...server.regulars, created]
|
||||
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
const regulars = await openRegulars()
|
||||
fireEvent.change(regulars.getByRole('textbox', { name: 'Add a Regular' }), { target: { value: 'Chicken enchiladas' } })
|
||||
fireEvent.click(regulars.getByRole('button', { name: 'Add Regular' }))
|
||||
|
||||
expect(await regulars.findByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
||||
expect(regularRow('Chicken enchiladas').getByText('Finding recipe…')).toBeInTheDocument()
|
||||
expect(writes(fetchMock)).toEqual([['POST', '/api/v1/dinners', JSON.stringify({ name: 'Chicken enchiladas' })]])
|
||||
})
|
||||
|
||||
it('edits a Regular in its sheet', async () => {
|
||||
const { fetchMock } = stubRegulars([TACOS], (server) => ({
|
||||
'PATCH /api/v1/dinners/d1': (body) => {
|
||||
const edited = { ...TACOS, ...(body as object) }
|
||||
server.regulars = [edited]
|
||||
return jsonResponse(200, edited)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Beef tacos').getByRole('button', { name: /^Beef tacos/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
||||
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
||||
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([['PATCH', '/api/v1/dinners/d1', JSON.stringify({ minutes_total: 25 })]])
|
||||
expect(await regularRow('Beef tacos').findByText('Stovetop · 25 min')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('fixes the recipe of a Regular that needs one', async () => {
|
||||
const { fetchMock } = stubRegulars([PIZZA], (server) => ({
|
||||
'GET /api/v1/dinners/d3/recipe-candidates': () =>
|
||||
jsonResponse(200, {
|
||||
candidates: [
|
||||
{ candidate_id: 'https://example.com/pizza', title: 'Weeknight pizza', site_name: 'Example Kitchen', photo_url: null, minutes: 45 },
|
||||
],
|
||||
}),
|
||||
'POST /api/v1/dinners/d3/ground': () => {
|
||||
const fixed = { ...PIZZA, grounding_status: 'grounded', grounding_error: null }
|
||||
server.regulars = [fixed]
|
||||
return jsonResponse(200, fixed)
|
||||
},
|
||||
}))
|
||||
await renderAt(width)
|
||||
await openRegulars()
|
||||
fireEvent.click(regularRow('Pizza night').getByRole('button', { name: /^Pizza night/ }))
|
||||
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
||||
fireEvent.click(await sheet.findByRole('button', { name: 'Use Weeknight pizza from Example Kitchen' }))
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(writes(fetchMock)).toEqual([
|
||||
['POST', '/api/v1/dinners/d3/ground', JSON.stringify({ candidate_id: 'https://example.com/pizza' })],
|
||||
])
|
||||
expect(await regularRow('Pizza night').findByText('Recipe found')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SettingsPage reminder time across tabs', () => {
|
||||
it('shows a reminder time saved on Notifications in the Household tab, keeping its draft', async () => {
|
||||
const saved = { ...HOUSEHOLD_SETTINGS }
|
||||
@@ -195,10 +315,7 @@ describe('SettingsPage reminder time across tabs', () => {
|
||||
|
||||
describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
it('shows the mobile list below 768 px and the sub-tabs from 768 px', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
const resize = await renderAt(767)
|
||||
expect(screen.getByRole('link', { name: /^AI assistant/ })).toHaveAttribute('href', '/settings/ai')
|
||||
expect(screen.queryByRole('tablist')).not.toBeInTheDocument()
|
||||
@@ -209,10 +326,7 @@ describe('SettingsPage across the 768 px breakpoint', () => {
|
||||
})
|
||||
|
||||
it('keeps an unsaved draft and its leave guard across 1180 px, 390 px and back', async () => {
|
||||
stubAPI({
|
||||
'GET /api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
})
|
||||
stubAPI({ 'GET /api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED) })
|
||||
const resize = await renderAt(1180)
|
||||
fireEvent.change(await screen.findByRole('textbox', { name: 'System prompt' }), {
|
||||
target: { value: 'A draft' },
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
|
||||
import { Card } from '../ui/Card.tsx'
|
||||
import { useBreakpoint, useOrientation } from '../shell/useBreakpoint.ts'
|
||||
import { AIAssistantCard } from './AISettingsPage.tsx'
|
||||
import { HouseholdSettingsSections } from './HouseholdSettingsPage.tsx'
|
||||
import { LeaveGuard } from './LeaveGuard.tsx'
|
||||
import { ChatWebhookCard, NotificationsCard } from './NotificationsPage.tsx'
|
||||
import { RecipeSearchCard } from './RecipeSearchCard.tsx'
|
||||
import { RegularsManager } from './RegularsSettingsPage.tsx'
|
||||
import styles from './SettingsTabs.module.css'
|
||||
|
||||
type Tab = 'household' | 'regulars' | 'notifications-ai'
|
||||
@@ -21,7 +22,10 @@ const step: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1 }
|
||||
// sub-tabs instead of the mobile list. Every tab stays mounted while hidden,
|
||||
// so a draft survives switching tabs, and one leave guard covers them all.
|
||||
// hidden hides the page but keeps it mounted, drafts and leave guard included.
|
||||
// On an upright tablet the two columns stack into one.
|
||||
export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
const layout = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
const [tab, setTab] = useState<Tab>('notifications-ai')
|
||||
const [householdDirty, setHouseholdDirty] = useState(false)
|
||||
const [aiDirty, setAIDirty] = useState(false)
|
||||
@@ -64,7 +68,10 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page} hidden={hidden}>
|
||||
<div
|
||||
className={[styles.page, layout === 'tablet' && orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}
|
||||
hidden={hidden}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.heading}>
|
||||
<h1 className={styles.title}>Settings</h1>
|
||||
@@ -94,13 +101,7 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
|
||||
{panel('household', styles.single, <HouseholdSettingsSections onDirtyChange={setHouseholdDirty} />)}
|
||||
|
||||
{panel(
|
||||
'regulars',
|
||||
styles.single,
|
||||
// TODO(Feature 12): embed the Regular dinners screen here once Feature
|
||||
// 12 builds it.
|
||||
<Placeholder title="Regular dinners">Managing the household's Regulars arrives in a later update.</Placeholder>,
|
||||
)}
|
||||
{panel('regulars', styles.single, <RegularsManager />)}
|
||||
|
||||
{panel(
|
||||
'notifications-ai',
|
||||
@@ -121,17 +122,3 @@ export function SettingsTabs({ hidden = false }: { hidden?: boolean }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Placeholder is a neutral card holding a section's place until the feature
|
||||
// that owns it renders it.
|
||||
function Placeholder({ title, children }: { title: string; children: ReactNode }) {
|
||||
const titleId = useId()
|
||||
return (
|
||||
<Card className={styles.placeholder} aria-labelledby={titleId} role="region">
|
||||
<h2 id={titleId} className={styles.cardTitle}>
|
||||
{title}
|
||||
</h2>
|
||||
<p className={styles.hint}>{children}</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,3 +22,9 @@
|
||||
.desktop .main {
|
||||
padding: 32px 36px;
|
||||
}
|
||||
|
||||
/* An upright tablet (820 px wide) leaves 724 px beside the rail; slimmer side
|
||||
padding gives the page a little more of it. */
|
||||
.tablet.portrait .main {
|
||||
padding: 32px 28px;
|
||||
}
|
||||
|
||||
@@ -2,15 +2,17 @@ import { Outlet } from 'react-router'
|
||||
import { BottomBar } from './BottomBar.tsx'
|
||||
import { Rail } from './Rail.tsx'
|
||||
import { Sidebar } from './Sidebar.tsx'
|
||||
import { useBreakpoint } from './useBreakpoint.ts'
|
||||
import { useBreakpoint, useOrientation } from './useBreakpoint.ts'
|
||||
import styles from './AppShell.module.css'
|
||||
|
||||
// The responsive frame around every page: one nav layout per breakpoint and a
|
||||
// scrolling main area for the routed page.
|
||||
// scrolling main area for the routed page. A portrait viewport is marked so
|
||||
// the narrower upright tablet keeps its content clear of the rail.
|
||||
export function AppShell() {
|
||||
const breakpoint = useBreakpoint()
|
||||
const orientation = useOrientation()
|
||||
return (
|
||||
<div className={`${styles.shell} ${styles[breakpoint]}`}>
|
||||
<div className={[styles.shell, styles[breakpoint], orientation === 'portrait' && styles.portrait].filter(Boolean).join(' ')}>
|
||||
{breakpoint === 'mobile' && <BottomBar />}
|
||||
{breakpoint === 'tablet' && <Rail />}
|
||||
{breakpoint === 'desktop' && <Sidebar />}
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
/* The app-wide grape focus ring barely shows on the dark bar (1.8:1); the
|
||||
bar's ring is the light on-accent ink instead. */
|
||||
.item:focus-visible {
|
||||
outline-color: var(--on-accent);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-family: var(--font-body);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -182,6 +182,18 @@ describe('BottomBar', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('rings a focused item in on-accent ink, which shows on the dark bar', async () => {
|
||||
await renderAt('/')
|
||||
const item = link('Plan')
|
||||
// A key press makes the next focus a keyboard one, which :focus-visible
|
||||
// matches; earlier tests' clicks would leave it a pointer one.
|
||||
fireEvent.keyDown(document.body, { key: 'Tab' })
|
||||
item.focus()
|
||||
expect(item).toHaveFocus()
|
||||
expect(item.matches(':focus-visible')).toBe(true)
|
||||
expect(declaredStyle(item).getPropertyValue('outline-color')).toBe('var(--on-accent)')
|
||||
})
|
||||
|
||||
it('does not animate when the active item stays the same', async () => {
|
||||
await renderAt('/plan')
|
||||
fireEvent.click(link('Plan'))
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppRoutes } from '../App.tsx'
|
||||
import planStyles from '../plan/PlanPage.module.css'
|
||||
import settingsTabsStyles from '../settings/SettingsTabs.module.css'
|
||||
import {
|
||||
AI_MODELS,
|
||||
AI_SETTINGS,
|
||||
CHAT_WEBHOOK_NONE,
|
||||
GOOGLE_NOT_CONNECTED,
|
||||
HOME,
|
||||
HOUSEHOLD_SETTINGS,
|
||||
NOTIFICATION_SETTINGS,
|
||||
RECIPE_SEARCH_SETTINGS,
|
||||
ApiProviders,
|
||||
} from '../test/ApiProviders.tsx'
|
||||
import { declaredStyle } from '../test/cssRules.ts'
|
||||
import { InDataRouter } from '../test/dataRouter.tsx'
|
||||
import { dinnerBody } from '../test/dinners.ts'
|
||||
import { jsonResponse, ME } from '../test/fetchStub.ts'
|
||||
import { mockViewport } from '../test/matchMedia.ts'
|
||||
import shellStyles from './AppShell.module.css'
|
||||
import railStyles from './Rail.module.css'
|
||||
|
||||
// Every primary route on a tablet held upright (820 × 1180) and on its side
|
||||
// (1180 × 820): both have the 96 px rail and the tablet layout; upright, the
|
||||
// shell is marked portrait, the Plan week reflows four days over three, and
|
||||
// Settings' two columns stack into one.
|
||||
|
||||
const PORTRAIT = { width: 820, height: 1180 }
|
||||
const LANDSCAPE = { width: 1180, height: 820 }
|
||||
|
||||
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
|
||||
|
||||
const PLAN = {
|
||||
id: 'plan-1',
|
||||
week_start: '2026-10-05',
|
||||
week_label: 'Week 41 · Oct 5 – 11',
|
||||
status: 'draft',
|
||||
ai_note: null,
|
||||
calendar_synced_at: null,
|
||||
days: DAYS.map((date, i) => ({
|
||||
date,
|
||||
dinner: { id: `d${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
|
||||
is_new: false,
|
||||
start_by: null,
|
||||
})),
|
||||
updated_at: '2026-10-04T15:00:00Z',
|
||||
}
|
||||
|
||||
const SUPPLY_RUN = {
|
||||
weekStart: '2026-10-05',
|
||||
label: 'Week 41 · Oct 5 – 11',
|
||||
title: null,
|
||||
status: 'current',
|
||||
sent_to_chat_at: null,
|
||||
chat_send_unconfirmed_at: null,
|
||||
needed_count: 1,
|
||||
total_count: 1,
|
||||
groups: [
|
||||
{
|
||||
dinner: { id: 'd0', name: 'Dinner 0' },
|
||||
date: '2026-10-05',
|
||||
day: 'Monday',
|
||||
items: [{ id: 'i1', name: 'Ground beef', qty: '1 lb', needed: true }],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const DETAIL = {
|
||||
id: 'd1',
|
||||
name: 'Korean beef bowls',
|
||||
status: 'regular',
|
||||
origin: 'regular',
|
||||
method: 'stovetop',
|
||||
minutes_total: 30,
|
||||
difficulty: 'easy',
|
||||
cuisine: 'korean',
|
||||
is_favorite: false,
|
||||
photo_urls: null,
|
||||
ingredients: [{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' }],
|
||||
steps: ['Brown the beef.'],
|
||||
source: null,
|
||||
grounding_status: 'manual',
|
||||
grounding_error: null,
|
||||
why_picked: null,
|
||||
}
|
||||
|
||||
// The API every route reads, answered as a settled household's server would.
|
||||
// Any other request stays pending, so a route that reads more just shows its
|
||||
// loading state.
|
||||
const API: Record<string, unknown> = {
|
||||
'/api/v1/me': ME,
|
||||
'/api/v1/home': HOME,
|
||||
'/api/v1/rotation-reviews': { reviews: [] },
|
||||
'/api/v1/settings/household': HOUSEHOLD_SETTINGS,
|
||||
'/api/v1/settings/ai': AI_SETTINGS,
|
||||
'/api/v1/settings/ai/models': AI_MODELS,
|
||||
'/api/v1/settings/recipe-search': RECIPE_SEARCH_SETTINGS,
|
||||
'/api/v1/settings/notifications': NOTIFICATION_SETTINGS,
|
||||
'/api/v1/settings/chat-webhook': CHAT_WEBHOOK_NONE,
|
||||
'/api/v1/integrations/google': GOOGLE_NOT_CONNECTED,
|
||||
'/api/v1/plans/2026-10-05': PLAN,
|
||||
'/api/v1/shopping/2026-10-05': SUPPLY_RUN,
|
||||
'/api/v1/shopping/archive?by=week': { by: 'week', periods: [] },
|
||||
'/api/v1/suggestions/deck': { suggestions: [], active_batch: null, failed_batch: null },
|
||||
'/api/v1/dinners?status=regular': { dinners: [dinnerBody('r1', 'Beef tacos')] },
|
||||
'/api/v1/dinners?status=quest_log': { dinners: [] },
|
||||
'/api/v1/dinners/d1': DETAIL,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
||||
const path = (input: Parameters<typeof fetch>[0]) => new URL(String(input), 'https://mealquest.test')
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>((input) => {
|
||||
const url = path(input)
|
||||
const key = `${url.pathname}${url.search}`
|
||||
if (!Object.hasOwn(API, key)) return new Promise<Response>(() => {})
|
||||
return Promise.resolve(jsonResponse(200, API[key]))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(window, 'matchMedia')
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function renderAt(path: string, { width, height }: { width: number; height: number }) {
|
||||
mockViewport(width, { height })
|
||||
render(
|
||||
<InDataRouter path={path}>
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
// The routes wait for the household settings (OnboardingGate).
|
||||
await screen.findByRole('navigation', { name: 'Primary' })
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return screen.getByRole('main').parentElement!
|
||||
}
|
||||
|
||||
const ROUTES: [string, string][] = [
|
||||
['Home', '/'],
|
||||
['Plan', '/plan?week=2026-10-05'],
|
||||
['Supply run', '/plan/shopping'],
|
||||
['Supply archive', '/plan/shopping/archive'],
|
||||
['Discover', '/discover'],
|
||||
['Quick dinners', '/quick'],
|
||||
['Cook from what I have', '/cook-from-pantry'],
|
||||
['Dinner detail (Kitchen reading mode)', '/dinner/d1'],
|
||||
['Settings', '/settings'],
|
||||
['Notifications', '/settings/notifications'],
|
||||
['AI assistant', '/settings/ai'],
|
||||
['Household', '/settings/household'],
|
||||
['Regulars', '/settings/regulars'],
|
||||
['Calendar', '/settings/calendar'],
|
||||
]
|
||||
|
||||
describe.each([
|
||||
['portrait', PORTRAIT],
|
||||
['landscape', LANDSCAPE],
|
||||
])('tablet %s', (orientation, viewport) => {
|
||||
const portrait = orientation === 'portrait'
|
||||
|
||||
it.each(ROUTES)(`renders %s with the 96 px rail in the tablet ${orientation} layout`, async (_name, path) => {
|
||||
await renderAt(path, viewport)
|
||||
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
||||
expect(screen.getByRole('navigation', { name: 'Primary' })).toHaveClass(railStyles.rail!)
|
||||
expect(declaredStyle(screen.getByRole('navigation', { name: 'Primary' })).width).toBe('96px')
|
||||
expect(shell()).toHaveClass(shellStyles.tablet!)
|
||||
if (portrait) expect(shell()).toHaveClass(shellStyles.portrait!)
|
||||
else expect(shell()).not.toHaveClass(shellStyles.portrait!)
|
||||
expect(await screen.findByRole('heading', { level: 1 })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it(`lays the Plan week out ${portrait ? 'four days over three' : 'in seven columns'}`, async () => {
|
||||
await renderAt('/plan?week=2026-10-05', viewport)
|
||||
const week = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
const page = week.closest(`.${planStyles.tabletPage}`)
|
||||
expect(page).not.toBeNull()
|
||||
if (portrait) expect(page).toHaveClass(planStyles.portraitPage!)
|
||||
else expect(page).not.toHaveClass(planStyles.portraitPage!)
|
||||
expect(declaredStyle(week).gridTemplateColumns).toBe(
|
||||
portrait ? 'repeat(4, minmax(0, 1fr))' : 'repeat(7, minmax(112px, 1fr))',
|
||||
)
|
||||
expect(declaredStyle(week).overflowX).toBe('auto')
|
||||
})
|
||||
|
||||
it(`shows Settings (mock 2e) in ${portrait ? 'one column' : 'two columns'}`, async () => {
|
||||
await renderAt('/settings', viewport)
|
||||
const panel = screen.getByRole('tabpanel', { name: 'Notifications & AI' })
|
||||
const page = panel.closest(`.${settingsTabsStyles.page}`)
|
||||
if (portrait) expect(page).toHaveClass(settingsTabsStyles.portrait!)
|
||||
else expect(page).not.toHaveClass(settingsTabsStyles.portrait!)
|
||||
expect(declaredStyle(panel).gridTemplateColumns).toBe(
|
||||
portrait ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) minmax(0, 1.15fr)',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('reflows when the tablet turns', async () => {
|
||||
const resize = mockViewport(PORTRAIT.width, { height: PORTRAIT.height })
|
||||
render(
|
||||
<InDataRouter path="/plan?week=2026-10-05">
|
||||
<ApiProviders>
|
||||
<AppRoutes />
|
||||
</ApiProviders>
|
||||
</InDataRouter>,
|
||||
)
|
||||
const week = await screen.findByRole('list', { name: 'Dinners this week' })
|
||||
expect(shell()).toHaveClass(shellStyles.portrait!)
|
||||
expect(week.closest(`.${planStyles.tabletPage}`)).toHaveClass(planStyles.portraitPage!)
|
||||
resize(LANDSCAPE.width, LANDSCAPE.height)
|
||||
expect(shell()).toHaveClass(shellStyles.tablet!)
|
||||
expect(shell()).not.toHaveClass(shellStyles.portrait!)
|
||||
expect(week.closest(`.${planStyles.tabletPage}`)).not.toHaveClass(planStyles.portraitPage!)
|
||||
})
|
||||
@@ -7,6 +7,12 @@ export type Breakpoint = 'mobile' | 'tablet' | 'desktop'
|
||||
export const TABLET_QUERY = '(min-width: 768px)'
|
||||
export const DESKTOP_QUERY = '(min-width: 1200px)'
|
||||
|
||||
export type Orientation = 'portrait' | 'landscape'
|
||||
|
||||
// A tablet held upright (820 × 1180) has the rail and the tablet layout but
|
||||
// far less width than one on its side, so pages reflow for it.
|
||||
export const PORTRAIT_QUERY = '(orientation: portrait)'
|
||||
|
||||
// The layout the current viewport calls for, updated as the viewport crosses
|
||||
// a breakpoint.
|
||||
export function useBreakpoint(): Breakpoint {
|
||||
@@ -29,3 +35,16 @@ export function useBreakpoint(): Breakpoint {
|
||||
lists.desktop.matches ? 'desktop' : lists.tablet.matches ? 'tablet' : 'mobile',
|
||||
)
|
||||
}
|
||||
|
||||
// The viewport's orientation, updated as the device turns.
|
||||
export function useOrientation(): Orientation {
|
||||
const [list] = useState(() => window.matchMedia(PORTRAIT_QUERY))
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
list.addEventListener('change', onChange)
|
||||
return () => list.removeEventListener('change', onChange)
|
||||
},
|
||||
[list],
|
||||
)
|
||||
return useSyncExternalStore(subscribe, () => (list.matches ? 'portrait' : 'landscape'))
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, 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,8 +213,46 @@ function reload(page: { unmount: () => void }) {
|
||||
vi.setSystemTime(now)
|
||||
}
|
||||
|
||||
async function advance(ms: number) {
|
||||
await act(() => vi.advanceTimersByTimeAsync(ms))
|
||||
// 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()
|
||||
}
|
||||
|
||||
// The page's setTimeout, watched: retrySecondTry waits for a timer to be set.
|
||||
@@ -224,8 +262,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 waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
|
||||
await advance(2 * RETRY_FIRST)
|
||||
await until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
|
||||
advance(2 * RETRY_FIRST)
|
||||
}
|
||||
|
||||
function checkbox(name: string) {
|
||||
@@ -240,16 +278,21 @@ beforeEach(() => {
|
||||
onLine = true
|
||||
reachable = true
|
||||
mockViewport(390)
|
||||
// setTimeout moves with real time, and a test can jump past a deadline or
|
||||
// a wait (advance).
|
||||
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true })
|
||||
// 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'] })
|
||||
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')
|
||||
})
|
||||
@@ -258,7 +301,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
@@ -276,7 +319,7 @@ describe('offline toggle queue', () => {
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
@@ -292,14 +335,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 screen.findByRole('checkbox', { name: 'Taco seasoning' }))
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' })))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(screen.getByText('3 / 3')).toBeInTheDocument()
|
||||
|
||||
// The browser still says online, but the server cannot be reached.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(await until(() => within(row('Corn tortillas')).getByText('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()
|
||||
@@ -307,7 +350,7 @@ describe('offline toggle queue', () => {
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => 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()
|
||||
@@ -316,7 +359,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
@@ -325,7 +368,7 @@ describe('offline toggle queue', () => {
|
||||
// Reload while offline: the settings, /me and the list cannot be read.
|
||||
reload(first)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
|
||||
@@ -333,9 +376,9 @@ describe('offline toggle queue', () => {
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(checkbox('Taco seasoning')).toBeChecked()
|
||||
expect(checkbox('Ground beef')).not.toBeChecked()
|
||||
@@ -344,7 +387,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
@@ -352,25 +395,25 @@ describe('offline toggle queue', () => {
|
||||
|
||||
server.archive()
|
||||
goOnline()
|
||||
const alert = await screen.findByRole('alert')
|
||||
const alert = await until(() => screen.getByRole('alert'))
|
||||
expect(alert).toHaveTextContent("Couldn't mark Taco seasoning to buy. archived message")
|
||||
expect(await screen.findByText('Archived')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('Archived'))).toBeInTheDocument()
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
|
||||
expect(screen.queryByText('will sync')).not.toBeInTheDocument()
|
||||
expect(queue()).toEqual([])
|
||||
// Sent once, never again.
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
|
||||
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`]))
|
||||
})
|
||||
|
||||
it('does not queue a toggle the server refuses: it is put back and says why', async () => {
|
||||
const server = stubServer(() => errorResponse(400, 'invalid'))
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't mark Ground beef “have it”. invalid message")
|
||||
expect(await until(() => screen.getByRole('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()
|
||||
@@ -378,7 +421,7 @@ describe('offline toggle queue', () => {
|
||||
|
||||
goOffline()
|
||||
goOnline()
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
||||
await until(() => expect(patches(server.fetchMock)).toEqual([`${ITEM('i1')} {"needed":false}`]))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -395,16 +438,16 @@ describe('offline toggle queue, replay', () => {
|
||||
localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' }))
|
||||
await until(() => expect(answers.waiting()).toBe(1))
|
||||
fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' })))
|
||||
for (let sent = 1; sent <= 4; sent++) {
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(sent))
|
||||
await until(() => expect(patches(server.fetchMock)).toHaveLength(sent))
|
||||
// The next one is not sent before this one is answered.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
await settle()
|
||||
expect(patches(server.fetchMock)).toHaveLength(sent)
|
||||
act(() => answers.release())
|
||||
}
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('n1')} {"needed":false}`,
|
||||
@@ -412,27 +455,27 @@ describe('offline toggle queue, replay', () => {
|
||||
`${ITEM('i2')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
|
||||
await until(() => expect(screen.getByText('1 / 3')).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('replays at least once: a toggle the server applied before a reload is sent again and leaves the item and count the same', async () => {
|
||||
const answers = held()
|
||||
const server = stubServer(undefined, answers.hold)
|
||||
const first = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
// The server applied it; the page reloads before its answer arrives.
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
await until(() => expect(answers.waiting()).toBe(1))
|
||||
expect(queue()).toEqual(['i3 true'])
|
||||
reload(first)
|
||||
act(() => answers.release())
|
||||
|
||||
await openPage()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
await until(() => expect(answers.waiting()).toBe(1))
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`])
|
||||
await waitFor(() => expect(screen.getByText('3 / 3')).toBeInTheDocument())
|
||||
await until(() => 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()
|
||||
@@ -442,18 +485,18 @@ describe('offline toggle queue, replay', () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
await until(() => expect(answers.waiting()).toBe(1))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
@@ -463,29 +506,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(patches(server.fetchMock)).toHaveLength(1))
|
||||
await until(() => expect(patches(server.fetchMock)).toHaveLength(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung PATCH is aborted; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
advance(TOGGLE_DEADLINE)
|
||||
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -496,26 +539,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(2))
|
||||
await until(() => expect(tries).toBe(2))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// Past the deadline the hung /me is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
advance(TOGGLE_DEADLINE)
|
||||
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(patches(server.fetchMock)).toEqual([])
|
||||
|
||||
// The next try sends a fresh /me, which the server answers.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(mes(server.fetchMock)).toBe(3)
|
||||
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
|
||||
expect(server.mostInFlight()).toBe(1)
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -536,29 +579,29 @@ describe('offline toggle queue, replay', () => {
|
||||
() => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
|
||||
)
|
||||
await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(checkbox('Taco seasoning'))
|
||||
await waitFor(() => expect(tries).toBe(1))
|
||||
await until(() => expect(tries).toBe(1))
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(patches(server.fetchMock)).toHaveLength(1)
|
||||
|
||||
// Past the deadline the hung refresh is abandoned; both still wait, in order.
|
||||
await advance(TOGGLE_DEADLINE)
|
||||
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument()
|
||||
advance(TOGGLE_DEADLINE)
|
||||
expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i3 true', 'i1 false'])
|
||||
|
||||
// The next try is refused again and sends a fresh refresh, which answers.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
expect(patches(server.fetchMock)).toEqual([
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i3')} {"needed":true}`,
|
||||
`${ITEM('i1')} {"needed":false}`,
|
||||
])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('2 / 3')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -566,21 +609,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 screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
|
||||
// The browser says online throughout; the server cannot be reached, then can.
|
||||
reachable = false
|
||||
fireEvent.click(checkbox('Corn tortillas'))
|
||||
expect(await within(row('Corn tortillas')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(await until(() => within(row('Corn tortillas')).getByText('will sync'))).toBeInTheDocument()
|
||||
reachable = true
|
||||
expect(patches(fetchMock)).toEqual([])
|
||||
|
||||
// Nothing says the server is back: only the wait's timer tries it again.
|
||||
await retrySecondTry()
|
||||
await waitFor(() => expect(queue()).toEqual([]))
|
||||
await until(() => expect(queue()).toEqual([]))
|
||||
// The first try failed at GET /api/v1/me, before its PATCH.
|
||||
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`])
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -588,20 +631,20 @@ describe('offline toggle queue, replay', () => {
|
||||
const answers = held()
|
||||
stubServer(undefined, answers.hold)
|
||||
const page = await openPage()
|
||||
expect(await screen.findByText('2 / 3')).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
|
||||
goOffline()
|
||||
fireEvent.click(checkbox('Ground beef'))
|
||||
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
|
||||
|
||||
page.leave()
|
||||
goOnline()
|
||||
await waitFor(() => expect(answers.waiting()).toBe(1))
|
||||
await until(() => expect(answers.waiting()).toBe(1))
|
||||
page.reopen()
|
||||
expect(await within(row('Ground beef')).findByText('will sync')).toBeInTheDocument()
|
||||
expect(await until(() => within(row('Ground beef')).getByText('will sync'))).toBeInTheDocument()
|
||||
expect(queue()).toEqual(['i1 false'])
|
||||
|
||||
act(() => answers.release())
|
||||
await waitFor(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
await until(() => expect(screen.queryByText('will sync')).not.toBeInTheDocument())
|
||||
expect(queue()).toEqual([])
|
||||
expect(screen.getByText('1 / 3')).toBeInTheDocument()
|
||||
})
|
||||
@@ -613,10 +656,10 @@ describe('offline toggle queue, replay', () => {
|
||||
const getItem = vi.spyOn(Storage.prototype, 'getItem')
|
||||
await openPage()
|
||||
|
||||
expect(await screen.findByText(/Couldn't load the household settings/)).toBeInTheDocument()
|
||||
expect(await until(() => screen.getByText(/Couldn't load the household settings/))).toBeInTheDocument()
|
||||
expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
|
||||
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
await settle()
|
||||
expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
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,7 +2,8 @@ import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
import { errorResponse, fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
import { dinnerBody } from './dinners.ts'
|
||||
import { fakeLocation, jsonResponse } from './fetchStub.ts'
|
||||
|
||||
// AI_SETTINGS is a GET /api/v1/settings/ai body: Anthropic with a verified key.
|
||||
export const AI_SETTINGS = {
|
||||
@@ -99,12 +100,16 @@ export const CHAT_WEBHOOK = {
|
||||
last_test_status: null,
|
||||
}
|
||||
|
||||
// SETTINGS_REGULARS is a GET /api/v1/dinners?status=regular body: one
|
||||
// Regular, its recipe found.
|
||||
export const SETTINGS_REGULARS = { dinners: [dinnerBody('r1', 'Beef tacos')] }
|
||||
|
||||
// SETTINGS_API answers the settings home's requests, for stubFetchByPath, as
|
||||
// today's server does: the dinners endpoint does not exist yet, and neither
|
||||
// Google Calendar nor Google Chat is connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the AI models and recipe search settings.
|
||||
// today's server does: one Regular on rotation, and neither Google Calendar
|
||||
// nor Google Chat connected. The home's sub-tabs stay mounted on mobile, so
|
||||
// it also reads the Regulars list, the AI models and recipe search settings.
|
||||
export const SETTINGS_API = {
|
||||
'/api/v1/dinners?status=regular': () => errorResponse(404, 'not_found'),
|
||||
'/api/v1/dinners?status=regular': () => jsonResponse(200, SETTINGS_REGULARS),
|
||||
'/api/v1/integrations/google': () => jsonResponse(200, GOOGLE_NOT_CONNECTED),
|
||||
'/api/v1/settings/notifications': () => jsonResponse(200, NOTIFICATION_SETTINGS),
|
||||
'/api/v1/settings/chat-webhook': () => jsonResponse(200, CHAT_WEBHOOK_NONE),
|
||||
|
||||
@@ -3,16 +3,22 @@ import { act } from '@testing-library/react'
|
||||
type Listener = (event: MediaQueryListEvent) => void
|
||||
|
||||
// Replaces window.matchMedia with one that answers min-width / max-width
|
||||
// queries for a pretend viewport width, and prefers-reduced-motion: reduce
|
||||
// with reducedMotion (jsdom has no matchMedia and no layout). Returns a
|
||||
// function that resizes the pretend viewport and notifies every query whose
|
||||
// answer changed.
|
||||
export function mockViewport(initialWidth: number, { reducedMotion = false } = {}): (width: number) => void {
|
||||
// queries for a pretend viewport width, orientation: portrait for its height
|
||||
// (a viewport given no height is landscape), and prefers-reduced-motion:
|
||||
// reduce with reducedMotion (jsdom has no matchMedia and no layout). Returns
|
||||
// a function that resizes the pretend viewport and notifies every query
|
||||
// whose answer changed.
|
||||
export function mockViewport(
|
||||
initialWidth: number,
|
||||
{ reducedMotion = false, height: initialHeight = 0 } = {},
|
||||
): (width: number, height?: number) => void {
|
||||
let width = initialWidth
|
||||
let height = initialHeight
|
||||
const lists: { list: MediaQueryList; listeners: Set<Listener> }[] = []
|
||||
|
||||
function evaluate(query: string): boolean {
|
||||
if (query === '(prefers-reduced-motion: reduce)') return reducedMotion
|
||||
if (query === '(orientation: portrait)') return height >= width
|
||||
const min = /\(min-width:\s*(\d+)px\)/.exec(query)
|
||||
const max = /\(max-width:\s*(\d+)px\)/.exec(query)
|
||||
if (!min && !max) throw new Error(`mockViewport cannot evaluate ${JSON.stringify(query)}`)
|
||||
@@ -37,9 +43,10 @@ export function mockViewport(initialWidth: number, { reducedMotion = false } = {
|
||||
return list
|
||||
}
|
||||
|
||||
return (next: number) => {
|
||||
return (nextWidth: number, nextHeight = 0) => {
|
||||
const before = lists.map(({ list }) => list.matches)
|
||||
width = next
|
||||
width = nextWidth
|
||||
height = nextHeight
|
||||
act(() => {
|
||||
lists.forEach(({ list, listeners }, i) => {
|
||||
if (list.matches === before[i]) return
|
||||
|
||||
Reference in New Issue
Block a user