Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a183c1984 | ||
|
|
832e6b9f7d | ||
|
|
17fa82ef2b |
@@ -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). |
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user