diff --git a/docs/STATES.md b/docs/STATES.md new file mode 100644 index 0000000..75a9e7f --- /dev/null +++ b/docs/STATES.md @@ -0,0 +1,48 @@ +# 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 — 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. | +| `/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). | diff --git a/web/src/states.test.tsx b/web/src/states.test.tsx new file mode 100644 index 0000000..70158f6 --- /dev/null +++ b/web/src/states.test.tsx @@ -0,0 +1,547 @@ +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 + +// pending is a response that never comes, so the page stays loading. +const pending: Responder = () => new Promise(() => {}) + +// 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) { + const all: Record = { + 'GET /api/v1/me': json(ME), + 'GET /api/v1/settings/household': json(HOUSEHOLD_SETTINGS), + ...responders, + } + const fetchMock = vi.fn((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( + + + + + + + + + , + ) +} + +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() +} + +// expectRetry finds the error banner saying said, presses its Retry and +// waits for recovered. +async function expectRetry(said: string, recovered: () => Promise) { + 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('/', '/', ) + } + + 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 Regulars step', () => { + const page = async (regulars: Responder) => { + serve({ 'GET /api/v1/dinners?status=regular': regulars }) + await renderRoute('/onboarding/regulars', '/onboarding/*', ) + } + + 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('/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', ) + } + 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', ) + } + 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', ) + } + 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', ) + } + 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', ) + } + + 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', ) + } + 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', ) + } + 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', ) + } + 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', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + 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', ) + } + + it('loading: skeleton', async () => { + await page(pending) + await expectLoading() + }) + + 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', ) + } + + 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', ) + } + 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', ) + } + 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' }), + ) + }) +})