Require method and minutes for a typed-in recipe; pause refresh on every save
CI / ci (push) Successful in 2m19s
CI / ci (pull_request) Successful in 2m17s

The manual recipe form now enables Save only once method and minutes are
set, as POST /api/v1/dinners/{id}/ground requires, and always sends both.

useRegulars watches the Regulars changes in flight with useIsMutating, so
the grounding refresh pauses while an edit or a recipe fix is being saved,
not only while an add or favorite changes the list, and resumes after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 18:21:05 -04:00
co-authored by Claude Opus 5.5
parent e3332aba35
commit b2e02223c6
3 changed files with 103 additions and 12 deletions
@@ -447,6 +447,34 @@ describe('RegularsSettingsPage', () => {
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()
@@ -480,6 +508,67 @@ describe('RegularsSettingsPage', () => {
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])