Files
MealQuest/web/src/ideas/CookFromPantryPage.test.tsx
T
dev_agentandClaude Opus 5.5 2b96dea5d8
CI / ci (push) Successful in 5m41s
CI / ci (pull_request) Successful in 5m43s
Show an added idea's true status and let Find ideas search typed text
F-02: adding an idea to the Quest log answers an existing dinner as it is,
so it can come back as a Regular. useAddIdeaToQuestLog now resolves with
the dinner and its status (quest_log or regular, anything else an error),
and Cook from what I have and Quick dinners show "In the Quest log" or
"Already a Regular" from it; Quick dinners keeps the status per idea for
the session.

F-03: Find ideas is enabled by a chip or by nonblank text typed and not
yet added, which the search already lists.

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

562 lines
25 KiB
TypeScript

import { QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
import { createNetworkStatus } from '../pwa/networkStatus.ts'
import { dinnerBody } from '../test/dinners.ts'
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
import { CookFromPantryPage } from './CookFromPantryPage.tsx'
import styles from './CookFromPantryPage.module.css'
const FIND = 'POST /api/v1/ideas/from-ingredients'
const INGREDIENTS = ['Chicken thighs', 'Rice', 'Spinach', 'Coconut milk', 'Garlic']
// idea is a grounded idea as the job's result carries it.
function idea(name: string, summary: string, minutes: number | null = 30) {
return {
name,
uses: INGREDIENTS,
missing: ['1 lime'],
summary,
method: 'stovetop',
minutes_total: minutes,
difficulty: 'easy',
quick: minutes !== null && minutes <= 30,
recipe_id: `r-${name}`,
photo_url: '',
source_url: 'https://recipes.example/x',
site_name: 'Recipes',
}
}
// The ideas of mock 1i.
const CURRY = idea('Coconut chicken curry', 'Uses all 5 · missing: 1 lime')
const BOWLS = idea('Garlic spinach rice bowls', 'Uses 4 of 5 · nothing missing', 25)
const ONE_PAN = idea('One-pan chicken & rice', 'Uses 3 of 5 · missing: onion')
type JobBody = { status: string; result: unknown; error: string | null }
// running is the ideas_from_ingredients job's state at stage with ideas.
function state(status: string, stage: string, ideas: unknown[], quickOnly = false, error: string | null = null): JobBody {
return { status, result: { stage, ingredients: INGREDIENTS, quick_only: quickOnly, ideas, dropped: 0 }, error }
}
const DAYS = ['2026-10-05', '2026-10-06', '2026-10-07', '2026-10-08', '2026-10-09', '2026-10-10', '2026-10-11']
// tonightPlan is the week's plan as cook-tonight answers it, the idea's
// dinner on Thursday.
function tonightPlan() {
return {
id: 'plan-1',
week_start: '2026-10-05',
status: 'draft',
ai_note: null,
calendar_synced_at: null,
days: DAYS.map((date, i) => ({
date,
dinner: { id: `d${i}`, name: i === 3 ? CURRY.name : `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop', origin: 'regular' },
is_new: i === 3,
start_by: null,
})),
updated_at: '2026-10-08T15:00:00Z',
}
}
type Responder = (init: RequestInit | undefined) => Response | Promise<Response>
// stubServer answers "METHOD path" requests from responders, with /me; any
// other request fails the test. jobs holds each job's state, which every
// poll of it answers, so a test moves a job on by changing it.
function stubServer(responders: Record<string, Responder>) {
const jobs: Record<string, JobBody> = {}
const fetchMock = vi.fn<typeof fetch>((input, init) => {
const key = `${init?.method ?? 'GET'} ${String(input)}`
if (key === 'GET /api/v1/me') return Promise.resolve(jsonResponse(200, ME))
const job = /^GET \/api\/v1\/jobs\/([^/]+)$/.exec(key)
if (job !== null && !Object.hasOwn(responders, key)) {
const id = job[1]!
const body = jobs[id]
if (body === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(jsonResponse(200, { id, kind: 'ideas_from_ingredients', ...body }))
}
const respond = Object.hasOwn(responders, key) ? responders[key] : undefined
if (respond === undefined) throw new Error(`unexpected fetch: ${key}`)
return Promise.resolve(respond(init))
})
vi.stubGlobal('fetch', fetchMock)
return { fetchMock, jobs }
}
// calls are the requests sent as "METHOD path", in order.
function calls(fetchMock: ReturnType<typeof stubServer>['fetchMock'], key: string) {
return fetchMock.mock.calls.filter(([input, init]) => `${init?.method ?? 'GET'} ${String(input)}` === key)
}
function bodyOf(call: Parameters<typeof fetch>) {
return JSON.parse(String(call[1]?.body)) as unknown
}
async function renderPage() {
const api = createApiClient(fakeLocation('/cook-from-pantry'), createNetworkStatus(window), createVerifiedSession(localStorage))
await api.loadMe()
return render(
<ApiProvider client={api}>
<QueryClientProvider client={createQueryClient()}>
<MemoryRouter initialEntries={['/cook-from-pantry']}>
<Routes>
<Route path="/cook-from-pantry" element={<CookFromPantryPage />} />
<Route path="/plan" element={<p>Plan screen</p>} />
<Route path="/" element={<p>Home screen</p>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>
</ApiProvider>,
)
}
function input() {
return screen.getByRole('textbox', { name: 'Add ingredient' })
}
function chips() {
return within(screen.getByRole('list', { name: 'Ingredients on hand' }))
.queryAllByRole('listitem')
.map((item) => item.textContent)
}
function findButton() {
return screen.getByRole('button', { name: 'Find ideas' })
}
// enter types each ingredient and presses Enter.
function enter(...ingredients: string[]) {
for (const ingredient of ingredients) {
fireEvent.change(input(), { target: { value: ingredient } })
fireEvent.keyDown(input(), { key: 'Enter' })
}
}
function card(name: string) {
return screen.getByRole('article', { name })
}
const SLOW = { timeout: 3000 }
// searched lists the mock's ingredients, presses Find ideas and waits for
// the job's first poll.
async function searched(fetchMock: ReturnType<typeof stubServer>['fetchMock'], jobId = 'j1') {
enter(...INGREDIENTS)
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, `GET /api/v1/jobs/${jobId}`).length).toBeGreaterThan(0))
}
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('CookFromPantryPage', () => {
it('renders mock 1i: back button, title, chip field, filter and a disabled Find ideas', async () => {
stubServer({})
await renderPage()
expect(screen.getByRole('heading', { level: 1, name: 'Cook from what I have' })).toBeInTheDocument()
expect(input()).toHaveAttribute('placeholder', 'Add ingredient…')
expect(input().closest(`.${styles.field}`)).not.toBeNull()
const filter = screen.getByRole('radiogroup', { name: 'Which dinners' })
expect(within(filter).getByRole('radio', { name: 'Any time' })).toBeChecked()
expect(within(filter).getByRole('radio', { name: 'Quick only' })).not.toBeChecked()
expect(findButton()).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: 'Back to Home' }))
expect(screen.getByText('Home screen')).toBeInTheDocument()
})
describe('chip entry', () => {
it('adds an ingredient on Enter or a comma, and Find ideas needs one', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs')
expect(chips()).toEqual(['Chicken thighs'])
expect(input()).toHaveValue('')
expect(findButton()).toBeEnabled()
fireEvent.change(input(), { target: { value: 'Rice,' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice'])
expect(input()).toHaveValue('')
// Pasted text is split at its commas; what follows the last is still
// being typed.
fireEvent.change(input(), { target: { value: ' Spinach , coconut milk, Gar' } })
expect(chips()).toEqual(['Chicken thighs', 'Rice', 'Spinach', 'coconut milk'])
expect(input()).toHaveValue(' Gar')
// Enter on nothing adds nothing.
fireEvent.change(input(), { target: { value: ' ' } })
fireEvent.keyDown(input(), { key: 'Enter' })
expect(chips()).toHaveLength(4)
})
it('enables Find ideas for text typed and not yet added, and searches it', async () => {
const { fetchMock } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
await renderPage()
fireEvent.change(input(), { target: { value: ' ' } })
expect(findButton()).toBeDisabled()
fireEvent.change(input(), { target: { value: 'Chicken thighs' } })
expect(chips()).toEqual([])
expect(findButton()).toBeEnabled()
fireEvent.click(findButton())
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: ['Chicken thighs'], quick_only: false })
expect(chips()).toEqual(['Chicken thighs'])
})
it('removes a chip with its cross, or the last one with Backspace in the empty input', async () => {
stubServer({})
await renderPage()
enter('Chicken thighs', 'Rice', 'Spinach')
fireEvent.click(screen.getByRole('button', { name: 'Remove Rice' }))
expect(chips()).toEqual(['Chicken thighs', 'Spinach'])
expect(input()).toHaveFocus()
fireEvent.keyDown(input(), { key: 'Backspace' })
expect(chips()).toEqual(['Chicken thighs'])
fireEvent.click(screen.getByRole('button', { name: 'Remove Chicken thighs' }))
expect(chips()).toEqual([])
expect(findButton()).toBeDisabled()
})
it('says why it leaves out a repeated, overlong or 21st ingredient', async () => {
stubServer({})
await renderPage()
enter('Rice', ' rice ')
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('rice is listed already.')
enter('x'.repeat(81))
expect(chips()).toEqual(['Rice'])
expect(screen.getByRole('status')).toHaveTextContent('An ingredient can be at most 80 characters.')
enter(...Array.from({ length: 19 }, (_, i) => `Item ${i}`))
expect(chips()).toHaveLength(20)
expect(screen.queryByRole('status')).toBeNull()
enter('One too many')
expect(chips()).toHaveLength(20)
expect(screen.getByRole('status')).toHaveTextContent('List at most 20 ingredients.')
})
})
it('fills the results progressively as the server grounds ideas, then enables the actions', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('running', 'suggesting', [])
await renderPage()
enter(...INGREDIENTS)
fireEvent.change(input(), { target: { value: 'lime' } })
fireEvent.click(findButton())
// What is still typed is listed before the search.
expect(chips()).toEqual([...INGREDIENTS, 'lime'])
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(1))
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: [...INGREDIENTS, 'lime'], quick_only: false })
// Suggesting: five skeleton cards, no idea yet.
expect(await screen.findByText('Thinking up ideas…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(5)
expect(screen.queryAllByRole('article')).toHaveLength(0)
// Grounding: the idea grounded so far, and skeletons for the rest; its
// actions wait for the job to end.
jobs.j1 = state('running', 'grounding', [CURRY])
await screen.findByRole('article', { name: CURRY.name }, SLOW)
expect(screen.getByText('Finding recipes for them…')).toBeInTheDocument()
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(4)
expect(within(card(CURRY.name)).getByRole('button', { name: 'Cook tonight' })).toBeDisabled()
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeDisabled()
jobs.j1 = state('running', 'grounding', [CURRY, BOWLS])
await screen.findByRole('article', { name: BOWLS.name }, SLOW)
expect(screen.getAllByTestId('skeleton-card')).toHaveLength(3)
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS, ONE_PAN])
await screen.findByText('3 ideas', {}, SLOW)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([CURRY.name, BOWLS.name, ONE_PAN.name])
const curry = within(card(CURRY.name))
// The server's summary line, as it is.
expect(curry.getByText('Uses all 5 · missing: 1 lime')).toHaveClass(styles.summary!)
expect(card(CURRY.name).querySelector(`.${styles.time}`)).toHaveTextContent('30 min')
expect(card(BOWLS.name).querySelector(`.${styles.time}`)).toHaveTextContent('25 min')
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
expect(curry.getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('leaves the time off an idea whose recipe does not say it', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [idea('Fried rice', 'Uses 2 of 5 · nothing missing', null)])
await renderPage()
await searched(fetchMock)
const fried = await screen.findByRole('article', { name: 'Fried rice' })
expect(within(fried).queryByText(/min/)).toBeNull()
})
describe('Any time | Quick only', () => {
it('sends quick_only, and asks again when changed after a search', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS], true)
jobs.j2 = state('succeeded', 'done', [ONE_PAN], false)
await renderPage()
// Before a search it only sets what Find ideas asks for.
fireEvent.click(screen.getByRole('radio', { name: 'Quick only' }))
expect(calls(fetchMock, FIND)).toHaveLength(0)
await searched(fetchMock, 'j1')
expect(bodyOf(calls(fetchMock, FIND)[0]!)).toEqual({ ingredients: INGREDIENTS, quick_only: true })
await screen.findByText('2 ideas')
fireEvent.click(screen.getByRole('radio', { name: 'Any time' }))
await waitFor(() => expect(calls(fetchMock, FIND)).toHaveLength(2))
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
await screen.findByText('1 idea')
expect(screen.getAllByRole('article').map((a) => a.getAttribute('aria-label'))).toEqual([ONE_PAN.name])
})
it('refuses a result that is not for the filter asked for', async () => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('succeeded', 'done', [CURRY], true)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent("Couldn't read the ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
})
})
describe('Add to Quest log', () => {
it('adds the idea and says so on its card', async () => {
const added = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/1/quest-log': () => added.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY, BOWLS])
await renderPage()
await searched(fetchMock)
await screen.findByText('2 ideas')
const bowls = within(card(BOWLS.name))
fireEvent.click(bowls.getByRole('button', { name: 'Add to Quest log' }))
expect(await bowls.findByRole('button', { name: 'Adding…' })).toBeDisabled()
added.resolve(jsonResponse(201, dinnerBody('dn-1', BOWLS.name, { status: 'quest_log', origin: 'ingredients' })))
expect(await bowls.findByRole('status')).toHaveTextContent('In the Quest log')
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/1/quest-log')).toHaveLength(1)
// The other card is untouched.
expect(within(card(CURRY.name)).getByRole('button', { name: 'Add to Quest log' })).toBeEnabled()
})
it('says an idea that came back as an existing Regular is already a Regular, not in the Quest log', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
jsonResponse(200, dinnerBody('dn-reg', 'Coconut Chicken Curry', { status: 'regular' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('Already a Regular')
expect(curry.queryByText('In the Quest log')).toBeNull()
})
it('fails on a dinner whose status is neither quest_log nor regular', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () => jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'suggested' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent(
'POST /api/v1/ideas/j1/items/0/quest-log: status "suggested" is not one of quest_log, regular',
)
expect(curry.queryByRole('status')).toBeNull()
})
it('shows its failure on its card and lets it be pressed again', async () => {
let fail = true
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/quest-log': () =>
fail ? errorResponse(500, 'internal') : jsonResponse(200, dinnerBody('dn-1', CURRY.name, { status: 'quest_log' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't add it to the Quest log. internal message")
fail = false
fireEvent.click(curry.getByRole('button', { name: 'Add to Quest log' }))
expect(await curry.findByRole('status')).toHaveTextContent('In the Quest log')
expect(curry.queryByRole('alert')).toBeNull()
})
})
describe('Cook tonight', () => {
it("puts the idea on tonight's plan", async () => {
const planned = Promise.withResolvers<Response>()
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => planned.promise,
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('button', { name: 'Planning…' })).toBeDisabled()
planned.resolve(jsonResponse(200, { dinner: dinnerBody('dn-1', CURRY.name, { status: 'quest_log' }), plan: tonightPlan() }))
expect(await curry.findByRole('status')).toHaveTextContent("On tonight's plan")
expect(calls(fetchMock, 'POST /api/v1/ideas/j1/items/0/cook-tonight')).toHaveLength(1)
})
it('offers Plan this week, not an error, when the week has no plan', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'no_plan'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
const link = await curry.findByRole('link', { name: 'Plan this week' })
expect(curry.getByRole('status')).toHaveTextContent('This week has no plan yet')
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(link)
expect(screen.getByText('Plan screen')).toBeInTheDocument()
})
it('shows any other failure on its card', async () => {
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: 'j1' }),
'POST /api/v1/ideas/j1/items/0/cook-tonight': () => errorResponse(409, 'dinner_already_planned'),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const curry = within(await screen.findByRole('article', { name: CURRY.name }))
fireEvent.click(curry.getByRole('button', { name: 'Cook tonight' }))
expect(await curry.findByRole('alert')).toHaveTextContent("Couldn't put it on tonight's plan. dinner_already_planned message")
expect(curry.queryByRole('link', { name: 'Plan this week' })).toBeNull()
expect(curry.getByRole('button', { name: 'Cook tonight' })).toBeEnabled()
})
})
describe('errors', () => {
it('shows the ErrorBanner when the search cannot start, and Retry asks again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => (++n === 1 ? errorResponse(400, 'invalid_request') : jsonResponse(202, { jobId: 'j1' })),
})
jobs.j1 = state('succeeded', 'done', [CURRY])
await renderPage()
enter(...INGREDIENTS)
fireEvent.click(findButton())
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't start looking for ideas. invalid_request message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(bodyOf(calls(fetchMock, FIND)[1]!)).toEqual({ ingredients: INGREDIENTS, quick_only: false })
})
it('shows a failed job with no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'ai_failed')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. The AI assistant couldn't come up with ideas.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
})
it.each([
['search_not_configured', 'Recipe search is not set up.'],
['search_failed', "The recipe search couldn't be reached."],
])('explains %s', async (code, message) => {
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: 'j1' }) })
jobs.j1 = state('failed', 'grounding', [], false, code)
await renderPage()
await searched(fetchMock)
expect(await screen.findByRole('alert')).toHaveTextContent(message)
})
it('shows no ideas found with the ErrorBanner and no cards, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({ [FIND]: () => jsonResponse(202, { jobId: `j${++n}` }) })
jobs.j1 = state('failed', 'grounding', [], false, 'no_ideas_found')
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't find ideas. No ideas came back with a recipe to match.")
expect(screen.queryAllByRole('article')).toHaveLength(0)
expect(screen.queryAllByTestId('skeleton-card')).toHaveLength(0)
expect(screen.queryByRole('heading', { name: 'No ideas found' })).toBeNull()
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
it('shows an unknown job with the ErrorBanner, and Retry searches again', async () => {
let n = 0
const { fetchMock, jobs } = stubServer({
[FIND]: () => jsonResponse(202, { jobId: `j${++n}` }),
'GET /api/v1/jobs/j1': () => errorResponse(404, 'not_found'),
})
jobs.j2 = state('succeeded', 'done', [CURRY])
await renderPage()
await searched(fetchMock)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent("Couldn't check on the ideas. not_found message")
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
await screen.findByRole('article', { name: CURRY.name })
expect(calls(fetchMock, FIND)).toHaveLength(2)
expect(screen.queryByRole('alert')).toBeNull()
})
})
})