2026-10-09 14:01:14 -04:00
|
|
|
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
|
|
|
|
import { createMemoryRouter, RouterProvider } from 'react-router'
|
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
|
|
|
|
|
import { ApiProviders, HOUSEHOLD_SETTINGS } from '../test/ApiProviders.tsx'
|
|
|
|
|
import { declaredStyle } from '../test/cssRules.ts'
|
|
|
|
|
import { errorResponse, jsonResponse } from '../test/fetchStub.ts'
|
|
|
|
|
import { mockViewport } from '../test/matchMedia.ts'
|
|
|
|
|
import { DinnerDetailPage } from './DinnerDetailPage.tsx'
|
|
|
|
|
import { TEXT_SIZE_KEY } from './textSize.ts'
|
|
|
|
|
|
|
|
|
|
const DINNER = '/api/v1/dinners/d1'
|
|
|
|
|
const SETTINGS = '/api/v1/settings/household'
|
|
|
|
|
const THIS_WEEK = '/api/v1/plans/2026-10-05'
|
|
|
|
|
const NEXT_WEEK = '/api/v1/plans/2026-10-12'
|
|
|
|
|
|
|
|
|
|
// detail is the Korean beef bowls, a Quest-log Side quest, as GET
|
|
|
|
|
// /api/v1/dinners/{id} sends it.
|
|
|
|
|
function detail(overrides: Record<string, unknown> = {}) {
|
|
|
|
|
return {
|
|
|
|
|
id: 'd1',
|
|
|
|
|
name: 'Korean beef bowls',
|
|
|
|
|
status: 'quest_log',
|
|
|
|
|
origin: 'suggested',
|
|
|
|
|
method: 'stovetop',
|
|
|
|
|
minutes_total: 30,
|
|
|
|
|
difficulty: 'easy',
|
|
|
|
|
cuisine: 'korean',
|
|
|
|
|
is_favorite: false,
|
|
|
|
|
photo_urls: null,
|
|
|
|
|
ingredients: [
|
|
|
|
|
{ name: 'Ground beef', qty: '1', unit: 'lb', note: null, raw_text: '1 lb ground beef' },
|
|
|
|
|
{ name: 'Jasmine rice', qty: '2', unit: 'cups', note: null, raw_text: '2 cups jasmine rice' },
|
|
|
|
|
{ name: 'Green onions', qty: '1', unit: 'bunch', note: 'sliced', raw_text: '1 bunch green onions, sliced' },
|
|
|
|
|
],
|
|
|
|
|
steps: [
|
|
|
|
|
'Rinse the rice and start it cooking.',
|
|
|
|
|
'Brown the beef with the garlic and ginger.',
|
|
|
|
|
'Stir in the sauce and simmer until glossy.',
|
|
|
|
|
],
|
|
|
|
|
source: null,
|
|
|
|
|
grounding_status: 'grounded',
|
|
|
|
|
grounding_error: null,
|
|
|
|
|
why_picked: 'close cousin of your Beef tacos.',
|
|
|
|
|
...overrides,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// planBody is a week's plan as GET /api/v1/plans/{weekStart} sends it: p0 to
|
|
|
|
|
// p6, except the bowls (d1) on each day of bowlsOn, counting from Monday = 0.
|
|
|
|
|
function planBody(weekStart: string, ...bowlsOn: number[]) {
|
|
|
|
|
const start = new Date(`${weekStart}T00:00:00Z`).getTime()
|
|
|
|
|
return {
|
|
|
|
|
id: `plan-${weekStart}`,
|
|
|
|
|
week_start: weekStart,
|
|
|
|
|
status: 'draft',
|
|
|
|
|
ai_note: null,
|
|
|
|
|
calendar_synced_at: null,
|
|
|
|
|
days: Array.from({ length: 7 }, (_, i) => ({
|
|
|
|
|
date: new Date(start + i * 86_400_000).toISOString().slice(0, 10),
|
|
|
|
|
dinner:
|
|
|
|
|
bowlsOn.includes(i)
|
|
|
|
|
? { id: 'd1', name: 'Korean beef bowls', photo: null, minutes: 30, method: 'stovetop' }
|
|
|
|
|
: { id: `p${i}`, name: `Dinner ${i}`, photo: null, minutes: 30, method: 'stovetop' },
|
|
|
|
|
is_new: bowlsOn.includes(i),
|
|
|
|
|
start_by: null,
|
|
|
|
|
})),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// serve answers the screen's reads: the dinner, the household settings and
|
|
|
|
|
// both weeks' plans, unless responses says otherwise.
|
|
|
|
|
function serve(responses: Record<string, () => Response> = {}): Mock<typeof fetch> {
|
|
|
|
|
const all: Record<string, () => Response> = {
|
|
|
|
|
[DINNER]: () => jsonResponse(200, detail()),
|
|
|
|
|
[SETTINGS]: () => jsonResponse(200, HOUSEHOLD_SETTINGS),
|
|
|
|
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 5)),
|
|
|
|
|
[NEXT_WEEK]: () => errorResponse(404, 'not_found'),
|
|
|
|
|
...responses,
|
|
|
|
|
}
|
|
|
|
|
const fetchMock = vi.fn<typeof fetch>((input, init) => {
|
|
|
|
|
const path = String(input)
|
|
|
|
|
const respond = Object.hasOwn(all, path) ? all[path] : undefined
|
|
|
|
|
if (respond === undefined) throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
|
|
|
|
return Promise.resolve(respond())
|
|
|
|
|
})
|
|
|
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
|
return fetchMock
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function reads(fetchMock: Mock<typeof fetch>, path: string) {
|
|
|
|
|
return fetchMock.mock.calls.filter(([input]) => String(input) === path).length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderPage renders the bowls' detail screen and waits for its title.
|
|
|
|
|
async function renderPage() {
|
|
|
|
|
const router = createMemoryRouter([{ path: '/dinner/:id', element: <DinnerDetailPage /> }], {
|
|
|
|
|
initialEntries: ['/dinner/d1'],
|
|
|
|
|
})
|
|
|
|
|
const view = render(
|
|
|
|
|
<ApiProviders>
|
|
|
|
|
<RouterProvider router={router} />
|
|
|
|
|
</ApiProviders>,
|
|
|
|
|
)
|
|
|
|
|
await screen.findByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
|
|
|
|
return view
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function steps() {
|
|
|
|
|
return within(screen.getByRole('region', { name: 'Steps' })).getAllByRole('listitem')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// currentStep is the text of the step marked current.
|
|
|
|
|
function currentStep() {
|
|
|
|
|
const current = steps().filter((step) => step.getAttribute('aria-current') === 'step')
|
|
|
|
|
expect(current).toHaveLength(1)
|
|
|
|
|
return current[0]!.textContent
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// FakeSentinel is a WakeLockSentinel as far as the screen uses one.
|
|
|
|
|
type FakeSentinel = EventTarget & { released: boolean; release: Mock<() => Promise<void>> }
|
|
|
|
|
|
|
|
|
|
// letGo releases a lock as the browser does, telling its listeners so.
|
|
|
|
|
function letGo(sentinel: FakeSentinel) {
|
|
|
|
|
if (sentinel.released) return
|
|
|
|
|
sentinel.released = true
|
|
|
|
|
sentinel.dispatchEvent(new Event('release'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stubWakeLock gives jsdom, which has none, a Screen Wake Lock. Each request
|
|
|
|
|
// is granted at once, or, given hold, only when the test calls grant.
|
|
|
|
|
function stubWakeLock({ hold = false } = {}) {
|
|
|
|
|
const sentinels: FakeSentinel[] = []
|
|
|
|
|
const pending: (() => void)[] = []
|
|
|
|
|
const request = vi.fn((type: string) => {
|
|
|
|
|
expect(type).toBe('screen')
|
|
|
|
|
const sentinel: FakeSentinel = Object.assign(new EventTarget(), {
|
|
|
|
|
released: false,
|
|
|
|
|
release: vi.fn(() => {
|
|
|
|
|
letGo(sentinel)
|
|
|
|
|
return Promise.resolve()
|
|
|
|
|
}),
|
|
|
|
|
})
|
|
|
|
|
sentinels.push(sentinel)
|
|
|
|
|
if (!hold) return Promise.resolve(sentinel)
|
|
|
|
|
return new Promise<FakeSentinel>((resolve) => pending.push(() => resolve(sentinel)))
|
|
|
|
|
})
|
|
|
|
|
Object.defineProperty(navigator, 'wakeLock', { configurable: true, value: { request } })
|
|
|
|
|
return { request, sentinels, grant: () => pending.shift()!() }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// setVisibility shows or hides the page as the browser would, telling the
|
|
|
|
|
// page so.
|
|
|
|
|
function setVisibility(state: DocumentVisibilityState) {
|
|
|
|
|
Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => state })
|
|
|
|
|
document.dispatchEvent(new Event('visibilitychange'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
mockViewport(1180)
|
|
|
|
|
// Wednesday Oct 7, noon in New York.
|
|
|
|
|
vi.useFakeTimers({ toFake: ['Date'] })
|
|
|
|
|
vi.setSystemTime(new Date('2026-10-07T16:00:00Z'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
vi.useRealTimers()
|
|
|
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
|
|
|
|
Reflect.deleteProperty(navigator, 'wakeLock')
|
|
|
|
|
Reflect.deleteProperty(document, 'visibilityState')
|
|
|
|
|
vi.unstubAllGlobals()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Kitchen reading mode', () => {
|
|
|
|
|
it('is the tablet layout of the same route: header with back, title, Keep screen on and text size', async () => {
|
|
|
|
|
stubWakeLock()
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
|
|
|
|
|
const title = screen.getByRole('heading', { level: 1, name: 'Korean beef bowls' })
|
|
|
|
|
expect(declaredStyle(title).fontSize).toBe('36px')
|
|
|
|
|
expect(screen.getByRole('button', { name: 'Back' })).toBeInTheDocument()
|
|
|
|
|
// Not the phone's sheet: no photo hero tags.
|
|
|
|
|
expect(screen.queryByText('Easy')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
|
|
|
|
|
const sizes = screen.getByRole('radiogroup', { name: 'Text size' })
|
|
|
|
|
expect(within(sizes).getAllByRole('radio').map((radio) => radio.getAttribute('aria-label'))).toEqual([
|
|
|
|
|
'Small text',
|
|
|
|
|
'Medium text',
|
|
|
|
|
'Large text',
|
|
|
|
|
])
|
|
|
|
|
expect(within(sizes).getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
|
|
|
|
|
expect(declaredStyle(screen.getByRole('region', { name: 'Ingredients' }).parentElement!).gridTemplateColumns).toBe(
|
|
|
|
|
'400px minmax(0, 1fr)',
|
|
|
|
|
)
|
|
|
|
|
expect(screen.getByText(/close cousin of your Beef tacos/)).toHaveTextContent('Why it was picked: close cousin')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('kicker', () => {
|
|
|
|
|
it('names the day the plan has the dinner on, and Side quest', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("names a day of next week's plan, and Regular, for a Regular", async () => {
|
|
|
|
|
serve({
|
|
|
|
|
[DINNER]: () => jsonResponse(200, detail({ status: 'regular', origin: 'regular' })),
|
|
|
|
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05')),
|
|
|
|
|
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12', 0)),
|
|
|
|
|
})
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText('Monday, Oct 12 · Regular')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it("names a day of this week's plan already past when that is the only day", async () => {
|
|
|
|
|
// Today is Wednesday; the bowls were on Monday, and are on no day of next week.
|
|
|
|
|
serve({
|
|
|
|
|
[THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0)),
|
|
|
|
|
[NEXT_WEEK]: () => jsonResponse(200, planBody('2026-10-12')),
|
|
|
|
|
})
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText('Monday, Oct 5 · Side quest')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('names the first day from today on over a day already past', async () => {
|
|
|
|
|
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1, 4, 6)) })
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText('Friday, Oct 9 · Side quest')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText(/Monday|Tuesday|Sunday/)).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('names the latest day already past when every day is past', async () => {
|
|
|
|
|
serve({ [THIS_WEEK]: () => jsonResponse(200, planBody('2026-10-05', 0, 1)) })
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText('Tuesday, Oct 6 · Side quest')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText(/Monday/)).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('is absent when neither week has a plan', async () => {
|
|
|
|
|
const fetchMock = serve({ [THIS_WEEK]: () => errorResponse(404, 'not_found') })
|
|
|
|
|
await renderPage()
|
|
|
|
|
await waitFor(() => expect(reads(fetchMock, THIS_WEEK)).toBe(1))
|
|
|
|
|
await waitFor(() => expect(reads(fetchMock, NEXT_WEEK)).toBe(1))
|
|
|
|
|
await act(async () => {})
|
|
|
|
|
expect(screen.queryByText(/· Side quest/)).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText(/Oct/)).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('says so when the plan cannot be read, with Retry', async () => {
|
|
|
|
|
let failing = true
|
|
|
|
|
serve({
|
|
|
|
|
[THIS_WEEK]: () => (failing ? errorResponse(500, 'internal') : jsonResponse(200, planBody('2026-10-05', 5))),
|
|
|
|
|
})
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(await screen.findByText(/Couldn't check the plan for this dinner's day/)).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
failing = false
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
|
|
|
expect(await screen.findByText('Saturday, Oct 10 · Side quest')).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('ingredients', () => {
|
|
|
|
|
it('checks ingredients off with 22 px checkboxes, struck through, counting those set out', async () => {
|
|
|
|
|
const fetchMock = serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const card = screen.getByRole('region', { name: 'Ingredients' })
|
|
|
|
|
expect(within(card).getByText('0 of 3 set out')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const beef = within(card).getByRole('checkbox', { name: 'Ground beef' })
|
|
|
|
|
expect(declaredStyle(beef).width).toBe('22px')
|
|
|
|
|
expect(declaredStyle(beef).height).toBe('22px')
|
|
|
|
|
expect(within(card).getByRole('checkbox', { name: 'Green onions, sliced' })).not.toBeChecked()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(beef)
|
|
|
|
|
expect(beef).toBeChecked()
|
|
|
|
|
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('line-through')
|
|
|
|
|
fireEvent.click(within(card).getByRole('checkbox', { name: 'Jasmine rice' }))
|
|
|
|
|
expect(within(card).getByText('2 of 3 set out')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(beef)
|
|
|
|
|
expect(beef).not.toBeChecked()
|
|
|
|
|
expect(declaredStyle(within(card).getByText('Ground beef')).textDecoration).toBe('')
|
|
|
|
|
expect(within(card).getByText('1 of 3 set out')).toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
// Local to the screen: nothing is sent.
|
|
|
|
|
expect(fetchMock.mock.calls.filter(([, init]) => (init?.method ?? 'GET') !== 'GET')).toEqual([])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('starts afresh on the next visit', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
const view = await renderPage()
|
|
|
|
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Ground beef' }))
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next step' }))
|
|
|
|
|
view.unmount()
|
|
|
|
|
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.getByText('0 of 3 set out')).toBeInTheDocument()
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('steps', () => {
|
|
|
|
|
it('highlights the current step in 22 px text, others 18 px, and moves by Previous and Next step', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const previous = screen.getByRole('button', { name: 'Previous' })
|
|
|
|
|
const next = screen.getByRole('button', { name: 'Next step' })
|
|
|
|
|
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
expect(previous).toBeDisabled()
|
|
|
|
|
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(22px * var(--reading-scale))')
|
|
|
|
|
expect(declaredStyle(steps()[0]!).background).toBe('var(--color-accent-100)')
|
|
|
|
|
expect(declaredStyle(steps()[0]!).boxShadow).toBe('inset 0 0 0 2px var(--color-accent-300)')
|
|
|
|
|
expect(declaredStyle(steps()[1]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(next)
|
|
|
|
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
|
|
|
|
// The step done is muted, its number a check.
|
|
|
|
|
expect(steps()[0]!.textContent).toBe('Rinse the rice and start it cooking.')
|
|
|
|
|
expect(steps()[0]!.querySelector('svg')).not.toBeNull()
|
2026-10-11 00:04:22 -04:00
|
|
|
expect(declaredStyle(steps()[0]!).color).toBe('var(--color-neutral-700)')
|
2026-10-09 14:01:14 -04:00
|
|
|
expect(declaredStyle(steps()[0]!).fontSize).toBe('calc(18px * var(--reading-scale))')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(next)
|
|
|
|
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
|
|
|
|
expect(next).toBeDisabled()
|
|
|
|
|
fireEvent.click(next)
|
|
|
|
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(previous)
|
|
|
|
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('moves by the ← and → keys, stopping at either end', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
|
|
|
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight' })
|
|
|
|
|
expect(currentStep()).toBe('3Stir in the sauce and simmer until glossy.')
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
|
|
|
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowLeft' })
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
|
|
|
|
|
// From a checked ingredient too.
|
|
|
|
|
const beef = screen.getByRole('checkbox', { name: 'Ground beef' })
|
|
|
|
|
beef.focus()
|
|
|
|
|
fireEvent.keyDown(beef, { key: 'ArrowRight' })
|
|
|
|
|
expect(currentStep()).toBe('2Brown the beef with the garlic and ginger.')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('leaves the keys alone in a text field, with a modifier, or in the text size control', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const field = document.createElement('input')
|
|
|
|
|
document.body.append(field)
|
|
|
|
|
field.focus()
|
|
|
|
|
fireEvent.keyDown(field, { key: 'ArrowRight' })
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
field.remove()
|
|
|
|
|
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'ArrowRight', altKey: true })
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
|
|
|
|
|
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
|
|
|
|
fireEvent.keyDown(medium, { key: 'ArrowRight' })
|
|
|
|
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
expect(currentStep()).toBe('1Rinse the rice and start it cooking.')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('text size', () => {
|
2026-10-11 00:04:22 -04:00
|
|
|
it('rings the focused size inside the clipped pill, in on-accent ink on the grape', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const medium = screen.getByRole('radio', { name: 'Medium text' })
|
|
|
|
|
// A key press makes the next focus a keyboard one, which
|
|
|
|
|
// :focus-visible matches.
|
|
|
|
|
fireEvent.keyDown(document.body, { key: 'Tab' })
|
|
|
|
|
medium.focus()
|
|
|
|
|
expect(medium.matches(':focus-visible')).toBe(true)
|
|
|
|
|
// The pill hides overflow, so a ring outside the size would be cut off.
|
|
|
|
|
expect(declaredStyle(medium).getPropertyValue('outline-offset')).toBe('-4px')
|
|
|
|
|
expect(declaredStyle(medium).getPropertyValue('outline-color')).toBe('var(--on-accent)')
|
|
|
|
|
expect(declaredStyle(medium).background).toBe('var(--color-accent)')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-09 14:01:14 -04:00
|
|
|
it('is remembered on this device', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
const view = await renderPage()
|
|
|
|
|
fireEvent.click(screen.getByRole('radio', { name: 'Large text' }))
|
|
|
|
|
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('large')
|
|
|
|
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
view.unmount()
|
|
|
|
|
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.getByRole('radio', { name: 'Large text' })).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
expect(declaredStyle(screen.getByRole('heading', { level: 1 }).closest('header')!.parentElement!).getPropertyValue('--reading-scale')).toBe(
|
|
|
|
|
'1.25',
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('says so when the size saved is not one MealQuest saved, until one is chosen', async () => {
|
|
|
|
|
localStorage.setItem(TEXT_SIZE_KEY, 'huge')
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.getByText(/Could not read the text size chosen on this device/)).toHaveTextContent('"huge"')
|
|
|
|
|
expect(screen.getByRole('radio', { name: 'Medium text' })).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('radio', { name: 'Small text' }))
|
|
|
|
|
expect(screen.queryByText(/Could not read the text size/)).not.toBeInTheDocument()
|
|
|
|
|
expect(localStorage.getItem(TEXT_SIZE_KEY)).toBe('small')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('source credit', () => {
|
|
|
|
|
it.each([
|
|
|
|
|
['a site and a url', { site_name: 'Serious Eats', url: 'https://www.seriouseats.com/bowls' }, 'Recipe from Serious Eats'],
|
|
|
|
|
['only a url', { site_name: null, url: 'https://www.seriouseats.com/bowls' }, 'Recipe from www.seriouseats.com'],
|
|
|
|
|
])('links the recipe page in a new tab for %s', async (_, source, name) => {
|
|
|
|
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
|
|
|
|
await renderPage()
|
|
|
|
|
const credit = screen.getByRole('link', { name })
|
|
|
|
|
expect(credit).toHaveAttribute('href', 'https://www.seriouseats.com/bowls')
|
|
|
|
|
expect(credit).toHaveAttribute('target', '_blank')
|
|
|
|
|
expect(credit).toHaveAttribute('rel', 'noopener noreferrer')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it.each([
|
|
|
|
|
['only a site', { site_name: 'Serious Eats', url: null }, 'Recipe from Serious Eats'],
|
|
|
|
|
['neither site nor url', { site_name: null, url: null }, 'Recipe from the web'],
|
|
|
|
|
])('is plain text for %s', async (_, source, text) => {
|
|
|
|
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source })) })
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.getByText(text).closest('a')).toBeNull()
|
|
|
|
|
expect(screen.queryByRole('link', { name: /Recipe from/ })).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('is absent for a recipe typed in', async () => {
|
|
|
|
|
serve({ [DINNER]: () => jsonResponse(200, detail({ source: null, grounding_status: 'manual' })) })
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.queryByText(/Recipe from/)).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows what stands in for a recipe not found, with no step buttons', async () => {
|
|
|
|
|
serve({
|
|
|
|
|
[DINNER]: () =>
|
|
|
|
|
jsonResponse(200, detail({ ingredients: [], steps: [], grounding_status: 'failed', grounding_error: 'no_recipe_found' })),
|
|
|
|
|
})
|
|
|
|
|
await renderPage()
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Fix recipe' })).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('region', { name: 'Ingredients' })).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.getByRole('button', { name: 'Add to this week' })).toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe('Keep screen on', () => {
|
|
|
|
|
it('holds a screen wake lock while on and releases it when turned off', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock()
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
expect(keepOn).toBeEnabled()
|
|
|
|
|
expect(wakeLock.request).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
|
|
|
|
expect(wakeLock.sentinels[0]!.release).not.toHaveBeenCalled()
|
|
|
|
|
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
// On again: a fresh lock.
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
|
|
|
|
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('releases the lock when the screen is left', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock()
|
|
|
|
|
serve()
|
|
|
|
|
const view = await renderPage()
|
|
|
|
|
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('releases a lock granted only after the screen was left', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock({ hold: true })
|
|
|
|
|
serve()
|
|
|
|
|
const view = await renderPage()
|
|
|
|
|
fireEvent.click(screen.getByRole('switch', { name: 'Keep screen on' }))
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
view.unmount()
|
|
|
|
|
await act(async () => wakeLock.grant())
|
|
|
|
|
expect(wakeLock.sentinels[0]!.release).toHaveBeenCalledTimes(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('asks for the lock again when the page is visible again while on, and not while off', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock()
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
|
|
|
|
|
|
|
|
|
// Hidden, the browser lets the lock go; nothing is asked while hidden.
|
|
|
|
|
act(() => setVisibility('hidden'))
|
|
|
|
|
act(() => letGo(wakeLock.sentinels[0]!))
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
act(() => setVisibility('visible'))
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
|
|
|
|
|
// Visible again with the lock still held: no second lock.
|
|
|
|
|
act(() => setVisibility('visible'))
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
act(() => setVisibility('hidden'))
|
|
|
|
|
act(() => setVisibility('visible'))
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('asks for the lock again when the browser lets it go while the page is visible', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock()
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(1))
|
|
|
|
|
|
|
|
|
|
act(() => letGo(wakeLock.sentinels[0]!))
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
|
|
|
|
await act(async () => {})
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
|
|
|
|
|
// The fresh lock is watched too.
|
|
|
|
|
act(() => letGo(wakeLock.sentinels[1]!))
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(3))
|
|
|
|
|
expect(wakeLock.sentinels[2]!.released).toBe(false)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('asks for the lock again when the page was hidden and shown while a request was pending', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock({ hold: true })
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
|
|
|
|
|
|
|
|
|
// Hidden and shown again before the first lock is granted, which the
|
|
|
|
|
// browser has let go by the time it is.
|
|
|
|
|
act(() => setVisibility('hidden'))
|
|
|
|
|
act(() => setVisibility('visible'))
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(1)
|
|
|
|
|
letGo(wakeLock.sentinels[0]!)
|
|
|
|
|
await act(async () => wakeLock.grant())
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
|
|
|
|
|
|
|
|
|
await act(async () => wakeLock.grant())
|
|
|
|
|
expect(wakeLock.sentinels[1]!.released).toBe(false)
|
|
|
|
|
expect(wakeLock.request).toHaveBeenCalledTimes(2)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('reports a lock the browser refuses and turns the switch back off', async () => {
|
|
|
|
|
const wakeLock = stubWakeLock()
|
|
|
|
|
wakeLock.request.mockRejectedValueOnce(new DOMException('Blocked by permissions policy', 'NotAllowedError'))
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
|
|
|
|
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
|
|
|
|
"Couldn't keep the screen on: NotAllowedError: Blocked by permissions policy",
|
|
|
|
|
)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
|
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'true')
|
|
|
|
|
await waitFor(() => expect(wakeLock.request).toHaveBeenCalledTimes(2))
|
|
|
|
|
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('is disabled, and says why, in a browser that cannot keep the screen on', async () => {
|
|
|
|
|
serve()
|
|
|
|
|
await renderPage()
|
|
|
|
|
const keepOn = screen.getByRole('switch', { name: 'Keep screen on' })
|
|
|
|
|
expect(keepOn).toBeDisabled()
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
expect(keepOn).toHaveAccessibleDescription(
|
|
|
|
|
"This browser can't keep the screen on, so it may dim while you cook.",
|
|
|
|
|
)
|
|
|
|
|
fireEvent.click(keepOn)
|
|
|
|
|
expect(keepOn).toHaveAttribute('aria-checked', 'false')
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|