Feature 12: Regular dinners and onboarding (#94)
CI / ci (pull_request) Successful in 2m30s
CI / ci (push) Successful in 2m25s

Co-authored-by: dev_agent <dev_agent@cubecraftlabs.com>
This commit was merged in pull request #94.
This commit is contained in:
2026-10-06 21:28:23 -04:00
committed by approver_agent
parent 0c89eef577
commit 2b0e5ff01a
71 changed files with 10392 additions and 192 deletions
+21 -20
View File
@@ -21,7 +21,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function renderAt(path: string, width: number) {
async function renderAt(path: string, width: number) {
const resize = mockViewport(width)
stubFetchByPath(SETTINGS_API)
render(
@@ -31,6 +31,8 @@ function renderAt(path: string, width: number) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
return resize
}
@@ -49,8 +51,8 @@ function current() {
}
describe('AppShell', () => {
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
renderAt('/', width)
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', async (width) => {
await renderAt('/', width)
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
'/',
@@ -61,8 +63,8 @@ describe('AppShell', () => {
])
})
it('shows only the mobile bottom bar below 768 px', () => {
renderAt('/', 767)
it('shows only the mobile bottom bar below 768 px', async () => {
await renderAt('/', 767)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
expect(screen.queryByText('M')).not.toBeInTheDocument()
@@ -80,8 +82,8 @@ describe('AppShell', () => {
expect(bar.borderRadius).toBe('var(--radius-pill)')
})
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
renderAt('/discover', MOBILE)
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', async () => {
await renderAt('/discover', MOBILE)
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
expect(pills).toHaveLength(1)
const pill = declaredStyle(pills[0]!)
@@ -101,24 +103,24 @@ describe('AppShell', () => {
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
})
it('makes the full 62 px bar height the mobile tap target', () => {
renderAt('/', MOBILE)
it('makes the full 62 px bar height the mobile tap target', async () => {
await renderAt('/', MOBILE)
for (const link of navLinks()) {
expect(declaredStyle(link).height).toBe('62px')
expectMinHitArea(link)
}
})
it('reserves at least 100 px below the mobile scroll area', () => {
renderAt('/', MOBILE)
it('reserves at least 100 px below the mobile scroll area', async () => {
await renderAt('/', MOBILE)
const padding = declaredStyle(screen.getByRole('main')).paddingBottom
const reserved = /^calc\((\d+)px \+ env\(safe-area-inset-bottom\)\)$/.exec(padding)
expect(reserved, `padding-bottom ${JSON.stringify(padding)}`).not.toBeNull()
expect(Number(reserved![1])).toBeGreaterThanOrEqual(100)
})
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', (width) => {
renderAt('/plan', width)
it.each([768, TABLET, 1199])('shows only the tablet rail with the "M" mark at %i px', async (width) => {
await renderAt('/plan', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
expect(nav()).toHaveClass(railStyles.rail!)
expect(declaredStyle(nav()).width).toBe('96px')
@@ -146,8 +148,8 @@ describe('AppShell', () => {
expect(screen.getByRole('link', { name: 'Home' }).firstElementChild).not.toHaveClass(railStyles.active!)
})
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', (width) => {
renderAt('/quick', width)
it.each([1200, DESKTOP])('shows only the desktop sidebar with wordmark and household card at %i px', async (width) => {
await renderAt('/quick', width)
expect(screen.getAllByRole('navigation')).toHaveLength(1)
const sidebar = nav().closest('aside')!
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
@@ -176,8 +178,8 @@ describe('AppShell', () => {
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
})
it('switches layout when the viewport crosses a breakpoint', () => {
const resize = renderAt('/', MOBILE)
it('switches layout when the viewport crosses a breakpoint', async () => {
const resize = await renderAt('/', MOBILE)
expect(nav()).toHaveClass(bottomBarStyles.bar!)
resize(TABLET)
expect(nav()).toHaveClass(railStyles.rail!)
@@ -206,11 +208,10 @@ describe('AppShell', () => {
['/settings/household', ['Settings']],
['/settings/regulars', ['Settings']],
['/settings/calendar', ['Settings']],
['/onboarding', []],
['/cook-from-pantry', []],
['/dinner/abc123', []],
])('%s marks %j current', (path, expected) => {
renderAt(path, width)
])('%s marks %j current', async (path, expected) => {
await renderAt(path, width)
expect(current()).toEqual(expected)
})
})
+26 -24
View File
@@ -70,7 +70,7 @@ afterEach(() => {
vi.unstubAllGlobals()
})
function renderAt(path: string, reducedMotion = false) {
async function renderAt(path: string, reducedMotion = false) {
mockViewport(MOBILE, { reducedMotion })
stubFetchByPath(SETTINGS_API)
render(
@@ -80,6 +80,8 @@ function renderAt(path: string, reducedMotion = false) {
</ApiProviders>
</InDataRouter>,
)
// The routes wait for the household settings (OnboardingGate).
await screen.findByRole('navigation', { name: 'Primary' })
}
function nav() {
@@ -109,21 +111,21 @@ function animationsOf(element: Element) {
}
describe('BottomBar', () => {
it('places the pill on the active item without animating on first render', () => {
renderAt('/plan')
it('places the pill on the active item without animating on first render', async () => {
await renderAt('/plan')
expect(pill().style.left).toBe('54px')
expect(pill().style.width).toBe('110px')
expect(pill()).toBeVisible()
expect(animate).not.toHaveBeenCalled()
})
it('hides the pill when no item is active', () => {
renderAt('/onboarding')
it('hides the pill when no item is active', async () => {
await renderAt('/cook-from-pantry')
expect(pill()).not.toBeVisible()
})
it('slides the pill from the old item to the new one over 420 ms on the spring curve', () => {
renderAt('/')
it('slides the pill from the old item to the new one over 420 ms on the spring curve', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
expect(animationsOf(pill())).toEqual([
@@ -139,8 +141,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('FLIPs every item that moved from its old left to its new one with the same timing', () => {
renderAt('/')
it('FLIPs every item that moved from its old left to its new one with the same timing', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
// Home shrinks from 110 to 48 px, so Plan and Discover shift 62 px left
@@ -163,8 +165,8 @@ describe('BottomBar', () => {
expect(animationsOf(link('Settings'))).toEqual([])
})
it('fades the new label in over 220 ms ease-out after 120 ms', () => {
renderAt('/')
it('fades the new label in over 220 ms ease-out after 120 ms', async () => {
await renderAt('/')
fireEvent.click(link('Discover'))
const label = within(link('Discover')).getByText('Discover')
@@ -180,14 +182,14 @@ describe('BottomBar', () => {
])
})
it('does not animate when the active item stays the same', () => {
renderAt('/plan')
it('does not animate when the active item stays the same', async () => {
await renderAt('/plan')
fireEvent.click(link('Plan'))
expect(animate).not.toHaveBeenCalled()
})
it('jumps the pill to the new item with no animation under reduced motion', () => {
renderAt('/', true)
it('jumps the pill to the new item with no animation under reduced motion', async () => {
await renderAt('/', true)
fireEvent.click(link('Settings'))
expect(link('Settings')).toHaveAttribute('aria-current', 'page')
@@ -196,8 +198,8 @@ describe('BottomBar', () => {
expect(pill().style.width).toBe('110px')
})
it('re-places the pill when the viewport resizes', () => {
renderAt('/discover')
it('re-places the pill when the viewport resizes', async () => {
await renderAt('/discover')
expect(pill().style.left).toBe('102px')
vi.spyOn(HTMLElement.prototype, 'offsetLeft', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(96)
@@ -207,8 +209,8 @@ describe('BottomBar', () => {
expect(animate).not.toHaveBeenCalled()
})
it('starts a second slide from where the first one is on screen, not from its destination', () => {
renderAt('/')
it('starts a second slide from where the first one is on screen, not from its destination', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const [firstPill] = runningOn(pill())
const firstPlan = runningOn(link('Plan'))[0]!
@@ -247,8 +249,8 @@ describe('BottomBar', () => {
])
})
it('stops a running slide and places the pill on the active item when the viewport resizes', () => {
renderAt('/')
it('stops a running slide and places the pill on the active item when the viewport resizes', async () => {
await renderAt('/')
fireEvent.click(link('Quick'))
const slide = [pill(), link('Plan'), link('Discover'), link('Quick')].flatMap(runningOn)
expect(slide).toHaveLength(4)
@@ -269,7 +271,7 @@ describe('BottomBar', () => {
const ready = new Promise<void>((resolve) => (fontsReady = resolve))
Object.defineProperty(document, 'fonts', { configurable: true, value: { ready } })
try {
renderAt('/discover')
await renderAt('/discover')
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(124)
fontsReady()
await ready
@@ -279,8 +281,8 @@ describe('BottomBar', () => {
}
})
it('crossfades the icon colour with a .2s colour transition', () => {
renderAt('/')
it('crossfades the icon colour with a .2s colour transition', async () => {
await renderAt('/')
for (const item of within(nav()).getAllByRole('link')) {
expect(declaredStyle(item).transition).toBe('color .2s')
}