Files
MealQuest/web/src/home/HomePage.test.tsx
T
dev_agentandClaude Opus 5.5 3a0459cce3
CI / ci (push) Successful in 8m7s
CI / ci (pull_request) Successful in 7m47s
Build the Home screen (mock 1g) from GET /api/v1/home
Replace the / placeholder with HomePage: the household-local date over
"Tonight's quest", the Recent updates bell with its grape unread dot, the
Tonight card (washed photo, name, "Regular · 40 min" / "Side quest · 30
min", dark Recipe pill to /dinner/:id), the Quick dinner and Cook from what
I have tiles, and the status rows to /discover and the week's Supply run.
With no plan the card is "No quest planned tonight" with "Plan this week";
rows that do not apply are hidden. Loading shows skeletons; a failure
shows an ErrorBanner with Retry.

The offline bootstrap tests now emit their online reset before cleanup:
TanStack's online manager only listens while a query client is mounted,
so the reset was lost and Home's query stayed paused in later tests.

Maestro task 5170be5d-4787-4ea8-8530-c19e98e591bc (Feature 21).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:02:03 -04:00

219 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { fireEvent, render, screen, within } from '@testing-library/react'
import { createMemoryRouter, RouterProvider, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiProviders, HOME } from '../test/ApiProviders.tsx'
import { declaredStyle } from '../test/cssRules.ts'
import { errorResponse, jsonResponse, stubFetch, stubFetchByPath } from '../test/fetchStub.ts'
import { expectMinHitArea } from '../test/hitArea.ts'
import { HOME_PATH } from './api.ts'
import { HomePage } from './HomePage.tsx'
// NOTHING_YET is the home of a household with no plan, no Side quests, no
// Supply run and nothing unread.
const NOTHING_YET = {
...HOME,
tonight: null,
sideQuestCount: 0,
shoppingList: { weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4', ready: false, sentToChatAt: null },
recentUpdates: { unread: false },
}
afterEach(() => {
vi.unstubAllGlobals()
})
// Where shows the route a link went to.
function Where() {
const location = useLocation()
return <p data-testid="where">{location.pathname + location.search}</p>
}
function renderHome(body: unknown) {
stubFetchByPath({ [HOME_PATH]: () => jsonResponse(200, body) })
return renderRouter()
}
function renderRouter() {
const router = createMemoryRouter(
[
{ path: '/', element: <HomePage /> },
{ path: '*', element: <Where /> },
],
{ initialEntries: ['/'] },
)
render(
<ApiProviders>
<RouterProvider router={router} />
</ApiProviders>,
)
}
describe('HomePage', () => {
it('renders mock 1g from GET /api/v1/home', async () => {
renderHome(HOME)
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
const tonight = screen.getByRole('region', { name: 'Tonight' })
expect(within(tonight).getByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(within(tonight).getByText('Regular · 40 min')).toBeInTheDocument()
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
expect(within(tonight).getByRole('link', { name: 'Recipe' })).toHaveAttribute('href', '/dinner/d1')
expect(screen.getByRole('link', { name: 'Quick dinner' })).toHaveAttribute('href', '/quick')
expect(screen.getByRole('link', { name: 'Cook from what I have' })).toHaveAttribute('href', '/cook-from-pantry')
expect(screen.getByRole('link', { name: '10 new side quests to swipe' })).toHaveAttribute('href', '/discover')
expect(screen.getByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toHaveAttribute(
'href',
'/plan/shopping?week=2026-10-05',
)
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
})
it('shows the grape unread dot on the bell when there are unread updates', async () => {
renderHome(HOME)
const bell = await screen.findByRole('button', { name: 'Recent updates, unread' })
const dot = within(bell).getByTestId('unread-dot')
expect(declaredStyle(dot).background).toBe('var(--color-accent)')
expect(declaredStyle(dot).width).toBe('8px')
expectMinHitArea(bell)
})
it('shows the bell without a dot when every update is seen', async () => {
renderHome(NOTHING_YET)
const bell = await screen.findByRole('button', { name: 'Recent updates' })
expect(within(bell).queryByTestId('unread-dot')).not.toBeInTheDocument()
})
it('labels a Side quest tonight "Side quest"', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'side_quest', minutes_total: 30 } })
expect(await screen.findByText('Side quest · 30 min')).toBeInTheDocument()
})
it('shows only the origin when tonight has no minutes', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, minutes_total: null, photo: null } })
const tonight = await screen.findByRole('region', { name: 'Tonight' })
expect(within(tonight).getByText('Regular')).toBeInTheDocument()
// No photo: the striped placeholder, named for the dinner.
expect(within(tonight).getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
})
it('offers to plan the week when tonight has no plan, and hides the rows that do not apply', async () => {
renderHome(NOTHING_YET)
expect(await screen.findByRole('heading', { name: 'No quest planned tonight' })).toBeInTheDocument()
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
// The tiles stay.
expect(screen.getByRole('link', { name: 'Quick dinner' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Plan this week' }))
expect(await screen.findByTestId('where')).toHaveTextContent('/plan')
})
it('hides the Side quests row at zero and keeps a ready Supply run', async () => {
renderHome({ ...HOME, sideQuestCount: 0 })
expect(await screen.findByRole('link', { name: 'Shopping list ready · Oct 5–11' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /side quests? to swipe/ })).not.toBeInTheDocument()
})
it('hides the Supply run row when no list is ready', async () => {
renderHome({ ...HOME, shoppingList: NOTHING_YET.shoppingList })
expect(await screen.findByRole('link', { name: '10 new side quests to swipe' })).toBeInTheDocument()
expect(screen.queryByRole('link', { name: /Shopping list ready/ })).not.toBeInTheDocument()
})
it('counts a single Side quest in the singular', async () => {
renderHome({ ...HOME, sideQuestCount: 1 })
expect(await screen.findByRole('link', { name: '1 new side quest to swipe' })).toBeInTheDocument()
})
it('shows a week spanning two months in full', async () => {
renderHome({ ...HOME, shoppingList: { ...HOME.shoppingList, weekStart: '2026-09-28', label: 'Week 40 · Sep 28 – Oct 4' } })
expect(await screen.findByRole('link', { name: 'Shopping list ready · Sep 28–Oct 4' })).toBeInTheDocument()
})
it.each([
['Recipe', '/dinner/d1'],
['Quick dinner', '/quick'],
['Cook from what I have', '/cook-from-pantry'],
['10 new side quests to swipe', '/discover'],
['Shopping list ready · Oct 5–11', '/plan/shopping?week=2026-10-05'],
])('%s goes to %s', async (name, to) => {
renderHome(HOME)
fireEvent.click(await screen.findByRole('link', { name }))
expect(await screen.findByTestId('where')).toHaveTextContent(to)
})
it('styles the card, tiles and rows as mock 1g with tap targets of at least 44 px', async () => {
renderHome(HOME)
const tonight = await screen.findByRole('region', { name: 'Tonight' })
expect(declaredStyle(tonight).borderRadius).toBe('32px')
expect(declaredStyle(within(tonight).getByRole('img')).height).toBe('150px')
expect(declaredStyle(within(tonight).getByRole('heading')).fontSize).toBe('22px')
const quick = screen.getByRole('link', { name: 'Quick dinner' })
expect(declaredStyle(quick).height).toBe('104px')
expect(declaredStyle(quick).background).toBe('var(--color-accent)')
expect(declaredStyle(quick).color).toBe('var(--on-accent)')
const pantry = screen.getByRole('link', { name: 'Cook from what I have' })
expect(declaredStyle(pantry).height).toBe('104px')
expect(declaredStyle(pantry).background).toBe('var(--color-accent-2-300)')
expect(declaredStyle(screen.getByText('Friday, Oct 2')).fontSize).toBe('12px')
expectMinHitArea(within(tonight).getByRole('link', { name: 'Recipe' }))
// The tiles are 104 px tall (above); the status rows fill the width at 44 px or more.
for (const row of within(screen.getByRole('list', { name: 'Status' })).getAllByRole('link')) {
expect(declaredStyle(row).minHeight).toBe('44px')
}
})
it('shows skeletons while the home loads', async () => {
let answer!: (response: Response) => void
vi.stubGlobal(
'fetch',
vi.fn<typeof fetch>(() => new Promise((resolve) => (answer = resolve))),
)
renderRouter()
expect(screen.getByTestId('skeleton-tonight')).toBeInTheDocument()
expect(screen.getByTestId('skeleton-date')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-status')).toHaveLength(2)
expect(screen.getByRole('heading', { level: 1, name: "Tonight's quest" })).toBeInTheDocument()
answer(jsonResponse(200, HOME))
expect(await screen.findByText('Friday, Oct 2')).toBeInTheDocument()
expect(screen.queryByTestId('skeleton-tonight')).not.toBeInTheDocument()
expect(screen.queryByTestId('skeleton-status')).not.toBeInTheDocument()
})
it('reports a failure with Retry, and no made-up content', async () => {
stubFetch(errorResponse(500, 'internal'), jsonResponse(200, HOME))
renderRouter()
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't load tonight's quest. internal message")
expect(screen.queryByRole('region', { name: 'Tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'No quest planned tonight' })).not.toBeInTheDocument()
expect(screen.queryByRole('list', { name: 'Status' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
expect(await screen.findByRole('heading', { name: 'Pizza night' })).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('reports a home it cannot read rather than guessing', async () => {
renderHome({ ...HOME, tonight: { ...HOME.tonight, origin: 'leftovers' } })
expect(await screen.findByRole('alert')).toHaveTextContent('origin "leftovers" is not one of regular, side_quest')
})
})