CI / ci (push) Successful in 9m39s
Co-authored-by: orchestrator-agent <20+orchestrator-agent@code.cubecraftcreations.com>
592 lines
28 KiB
TypeScript
592 lines
28 KiB
TypeScript
import { QueryClientProvider } from '@tanstack/react-query'
|
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
import { Route, Routes } from 'react-router'
|
|
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession } from '../api/index.ts'
|
|
import { GROUNDING_POLL_MS } from '../dinners/api.ts'
|
|
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
|
import { InDataRouter } from '../test/dataRouter.tsx'
|
|
import { dinnerBody, type DinnerBody } from '../test/dinners.ts'
|
|
import { errorResponse, fakeLocation, jsonResponse, ME } from '../test/fetchStub.ts'
|
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
|
import { RegularsSettingsPage } from './RegularsSettingsPage.tsx'
|
|
|
|
const REGULARS = '/api/v1/dinners?status=regular'
|
|
|
|
type Responder = (body: unknown) => Response | Promise<Response>
|
|
|
|
// stubServer stands in for the dinners API: GET of the Regulars answers
|
|
// the current server.regulars, so a test changes what the next read sees;
|
|
// every other request is answered by responders, keyed "METHOD path" and
|
|
// called with the request's decoded JSON body. Any other request fails the
|
|
// test.
|
|
function stubServer(regulars: DinnerBody[], responders: Record<string, Responder> = {}) {
|
|
const server = { regulars, fetchMock: undefined as unknown as Mock<typeof fetch> }
|
|
const all: Record<string, Responder> = {
|
|
'GET /api/v1/me': () => jsonResponse(200, ME),
|
|
[`GET ${REGULARS}`]: () => jsonResponse(200, { dinners: server.regulars }),
|
|
...responders,
|
|
}
|
|
server.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}`)
|
|
const body = typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined
|
|
return Promise.resolve(respond(body))
|
|
})
|
|
vi.stubGlobal('fetch', server.fetchMock)
|
|
return server
|
|
}
|
|
|
|
// sent lists the requests other than GETs, as "METHOD path" and body.
|
|
function sent(fetchMock: Mock<typeof fetch>) {
|
|
return fetchMock.mock.calls
|
|
.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')
|
|
.map(([input, init]) => ({
|
|
request: `${init?.method} ${String(input)}`,
|
|
body: typeof init?.body === 'string' ? (JSON.parse(init.body) as unknown) : undefined,
|
|
}))
|
|
}
|
|
|
|
function reads(fetchMock: Mock<typeof fetch>) {
|
|
return fetchMock.mock.calls.filter(([input, init]) => (init?.method ?? 'GET') === 'GET' && String(input) === REGULARS)
|
|
.length
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
// renderPage loads /me (for the CSRF token every write needs) and renders
|
|
// the page at /settings/regulars once its Regulars have loaded.
|
|
async function renderPage() {
|
|
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
|
await api.loadMe()
|
|
render(
|
|
<ApiProvider client={api}>
|
|
<QueryClientProvider client={createQueryClient()}>
|
|
<InDataRouter path="/settings/regulars">
|
|
<Routes>
|
|
<Route path="/settings/regulars" element={<RegularsSettingsPage />} />
|
|
</Routes>
|
|
</InDataRouter>
|
|
</QueryClientProvider>
|
|
</ApiProvider>,
|
|
)
|
|
await screen.findByRole('heading', { level: 2, name: /^On rotation/ })
|
|
}
|
|
|
|
const TACOS = dinnerBody('d1', 'Beef tacos', {
|
|
method: 'stovetop',
|
|
minutes_total: 40,
|
|
difficulty: 'easy',
|
|
is_favorite: true,
|
|
photo_url: '/media/photos/p1-400.jpg',
|
|
})
|
|
const CHILI = dinnerBody('d2', 'Chili', { method: 'crockpot', minutes_total: 480, grounding_status: 'pending' })
|
|
const PIZZA = dinnerBody('d3', 'Pizza night', { grounding_status: 'failed', grounding_error: 'no_recipe_found' })
|
|
const ROAST = dinnerBody('d4', 'Pot roast', { method: 'oven', grounding_status: 'manual' })
|
|
|
|
function rows() {
|
|
return within(screen.getByRole('list', { name: 'Regulars' })).queryAllByRole('listitem')
|
|
}
|
|
|
|
function row(name: string) {
|
|
const item = rows().find((li) => within(li).queryByText(name) !== null)
|
|
if (item === undefined) throw new Error(`no Regular row named ${name}`)
|
|
return item
|
|
}
|
|
|
|
function addField() {
|
|
return screen.getByRole('textbox', { name: 'Add a Regular' })
|
|
}
|
|
|
|
function add(text: string) {
|
|
fireEvent.change(addField(), { target: { value: text } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
|
}
|
|
|
|
function openRow(name: string) {
|
|
fireEvent.click(within(row(name)).getByRole('button', { name: new RegExp(`^${name}`) }))
|
|
}
|
|
|
|
describe('RegularsSettingsPage', () => {
|
|
it('lists each Regular with its photo, method and minutes, favorite heart and grounding badge', async () => {
|
|
stubServer([TACOS, CHILI, PIZZA, ROAST])
|
|
await renderPage()
|
|
|
|
expect(screen.getByRole('heading', { level: 1, name: 'Regular dinners' })).toBeInTheDocument()
|
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 4' })).toBeInTheDocument()
|
|
expect(rows()).toHaveLength(4)
|
|
|
|
const tacos = within(row('Beef tacos'))
|
|
expect(tacos.getByRole('img', { name: 'Beef tacos' })).toHaveAttribute('src', '/media/photos/p1-400.jpg')
|
|
expect(tacos.getByText('Stovetop · 40 min')).toBeInTheDocument()
|
|
expect(tacos.getByText('Recipe found')).toBeInTheDocument()
|
|
expect(tacos.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'true')
|
|
|
|
const chili = within(row('Chili'))
|
|
expect(chili.getByText('Crock pot · 480 min')).toBeInTheDocument()
|
|
expect(chili.getByText('Finding recipe…')).toBeInTheDocument()
|
|
expect(chili.getByRole('button', { name: 'Favorite Chili' })).toHaveAttribute('aria-pressed', 'false')
|
|
|
|
const pizza = within(row('Pizza night'))
|
|
// No photo yet: the striped placeholder stands in.
|
|
expect(pizza.getByRole('img', { name: 'Pizza night' })).toHaveTextContent('dish photo')
|
|
expect(pizza.getByText('Needs a recipe')).toBeInTheDocument()
|
|
|
|
expect(within(row('Pot roast')).getByText('Oven')).toBeInTheDocument()
|
|
expect(within(row('Pot roast')).getByText('Recipe found')).toBeInTheDocument()
|
|
})
|
|
|
|
it('keeps every control at least 44 px', async () => {
|
|
stubServer([TACOS])
|
|
await renderPage()
|
|
expectMinHitArea(screen.getByRole('button', { name: 'Add Regular' }))
|
|
expectMinHitArea(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
|
expectMinHitArea(within(row('Beef tacos')).getByRole('button', { name: /^Beef tacos/ }))
|
|
expectMinHitArea(screen.getByRole('button', { name: 'Back to Settings' }))
|
|
})
|
|
|
|
it('says so when there are no Regulars yet', async () => {
|
|
stubServer([])
|
|
await renderPage()
|
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 0' })).toBeInTheDocument()
|
|
expect(screen.getByText(/No Regulars yet/)).toBeInTheDocument()
|
|
})
|
|
|
|
it('reports Regulars that could not load, and retries', async () => {
|
|
const server = stubServer([], { [`GET ${REGULARS}`]: () => errorResponse(500, 'internal') })
|
|
const api = createApiClient(fakeLocation('/settings/regulars'), createNetworkStatus(window), createVerifiedSession(localStorage))
|
|
render(
|
|
<ApiProvider client={api}>
|
|
<QueryClientProvider client={createQueryClient()}>
|
|
<InDataRouter path="/settings/regulars">
|
|
<RegularsSettingsPage />
|
|
</InDataRouter>
|
|
</QueryClientProvider>
|
|
</ApiProvider>,
|
|
)
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Your Regulars could not load: internal message')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
|
})
|
|
|
|
it('adds a Regular by name, showing it at once and finding its recipe', async () => {
|
|
const server = stubServer([TACOS], {
|
|
'POST /api/v1/dinners': (body) => {
|
|
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
|
server.regulars = [...server.regulars, created]
|
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
|
},
|
|
})
|
|
await renderPage()
|
|
|
|
add(' Chicken enchiladas ')
|
|
expect(addField()).toHaveValue('')
|
|
expect(await screen.findByText('Chicken enchiladas')).toBeInTheDocument()
|
|
expect(within(row('Chicken enchiladas')).getByText('Finding recipe…')).toBeInTheDocument()
|
|
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners', body: { name: 'Chicken enchiladas' } }])
|
|
await waitFor(() => expect(row('Chicken enchiladas')).not.toHaveAttribute('aria-busy'))
|
|
expect(screen.getByRole('heading', { level: 2, name: 'On rotation · 2' })).toBeInTheDocument()
|
|
})
|
|
|
|
it('adds a Regular from a pasted recipe link under the name the household gives it', async () => {
|
|
const link = 'https://example.com/recipes/stew-key-abcdefghijklmno'
|
|
const server = stubServer([], {
|
|
'POST /api/v1/dinners': (body) => {
|
|
const created = dinnerBody('d9', (body as { name: string }).name, { grounding_status: 'pending' })
|
|
server.regulars = [created]
|
|
return jsonResponse(201, { ...created, grounding_job_id: 'job-1' })
|
|
},
|
|
})
|
|
await renderPage()
|
|
|
|
fireEvent.change(addField(), { target: { value: ` ${link} ` } })
|
|
// A link alone cannot be added: it never names a dinner.
|
|
expect(screen.getByRole('button', { name: 'Add Regular' })).toBeDisabled()
|
|
const name = screen.getByRole('textbox', { name: 'Dinner name' })
|
|
expect(name).toHaveAccessibleDescription(/never names a dinner/)
|
|
fireEvent.change(name, { target: { value: ' Sunday stew ' } })
|
|
fireEvent.click(screen.getByRole('button', { name: 'Add Regular' }))
|
|
|
|
expect(addField()).toHaveValue('')
|
|
expect(screen.queryByRole('textbox', { name: 'Dinner name' })).not.toBeInTheDocument()
|
|
expect(await screen.findByText('Sunday stew')).toBeInTheDocument()
|
|
expect(sent(server.fetchMock)).toEqual([
|
|
{ request: 'POST /api/v1/dinners', body: { name: 'Sunday stew', recipe_url: link } },
|
|
])
|
|
expect(within(row('Sunday stew')).getByText('Finding recipe…')).toBeInTheDocument()
|
|
expect(document.body).not.toHaveTextContent(/example\.com|abcdefghijklmno|key/i)
|
|
})
|
|
|
|
it('takes back an add the server refuses and says why', async () => {
|
|
stubServer([TACOS], { 'POST /api/v1/dinners': () => errorResponse(409, 'duplicate_dinner') })
|
|
await renderPage()
|
|
|
|
add('Beef tacos!')
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not add Beef tacos!: duplicate_dinner message')
|
|
expect(rows()).toHaveLength(1)
|
|
})
|
|
|
|
it('toggles the favorite heart', async () => {
|
|
const server = stubServer([TACOS], {
|
|
'POST /api/v1/dinners/d1/favorite': () => {
|
|
server.regulars = [{ ...TACOS, is_favorite: false }]
|
|
return jsonResponse(200, server.regulars[0])
|
|
},
|
|
})
|
|
await renderPage()
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Favorite Beef tacos' }))
|
|
await waitFor(() =>
|
|
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false'),
|
|
)
|
|
expect(sent(server.fetchMock)).toEqual([{ request: 'POST /api/v1/dinners/d1/favorite', body: undefined }])
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
|
expect(screen.getByRole('button', { name: 'Favorite Beef tacos' })).toHaveAttribute('aria-pressed', 'false')
|
|
})
|
|
|
|
it('edits a Regular: name, method, minutes and difficulty', async () => {
|
|
const server = stubServer([TACOS], {
|
|
'PATCH /api/v1/dinners/d1': (body) => {
|
|
const edited = { ...TACOS, ...(body as object) }
|
|
server.regulars = [edited]
|
|
return jsonResponse(200, edited)
|
|
},
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Beef tacos')
|
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
|
expect(sheet.getByLabelText('Name')).toHaveValue('Beef tacos')
|
|
expect(sheet.getByLabelText('Method')).toHaveValue('stovetop')
|
|
expect(sheet.getByLabelText('Minutes')).toHaveValue(40)
|
|
expect(sheet.getByLabelText('Difficulty')).toHaveValue('easy')
|
|
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
|
|
|
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
|
fireEvent.change(sheet.getByLabelText('Method'), { target: { value: 'grill' } })
|
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '25' } })
|
|
fireEvent.change(sheet.getByLabelText('Difficulty'), { target: { value: 'medium' } })
|
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
expect(sent(server.fetchMock)).toEqual([
|
|
{
|
|
request: 'PATCH /api/v1/dinners/d1',
|
|
body: { name: 'Fish tacos', method: 'grill', minutes_total: 25, difficulty: 'medium' },
|
|
},
|
|
])
|
|
expect(within(row('Fish tacos')).getByText('Grill · 25 min')).toBeInTheDocument()
|
|
})
|
|
|
|
it('sends only the changed fields, refuses bad minutes and shows a refused edit', async () => {
|
|
const server = stubServer([TACOS], { 'PATCH /api/v1/dinners/d1': () => errorResponse(400, 'invalid_dinner') })
|
|
await renderPage()
|
|
|
|
openRow('Beef tacos')
|
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '0' } })
|
|
expect(sheet.getByText('Minutes must be a whole number from 1 to 1440.')).toBeInTheDocument()
|
|
expect(sheet.getByRole('button', { name: 'Save changes' })).toBeDisabled()
|
|
|
|
fireEvent.change(sheet.getByLabelText('Minutes'), { target: { value: '45' } })
|
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
|
expect(await sheet.findByRole('alert')).toHaveTextContent('Not saved: invalid_dinner message')
|
|
expect(sent(server.fetchMock)).toEqual([{ request: 'PATCH /api/v1/dinners/d1', body: { minutes_total: 45 } }])
|
|
})
|
|
|
|
it('removes a Regular only once confirmed', async () => {
|
|
const server = stubServer([TACOS, ROAST], {
|
|
'DELETE /api/v1/dinners/d1': () => {
|
|
server.regulars = [ROAST]
|
|
return jsonResponse(200, { ...TACOS, status: 'archived' })
|
|
},
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Beef tacos')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
|
const confirm = within(screen.getByRole('dialog', { name: 'Remove Beef tacos?' }))
|
|
fireEvent.click(confirm.getByRole('button', { name: 'Keep it' }))
|
|
expect(screen.getByRole('dialog', { name: 'Edit Beef tacos' })).toBeInTheDocument()
|
|
expect(sent(server.fetchMock)).toEqual([])
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
await waitFor(() => expect(rows()).toHaveLength(1))
|
|
expect(within(rows()[0]!).getByText('Pot roast')).toBeInTheDocument()
|
|
expect(sent(server.fetchMock)).toEqual([{ request: 'DELETE /api/v1/dinners/d1', body: undefined }])
|
|
})
|
|
|
|
it('puts a refused removal back and says why', async () => {
|
|
stubServer([TACOS], { 'DELETE /api/v1/dinners/d1': () => errorResponse(500, 'internal') })
|
|
await renderPage()
|
|
|
|
openRow('Beef tacos')
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove from Regulars' }))
|
|
fireEvent.click(screen.getByRole('button', { name: 'Remove' }))
|
|
expect(await screen.findByRole('alert')).toHaveTextContent('Could not remove Beef tacos: internal message')
|
|
expect(rows()).toHaveLength(1)
|
|
})
|
|
|
|
describe('Fix recipe', () => {
|
|
const CANDIDATES = {
|
|
candidates: [
|
|
{
|
|
candidate_id: 'https://example.com/pizza',
|
|
title: 'Weeknight pizza',
|
|
site_name: 'Example Kitchen',
|
|
photo_url: '/media/photos/p7-400.jpg',
|
|
minutes: 45,
|
|
},
|
|
{ candidate_id: 'https://cook.test/pizza', title: 'Sheet-pan pizza', site_name: 'Cook Test', photo_url: null, minutes: null },
|
|
{ candidate_id: 'https://food.test/p', title: 'Pizza margherita', site_name: null, photo_url: null, minutes: 30 },
|
|
],
|
|
}
|
|
|
|
function grounded(server: { regulars: DinnerBody[] }, overrides: Partial<DinnerBody>) {
|
|
const fixed = { ...PIZZA, grounding_error: null, ...overrides }
|
|
server.regulars = [fixed]
|
|
return jsonResponse(200, fixed)
|
|
}
|
|
|
|
function fixSheet() {
|
|
return within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
|
}
|
|
|
|
it('opens from a Needs a recipe row and picks one of up to three matches', async () => {
|
|
const server = stubServer([PIZZA], {
|
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
|
'POST /api/v1/dinners/d3/ground': () =>
|
|
grounded(server, { grounding_status: 'grounded', photo_url: '/media/photos/p7-400.jpg', method: 'oven', minutes_total: 45 }),
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Pizza night')
|
|
const matches = within(await fixSheet().findByRole('list', { name: 'Recipe matches' }))
|
|
const options = matches.getAllByRole('button')
|
|
expect(options.map((option) => option.getAttribute('aria-label'))).toEqual([
|
|
'Use Weeknight pizza from Example Kitchen',
|
|
'Use Sheet-pan pizza from Cook Test',
|
|
'Use Pizza margherita',
|
|
])
|
|
expect(within(options[0]!).getByRole('img', { name: 'Weeknight pizza' })).toHaveAttribute('src', '/media/photos/p7-400.jpg')
|
|
expect(within(options[0]!).getByText('Example Kitchen')).toBeInTheDocument()
|
|
expectMinHitArea(options[0]!)
|
|
|
|
fireEvent.click(options[0]!)
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
expect(sent(server.fetchMock)).toEqual([
|
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { candidate_id: 'https://example.com/pizza' } },
|
|
])
|
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
|
expect(within(row('Pizza night')).getByText('Oven · 45 min')).toBeInTheDocument()
|
|
})
|
|
|
|
it('says so when no match was found', async () => {
|
|
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }) })
|
|
await renderPage()
|
|
openRow('Pizza night')
|
|
expect(await fixSheet().findByText('No matches were found. Paste a recipe link or type it in.')).toBeInTheDocument()
|
|
})
|
|
|
|
it('grounds a pasted recipe link, and says why a link was refused', async () => {
|
|
let answers = 0
|
|
const server = stubServer([PIZZA], {
|
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
|
'POST /api/v1/dinners/d3/ground': () =>
|
|
++answers === 1 ? errorResponse(422, 'ungroundable') : grounded(server, { grounding_status: 'grounded' }),
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Pizza night')
|
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Paste a link' }))
|
|
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: ' https://example.com/blog/pizza ' } })
|
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
|
expect(await fixSheet().findByRole('alert')).toHaveTextContent('Not fixed: ungroundable message')
|
|
|
|
fireEvent.change(fixSheet().getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza-2' } })
|
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Use this recipe' }))
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
expect(sent(server.fetchMock)).toEqual([
|
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/blog/pizza' } },
|
|
{ request: 'POST /api/v1/dinners/d3/ground', body: { recipe_url: 'https://example.com/pizza-2' } },
|
|
])
|
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
|
})
|
|
|
|
it('saves ingredients and steps typed in', async () => {
|
|
const server = stubServer([PIZZA], {
|
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
|
'POST /api/v1/dinners/d3/ground': () =>
|
|
grounded(server, { grounding_status: 'manual', method: 'oven', minutes_total: 35 }),
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Pizza night')
|
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
|
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
|
expect(save).toBeDisabled()
|
|
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), {
|
|
target: { value: '1 pizza dough\n\n 1 cup tomato sauce \n2 cups mozzarella' },
|
|
})
|
|
fireEvent.change(fixSheet().getByLabelText('Steps, one per line'), {
|
|
target: { value: 'Heat the oven to 250°C.\nTop the dough and bake 12 minutes.\n' },
|
|
})
|
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
|
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
|
fireEvent.click(save)
|
|
|
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
|
expect(sent(server.fetchMock)).toEqual([
|
|
{
|
|
request: 'POST /api/v1/dinners/d3/ground',
|
|
body: {
|
|
manual: {
|
|
ingredients: ['1 pizza dough', '1 cup tomato sauce', '2 cups mozzarella'],
|
|
steps: ['Heat the oven to 250°C.', 'Top the dough and bake 12 minutes.'],
|
|
minutes: 35,
|
|
method: 'oven',
|
|
},
|
|
},
|
|
},
|
|
])
|
|
expect(within(row('Pizza night')).getByText('Recipe found')).toBeInTheDocument()
|
|
expect(within(row('Pizza night')).getByText('Oven · 35 min')).toBeInTheDocument()
|
|
})
|
|
|
|
it('saves a typed-in recipe only once its method and minutes are set', async () => {
|
|
const server = stubServer([PIZZA], {
|
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES),
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Pizza night')
|
|
fireEvent.click(fixSheet().getByRole('radio', { name: 'Type it in' }))
|
|
const save = fixSheet().getByRole('button', { name: 'Save recipe' })
|
|
fireEvent.change(fixSheet().getByLabelText('Ingredients, one per line'), { target: { value: '1 pizza dough' } })
|
|
expect(fixSheet().getByText('Choose a method and minutes to save.')).toBeInTheDocument()
|
|
expect(save).toBeDisabled()
|
|
|
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
|
expect(save).toBeDisabled()
|
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: '' } })
|
|
fireEvent.change(fixSheet().getByLabelText('Minutes'), { target: { value: '35' } })
|
|
expect(save).toBeDisabled()
|
|
|
|
// Even a submit that skips the button sends nothing.
|
|
fireEvent.submit(save.closest('form')!)
|
|
expect(sent(server.fetchMock)).toEqual([])
|
|
|
|
fireEvent.change(fixSheet().getByLabelText('Method'), { target: { value: 'oven' } })
|
|
expect(fixSheet().queryByText('Choose a method and minutes to save.')).not.toBeInTheDocument()
|
|
expect(save).toBeEnabled()
|
|
})
|
|
|
|
it('switches to editing the Regular', async () => {
|
|
stubServer([PIZZA], { 'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, CANDIDATES) })
|
|
await renderPage()
|
|
openRow('Pizza night')
|
|
fireEvent.click(fixSheet().getByRole('button', { name: 'Edit or remove Pizza night' }))
|
|
expect(screen.getByRole('dialog', { name: 'Edit Pizza night' })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('refreshes grounding status while any Regular is finding its recipe, then stops', async () => {
|
|
// Only intervals are faked: TanStack Query polls with setInterval, and
|
|
// Testing Library's waits keep their real timers.
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
const server = stubServer([TACOS, CHILI])
|
|
await renderPage()
|
|
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
|
expect(reads(server.fetchMock)).toBe(1)
|
|
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
|
expect(within(row('Chili')).getByText('Finding recipe…')).toBeInTheDocument()
|
|
|
|
server.regulars = [TACOS, { ...CHILI, grounding_status: 'grounded', photo_url: '/media/photos/p2-400.jpg' }]
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
|
expect(await within(row('Chili')).findByText('Recipe found')).toBeInTheDocument()
|
|
expect(within(row('Chili')).getByRole('img', { name: 'Chili' })).toHaveAttribute('src', '/media/photos/p2-400.jpg')
|
|
expect(reads(server.fetchMock)).toBe(3)
|
|
|
|
// Nothing is pending any more, so nothing is read again.
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
|
expect(reads(server.fetchMock)).toBe(3)
|
|
})
|
|
|
|
// held answers a request only when release is called, so a test can look
|
|
// at the page while a save is in flight.
|
|
function held(answer: () => Response) {
|
|
let release = () => {}
|
|
const response = new Promise<Response>((resolve) => {
|
|
release = () => resolve(answer())
|
|
})
|
|
return { respond: () => response, release: () => release() }
|
|
}
|
|
|
|
it('pauses the refresh while an edit is being saved, then resumes', async () => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
const edited = { ...TACOS, name: 'Fish tacos' }
|
|
const patch = held(() => jsonResponse(200, edited))
|
|
const server = stubServer([TACOS, CHILI], { 'PATCH /api/v1/dinners/d1': patch.respond })
|
|
await renderPage()
|
|
|
|
openRow('Beef tacos')
|
|
const sheet = within(screen.getByRole('dialog', { name: 'Edit Beef tacos' }))
|
|
fireEvent.change(sheet.getByLabelText('Name'), { target: { value: 'Fish tacos' } })
|
|
fireEvent.click(sheet.getByRole('button', { name: 'Save changes' }))
|
|
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
|
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
|
expect(reads(server.fetchMock)).toBe(1)
|
|
|
|
server.regulars = [edited, CHILI]
|
|
patch.release()
|
|
// Settling reads the list once, then the refresh carries on.
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
|
})
|
|
|
|
it('pauses the refresh while a recipe fix is being saved, then resumes', async () => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
const fixed = { ...PIZZA, grounding_status: 'grounded' as const, grounding_error: null }
|
|
const ground = held(() => jsonResponse(200, fixed))
|
|
const server = stubServer([CHILI, PIZZA], {
|
|
'GET /api/v1/dinners/d3/recipe-candidates': () => jsonResponse(200, { candidates: [] }),
|
|
'POST /api/v1/dinners/d3/ground': ground.respond,
|
|
})
|
|
await renderPage()
|
|
|
|
openRow('Pizza night')
|
|
const sheet = within(screen.getByRole('dialog', { name: 'Fix recipe' }))
|
|
fireEvent.click(sheet.getByRole('radio', { name: 'Paste a link' }))
|
|
fireEvent.change(sheet.getByLabelText('Recipe link'), { target: { value: 'https://example.com/pizza' } })
|
|
fireEvent.click(sheet.getByRole('button', { name: 'Use this recipe' }))
|
|
await waitFor(() => expect(sent(server.fetchMock)).toHaveLength(1))
|
|
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
|
expect(reads(server.fetchMock)).toBe(1)
|
|
|
|
server.regulars = [CHILI, fixed]
|
|
ground.release()
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(2))
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS))
|
|
await waitFor(() => expect(reads(server.fetchMock)).toBe(3))
|
|
})
|
|
|
|
it('does not poll when no Regular is finding its recipe', async () => {
|
|
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
|
|
const server = stubServer([TACOS, PIZZA])
|
|
await renderPage()
|
|
await act(() => vi.advanceTimersByTimeAsync(GROUNDING_POLL_MS * 3))
|
|
expect(reads(server.fetchMock)).toBe(1)
|
|
})
|
|
})
|