Compare commits

...
Author SHA1 Message Date
dev_agentandClaude Opus 5.5 9a183c1984 Scope settings loading tests to their section and document onboarding paths
CI / ci (push) Successful in 8m8s
CI / ci (pull_request) Successful in 8m11s
The /settings/notifications and /settings/ai loading tests passed on any
skeleton, including the webhook and Recipe search cards' own. They now
wait for those sections to settle and check the one skeleton left sits
before them, so removing the held section's skeleton fails the test.

docs/STATES.md listed /onboarding as the Regulars step. It now lists the
Welcome (/onboarding), Regulars (/onboarding/regulars) and Notifications
(/onboarding/notifications) steps, each tested at its own path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:35:40 -04:00
dev_agentandClaude Opus 5.5 832e6b9f7d Document and test every route's empty, loading and error states
CI / ci (push) Successful in 8m10s
CI / ci (pull_request) Successful in 8m15s
docs/STATES.md lists each handoff route and the added Settings screens
with its empty, loading and error state, all built on Skeleton,
ErrorBanner and EmptyState. web/src/states.test.tsx renders the three
variants of every route against a stubbed API, asserting the copy and
pressing Retry.

Task: 322a15db-b2ad-46f4-a2ba-cb3745dd0aff

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:10:15 -04:00
orchestrator-agent 17fa82ef2b Deflake the Supply run offline-queue tests under host load (#186)
CI / ci (push) Successful in 8m26s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
2026-10-10 22:05:38 -04:00
3 changed files with 762 additions and 68 deletions
+50
View File
@@ -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). |
+111 -68
View File
@@ -1,5 +1,5 @@
import { QueryClientProvider } from '@tanstack/react-query' import { defaultScheduler, notifyManager, QueryClientProvider } from '@tanstack/react-query'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { MemoryRouter } from 'react-router' import { MemoryRouter } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockInstance } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts' import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
@@ -213,8 +213,46 @@ function reload(page: { unmount: () => void }) {
vi.setSystemTime(now) vi.setSystemTime(now)
} }
async function advance(ms: number) { // advance jumps the clock past a deadline or a wait, firing the timers due
await act(() => vi.advanceTimersByTimeAsync(ms)) // 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. // 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 // 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. // never lands before the timer it is meant to fire.
async function retrySecondTry() { async function retrySecondTry() {
await waitFor(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST)) await until(() => expect(timers).toHaveBeenCalledWith(expect.any(Function), 2 * RETRY_FIRST))
await advance(2 * RETRY_FIRST) advance(2 * RETRY_FIRST)
} }
function checkbox(name: string) { function checkbox(name: string) {
@@ -240,16 +278,21 @@ beforeEach(() => {
onLine = true onLine = true
reachable = true reachable = true
mockViewport(390) mockViewport(390)
// setTimeout moves with real time, and a test can jump past a deadline or // The clock and setTimeout move only when a test jumps past a deadline or
// a wait (advance). // a wait (advance), never with real time: under load, real time would fire
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'], shouldAdvanceTime: true }) // 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')) vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
notifyManager.setScheduler(nextTurn)
timers = vi.spyOn(globalThis, 'setTimeout') timers = vi.spyOn(globalThis, 'setTimeout')
}) })
afterEach(() => { afterEach(() => {
vi.restoreAllMocks() vi.restoreAllMocks()
vi.useRealTimers() vi.useRealTimers()
notifyManager.setScheduler(defaultScheduler)
vi.unstubAllGlobals() vi.unstubAllGlobals()
Reflect.deleteProperty(window, 'matchMedia') 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 () => { it('applies toggles made offline at once, saves them in order and sends them in that order once online', async () => {
const { fetchMock } = stubServer() const { fetchMock } = stubServer()
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline() goOffline()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
@@ -276,7 +319,7 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([]) expect(patches(fetchMock)).toEqual([])
goOnline() goOnline()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([ expect(patches(fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`, `${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 () => { it('queues a toggle whose PATCH fails with a network error and sends it again once back online', async () => {
const { fetchMock } = stubServer() const { fetchMock } = stubServer()
await openPage() await openPage()
fireEvent.click(await screen.findByRole('checkbox', { name: 'Taco seasoning' })) fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Taco seasoning' })))
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(screen.getByText('3 / 3')).toBeInTheDocument() expect(screen.getByText('3 / 3')).toBeInTheDocument()
// The browser still says online, but the server cannot be reached. // The browser still says online, but the server cannot be reached.
reachable = false reachable = false
fireEvent.click(checkbox('Corn tortillas')) 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(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`])
expect(checkbox('Corn tortillas')).not.toBeChecked() expect(checkbox('Corn tortillas')).not.toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.getByText('2 / 3')).toBeInTheDocument()
@@ -307,7 +350,7 @@ describe('offline toggle queue', () => {
expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument()
goOnline() 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(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`])
expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.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 () => { it('opens the saved list after a reload offline and replays the saved toggles in order once online', async () => {
const { fetchMock } = stubServer() const { fetchMock } = stubServer()
const first = await openPage() const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline() goOffline()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
fireEvent.click(checkbox('Ground beef')) 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 while offline: the settings, /me and the list cannot be read.
reload(first) reload(first)
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked() expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.toBeChecked() expect(checkbox('Ground beef')).not.toBeChecked()
expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument() expect(within(row('Taco seasoning')).getByText('will sync')).toBeInTheDocument()
@@ -333,9 +376,9 @@ describe('offline toggle queue', () => {
expect(patches(fetchMock)).toEqual([]) expect(patches(fetchMock)).toEqual([])
goOnline() goOnline()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(patches(fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`]) 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(screen.getByText('2 / 3')).toBeInTheDocument()
expect(checkbox('Taco seasoning')).toBeChecked() expect(checkbox('Taco seasoning')).toBeChecked()
expect(checkbox('Ground beef')).not.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 () => { 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')) const server = stubServer(() => errorResponse(409, 'archived'))
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline() goOffline()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
@@ -352,25 +395,25 @@ describe('offline toggle queue', () => {
server.archive() server.archive()
goOnline() 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(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(screen.getByText('2 / 3')).toBeInTheDocument()
expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument() expect(within(row('Taco seasoning')).getByText('have it')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument() expect(screen.queryByText('will sync')).not.toBeInTheDocument()
expect(queue()).toEqual([]) expect(queue()).toEqual([])
// Sent once, never again. // Sent once, never again.
goOnline() 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 () => { it('does not queue a toggle the server refuses: it is put back and says why', async () => {
const server = stubServer(() => errorResponse(400, 'invalid')) const server = stubServer(() => errorResponse(400, 'invalid'))
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Ground beef')) 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(checkbox('Ground beef')).toBeChecked()
expect(screen.getByText('2 / 3')).toBeInTheDocument() expect(screen.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByText('will sync')).not.toBeInTheDocument() expect(screen.queryByText('will sync')).not.toBeInTheDocument()
@@ -378,7 +421,7 @@ describe('offline toggle queue', () => {
goOffline() goOffline()
goOnline() 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 })) localStorage.setItem(SAVED_KEY, JSON.stringify({ schedule: null, runs, queue: queued }))
await openPage() await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1)) await until(() => expect(answers.waiting()).toBe(1))
fireEvent.click(await screen.findByRole('checkbox', { name: 'Corn tortillas' })) fireEvent.click(await until(() => screen.getByRole('checkbox', { name: 'Corn tortillas' })))
for (let sent = 1; sent <= 4; sent++) { 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. // 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) expect(patches(server.fetchMock)).toHaveLength(sent)
act(() => answers.release()) act(() => answers.release())
} }
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([ expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('n1')} {"needed":false}`, `${ITEM('n1')} {"needed":false}`,
@@ -412,27 +455,27 @@ describe('offline toggle queue, replay', () => {
`${ITEM('i2')} {"needed":false}`, `${ITEM('i2')} {"needed":false}`,
]) ])
expect(server.mostInFlight()).toBe(1) 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 () => { 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 answers = held()
const server = stubServer(undefined, answers.hold) const server = stubServer(undefined, answers.hold)
const first = await openPage() const first = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
// The server applied it; the page reloads before its answer arrives. // 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']) expect(queue()).toEqual(['i3 true'])
reload(first) reload(first)
act(() => answers.release()) act(() => answers.release())
await openPage() await openPage()
await waitFor(() => expect(answers.waiting()).toBe(1)) await until(() => expect(answers.waiting()).toBe(1))
act(() => answers.release()) 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}`]) 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(checkbox('Taco seasoning')).toBeChecked()
expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 }) expect(server.run('2026-10-05')).toMatchObject({ needed_count: 3, total_count: 3 })
expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument()
@@ -442,18 +485,18 @@ describe('offline toggle queue, replay', () => {
const answers = held() const answers = held()
stubServer(undefined, answers.hold) stubServer(undefined, answers.hold)
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline() goOffline()
fireEvent.click(checkbox('Ground beef')) fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
goOnline() 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(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i1 false']) expect(queue()).toEqual(['i1 false'])
act(() => answers.release()) 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(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument() 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. // The first PATCH never answers; every later one answers at once.
const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve())) const server = stubServer(undefined, () => (++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning')) 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')) fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false']) expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung PATCH is aborted; both still wait, in order. // Past the deadline the hung PATCH is aborted; both still wait, in order.
await advance(TOGGLE_DEADLINE) advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toHaveLength(1) expect(patches(server.fetchMock)).toHaveLength(1)
expect(queue()).toEqual(['i3 true', 'i1 false']) expect(queue()).toEqual(['i3 true', 'i1 false'])
await retrySecondTry() await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([ expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`, `${ITEM('i1')} {"needed":false}`,
]) ])
expect(server.mostInFlight()).toBe(1) 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.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument()
}) })
@@ -496,26 +539,26 @@ describe('offline toggle queue, replay', () => {
// answers. Every later /me answers at once. // answers. Every later /me answers at once.
const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve())) const server = stubServer(undefined, undefined, () => (++tries === 2 ? new Promise<void>(() => {}) : Promise.resolve()))
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(2)) await until(() => expect(tries).toBe(2))
fireEvent.click(checkbox('Ground beef')) fireEvent.click(checkbox('Ground beef'))
expect(queue()).toEqual(['i3 true', 'i1 false']) expect(queue()).toEqual(['i3 true', 'i1 false'])
// Past the deadline the hung /me is abandoned; both still wait, in order. // Past the deadline the hung /me is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE) advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(patches(server.fetchMock)).toEqual([]) expect(patches(server.fetchMock)).toEqual([])
// The next try sends a fresh /me, which the server answers. // The next try sends a fresh /me, which the server answers.
await retrySecondTry() await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(mes(server.fetchMock)).toBe(3) expect(mes(server.fetchMock)).toBe(3)
expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`]) expect(patches(server.fetchMock)).toEqual([`${ITEM('i3')} {"needed":true}`, `${ITEM('i1')} {"needed":false}`])
expect(server.mostInFlight()).toBe(1) 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.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.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()), () => (refusedAt !== null && ++tries === 1 ? new Promise<void>(() => {}) : Promise.resolve()),
) )
await openPage() await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
fireEvent.click(checkbox('Taco seasoning')) fireEvent.click(checkbox('Taco seasoning'))
await waitFor(() => expect(tries).toBe(1)) await until(() => expect(tries).toBe(1))
fireEvent.click(checkbox('Ground beef')) fireEvent.click(checkbox('Ground beef'))
expect(patches(server.fetchMock)).toHaveLength(1) expect(patches(server.fetchMock)).toHaveLength(1)
// Past the deadline the hung refresh is abandoned; both still wait, in order. // Past the deadline the hung refresh is abandoned; both still wait, in order.
await advance(TOGGLE_DEADLINE) advance(TOGGLE_DEADLINE)
expect(await within(row('Taco seasoning')).findByText('will sync')).toBeInTheDocument() expect(await until(() => within(row('Taco seasoning')).getByText('will sync'))).toBeInTheDocument()
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
expect(queue()).toEqual(['i3 true', 'i1 false']) expect(queue()).toEqual(['i3 true', 'i1 false'])
// The next try is refused again and sends a fresh refresh, which answers. // The next try is refused again and sends a fresh refresh, which answers.
await retrySecondTry() await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
expect(patches(server.fetchMock)).toEqual([ expect(patches(server.fetchMock)).toEqual([
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i3')} {"needed":true}`, `${ITEM('i3')} {"needed":true}`,
`${ITEM('i1')} {"needed":false}`, `${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.getByText('2 / 3')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.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 () => { it('tries a toggle again once the server is back, with no online event from the browser', async () => {
const { fetchMock } = stubServer() const { fetchMock } = stubServer()
await openPage() 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. // The browser says online throughout; the server cannot be reached, then can.
reachable = false reachable = false
fireEvent.click(checkbox('Corn tortillas')) 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 reachable = true
expect(patches(fetchMock)).toEqual([]) expect(patches(fetchMock)).toEqual([])
// Nothing says the server is back: only the wait's timer tries it again. // Nothing says the server is back: only the wait's timer tries it again.
await retrySecondTry() await retrySecondTry()
await waitFor(() => expect(queue()).toEqual([])) await until(() => expect(queue()).toEqual([]))
// The first try failed at GET /api/v1/me, before its PATCH. // The first try failed at GET /api/v1/me, before its PATCH.
expect(patches(fetchMock)).toEqual([`${ITEM('i2')} {"needed":false}`]) 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() expect(screen.getByText('1 / 3')).toBeInTheDocument()
}) })
@@ -588,20 +631,20 @@ describe('offline toggle queue, replay', () => {
const answers = held() const answers = held()
stubServer(undefined, answers.hold) stubServer(undefined, answers.hold)
const page = await openPage() const page = await openPage()
expect(await screen.findByText('2 / 3')).toBeInTheDocument() expect(await until(() => screen.getByText('2 / 3'))).toBeInTheDocument()
goOffline() goOffline()
fireEvent.click(checkbox('Ground beef')) fireEvent.click(checkbox('Ground beef'))
expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument() expect(within(row('Ground beef')).getByText('will sync')).toBeInTheDocument()
page.leave() page.leave()
goOnline() goOnline()
await waitFor(() => expect(answers.waiting()).toBe(1)) await until(() => expect(answers.waiting()).toBe(1))
page.reopen() 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']) expect(queue()).toEqual(['i1 false'])
act(() => answers.release()) 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(queue()).toEqual([])
expect(screen.getByText('1 / 3')).toBeInTheDocument() expect(screen.getByText('1 / 3')).toBeInTheDocument()
}) })
@@ -613,10 +656,10 @@ describe('offline toggle queue, replay', () => {
const getItem = vi.spyOn(Storage.prototype, 'getItem') const getItem = vi.spyOn(Storage.prototype, 'getItem')
await openPage() 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() expect(screen.getByText(/can't be kept on this device while offline/)).toBeInTheDocument()
const reads = getItem.mock.calls.filter(([key]) => key === SAVED_KEY).length 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) expect(getItem.mock.calls.filter(([key]) => key === SAVED_KEY)).toHaveLength(reads)
}) })
}) })
+601
View File
@@ -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' }),
)
})
})