diff --git a/docs/STATES.md b/docs/STATES.md index 75a9e7f..fa1a1f8 100644 --- a/docs/STATES.md +++ b/docs/STATES.md @@ -27,7 +27,9 @@ screen (a Quest log count, a calendar list, a webhook card) use the same | 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 — 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` | 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. | diff --git a/web/src/states.test.tsx b/web/src/states.test.tsx index 70158f6..7ccecdb 100644 --- a/web/src/states.test.tsx +++ b/web/src/states.test.tsx @@ -113,6 +113,18 @@ async function expectLoading() { 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) { @@ -160,7 +172,18 @@ describe('/ Home', () => { }) }) -describe('/onboarding Regulars step', () => { +describe('/onboarding Welcome step', () => { + it('reads nothing, so it has no empty, loading or error state', async () => { + const fetchMock = serve({}) + await renderRoute('/onboarding', '/onboarding/*', ) + 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/*', ) @@ -186,6 +209,27 @@ describe('/onboarding Regulars step', () => { }) }) +describe('/onboarding/notifications Notifications step', () => { + const page = async (finish: Responder) => { + serve({ 'PATCH /api/v1/settings/household': finish }) + await renderRoute('/onboarding/notifications', '/onboarding/*', ) + 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') }) @@ -427,9 +471,15 @@ describe('/settings/notifications Notifications', () => { await renderRoute('/settings/notifications', '/settings/notifications', ) } - it('loading: skeleton', async () => { + it('loading: skeleton for the channel matrix', async () => { await page(pending) - await expectLoading() + // 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 () => { @@ -456,9 +506,13 @@ describe('/settings/ai AI assistant', () => { await renderRoute('/settings/ai', '/settings/ai', ) } - it('loading: skeleton', async () => { + it('loading: skeleton for the AI assistant form', async () => { await page(pending) - await expectLoading() + // 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 () => {