2026-10-03 18:40:34 -04:00
|
|
|
import { render, screen, within } from '@testing-library/react'
|
|
|
|
|
import { MemoryRouter } from 'react-router'
|
2026-10-06 09:50:17 -04:00
|
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
2026-10-04 08:46:40 -04:00
|
|
|
import { AppRoutes } from '../App.tsx'
|
2026-10-06 09:50:17 -04:00
|
|
|
import { ApiProviders, SETTINGS_API } from '../test/ApiProviders.tsx'
|
2026-10-03 18:40:34 -04:00
|
|
|
import { declaredStyle, outerSize } from '../test/cssRules.ts'
|
|
|
|
|
import { expectMinHitArea } from '../test/hitArea.ts'
|
2026-10-06 09:50:17 -04:00
|
|
|
import { stubFetchByPath } from '../test/fetchStub.ts'
|
2026-10-03 18:40:34 -04:00
|
|
|
import { mockViewport } from '../test/matchMedia.ts'
|
|
|
|
|
import bottomBarStyles from './BottomBar.module.css'
|
|
|
|
|
import railStyles from './Rail.module.css'
|
|
|
|
|
import sidebarStyles from './Sidebar.module.css'
|
|
|
|
|
|
|
|
|
|
const ORDER = ['Home', 'Plan', 'Discover', 'Quick', 'Settings']
|
|
|
|
|
const MOBILE = 390
|
|
|
|
|
const TABLET = 1180
|
|
|
|
|
const DESKTOP = 1440
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
Reflect.deleteProperty(window, 'matchMedia')
|
2026-10-06 09:50:17 -04:00
|
|
|
vi.unstubAllGlobals()
|
2026-10-03 18:40:34 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function renderAt(path: string, width: number) {
|
|
|
|
|
const resize = mockViewport(width)
|
2026-10-06 09:50:17 -04:00
|
|
|
stubFetchByPath(SETTINGS_API)
|
2026-10-03 18:40:34 -04:00
|
|
|
render(
|
|
|
|
|
<MemoryRouter initialEntries={[path]}>
|
2026-10-06 09:50:17 -04:00
|
|
|
<ApiProviders>
|
|
|
|
|
<AppRoutes />
|
|
|
|
|
</ApiProviders>
|
2026-10-03 18:40:34 -04:00
|
|
|
</MemoryRouter>,
|
|
|
|
|
)
|
|
|
|
|
return resize
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nav() {
|
|
|
|
|
return screen.getByRole('navigation', { name: 'Primary' })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function navLinks() {
|
|
|
|
|
return within(nav()).getAllByRole('link')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function current() {
|
|
|
|
|
return navLinks()
|
|
|
|
|
.filter((link) => link.getAttribute('aria-current') === 'page')
|
|
|
|
|
.map((link) => link.textContent)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('AppShell', () => {
|
|
|
|
|
it.each([MOBILE, TABLET, DESKTOP])('orders the nav Home · Plan · Discover · Quick · Settings at %i px', (width) => {
|
|
|
|
|
renderAt('/', width)
|
|
|
|
|
expect(navLinks().map((link) => link.textContent)).toEqual(ORDER)
|
|
|
|
|
expect(navLinks().map((link) => link.getAttribute('href'))).toEqual([
|
|
|
|
|
'/',
|
|
|
|
|
'/plan',
|
|
|
|
|
'/discover',
|
|
|
|
|
'/quick',
|
|
|
|
|
'/settings',
|
|
|
|
|
])
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('shows only the mobile bottom bar below 768 px', () => {
|
|
|
|
|
renderAt('/', 767)
|
|
|
|
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
|
|
|
|
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
|
|
|
|
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const bar = declaredStyle(nav())
|
|
|
|
|
expect(bar.position).toBe('absolute')
|
|
|
|
|
expect(bar.left).toBe('12px')
|
|
|
|
|
expect(bar.right).toBe('12px')
|
|
|
|
|
expect(bar.bottom).toBe('calc(16px + env(safe-area-inset-bottom))')
|
|
|
|
|
expect(bar.height).toBe('62px')
|
|
|
|
|
expect(outerSize(nav(), 'height')).toBe(62)
|
|
|
|
|
expect(bar.background).toBe('var(--color-text)')
|
|
|
|
|
expect(bar.borderRadius).toBe('var(--radius-pill)')
|
|
|
|
|
})
|
|
|
|
|
|
2026-10-03 18:52:57 -04:00
|
|
|
it('shows the active mobile item as icon and label over the one grape pill, inactive items as neutral icons', () => {
|
2026-10-03 18:40:34 -04:00
|
|
|
renderAt('/discover', MOBILE)
|
2026-10-03 18:52:57 -04:00
|
|
|
const pills = nav().querySelectorAll(`.${bottomBarStyles.pill}`)
|
|
|
|
|
expect(pills).toHaveLength(1)
|
|
|
|
|
const pill = declaredStyle(pills[0]!)
|
|
|
|
|
expect(pill.position).toBe('absolute')
|
|
|
|
|
expect(pill.top).toBe('10px')
|
|
|
|
|
expect(pill.height).toBe('42px')
|
|
|
|
|
expect(pill.background).toBe('var(--color-accent)')
|
|
|
|
|
expect(Number(pill.zIndex)).toBeLessThan(Number(declaredStyle(navLinks()[0]!).zIndex))
|
|
|
|
|
|
2026-10-03 18:40:34 -04:00
|
|
|
const active = screen.getByRole('link', { name: 'Discover' })
|
2026-10-03 18:52:57 -04:00
|
|
|
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
2026-10-03 18:40:34 -04:00
|
|
|
expect(within(active).getByText('Discover')).toHaveClass(bottomBarStyles.label!)
|
|
|
|
|
expect(declaredStyle(within(active).getByText('Discover')).fontSize).toBe('13px')
|
|
|
|
|
|
|
|
|
|
const inactive = screen.getByRole('link', { name: 'Plan' })
|
|
|
|
|
expect(declaredStyle(inactive).color).toBe('var(--color-neutral-400)')
|
|
|
|
|
expect(within(inactive).getByText('Plan')).toHaveClass(bottomBarStyles.hiddenLabel!)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('makes the full 62 px bar height the mobile tap target', () => {
|
|
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
|
|
|
|
expect(nav()).toHaveClass(railStyles.rail!)
|
|
|
|
|
expect(declaredStyle(nav()).width).toBe('96px')
|
|
|
|
|
expect(outerSize(nav(), 'width')).toBe(96)
|
|
|
|
|
expect(declaredStyle(nav()).background).toBe('var(--color-surface)')
|
|
|
|
|
const mark = within(nav()).getByText('M')
|
|
|
|
|
expect(declaredStyle(mark).width).toBe('48px')
|
|
|
|
|
expect(outerSize(mark, 'width')).toBe(48)
|
|
|
|
|
expect(outerSize(mark, 'height')).toBe(48)
|
|
|
|
|
expect(declaredStyle(mark).background).toBe('var(--color-accent)')
|
|
|
|
|
expect(screen.queryByText('MealQuest')).not.toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('Our kitchen')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
for (const link of navLinks()) {
|
|
|
|
|
const pill = link.firstElementChild!
|
|
|
|
|
expect(declaredStyle(pill).width).toBe('56px')
|
|
|
|
|
expect(declaredStyle(pill).height).toBe('36px')
|
|
|
|
|
expect(outerSize(pill, 'width')).toBe(56)
|
|
|
|
|
expect(outerSize(pill, 'height')).toBe(36)
|
|
|
|
|
expect(declaredStyle(link).fontSize).toBe('11px')
|
|
|
|
|
expect(declaredStyle(link).fontWeight).toBe('700')
|
|
|
|
|
expectMinHitArea(link)
|
|
|
|
|
}
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Plan' }).firstElementChild).toHaveClass(railStyles.active!)
|
|
|
|
|
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)
|
|
|
|
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
|
|
|
|
const sidebar = nav().closest('aside')!
|
|
|
|
|
expect(sidebar).toHaveClass(sidebarStyles.sidebar!)
|
|
|
|
|
expect(declaredStyle(sidebar).width).toBe('208px')
|
|
|
|
|
expect(declaredStyle(sidebar).background).toBe('var(--color-surface)')
|
|
|
|
|
expect(declaredStyle(sidebar).padding).toBe('28px 16px')
|
|
|
|
|
// The 16 px side padding sits inside the 208 px, not outside it.
|
|
|
|
|
expect(outerSize(sidebar, 'width')).toBe(208)
|
|
|
|
|
|
|
|
|
|
const wordmark = within(sidebar).getByText('MealQuest')
|
|
|
|
|
expect(declaredStyle(wordmark).fontFamily).toBe('var(--font-heading)')
|
|
|
|
|
expect(declaredStyle(wordmark).fontSize).toBe('28px')
|
|
|
|
|
expect(declaredStyle(wordmark).color).toBe('var(--color-accent)')
|
|
|
|
|
|
|
|
|
|
expect(within(sidebar).getByText('Our kitchen')).toBeInTheDocument()
|
|
|
|
|
expect(within(sidebar).getByText('Shared household')).toBeInTheDocument()
|
|
|
|
|
expect(screen.queryByText('M')).not.toBeInTheDocument()
|
|
|
|
|
|
|
|
|
|
const active = screen.getByRole('link', { name: 'Quick' })
|
|
|
|
|
expect(active).toHaveClass(sidebarStyles.active!)
|
|
|
|
|
expect(declaredStyle(active).background).toBe('var(--color-accent)')
|
|
|
|
|
expect(declaredStyle(active).color).toBe('var(--on-accent)')
|
|
|
|
|
expect(declaredStyle(active).fontSize).toBe('15px')
|
|
|
|
|
expect(screen.getByRole('link', { name: 'Home' })).not.toHaveClass(sidebarStyles.active!)
|
|
|
|
|
// Rows stretch across the sidebar, so only their height needs declaring.
|
|
|
|
|
for (const link of navLinks()) expect(declaredStyle(link).minHeight).toBe('44px')
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('switches layout when the viewport crosses a breakpoint', () => {
|
|
|
|
|
const resize = renderAt('/', MOBILE)
|
|
|
|
|
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
|
|
|
|
resize(TABLET)
|
|
|
|
|
expect(nav()).toHaveClass(railStyles.rail!)
|
|
|
|
|
resize(DESKTOP)
|
|
|
|
|
expect(nav().closest('aside')).toHaveClass(sidebarStyles.sidebar!)
|
|
|
|
|
resize(MOBILE)
|
|
|
|
|
expect(nav()).toHaveClass(bottomBarStyles.bar!)
|
|
|
|
|
expect(screen.getAllByRole('navigation')).toHaveLength(1)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
describe.each([
|
|
|
|
|
['mobile', MOBILE],
|
|
|
|
|
['tablet', TABLET],
|
|
|
|
|
['desktop', DESKTOP],
|
|
|
|
|
])('aria-current (%s)', (_layout, width) => {
|
|
|
|
|
it.each([
|
|
|
|
|
['/', ['Home']],
|
|
|
|
|
['/plan', ['Plan']],
|
|
|
|
|
['/plan/shopping', ['Plan']],
|
|
|
|
|
['/plan/shopping/archive', ['Plan']],
|
|
|
|
|
['/discover', ['Discover']],
|
|
|
|
|
['/quick', ['Quick']],
|
|
|
|
|
['/settings', ['Settings']],
|
|
|
|
|
['/settings/notifications', ['Settings']],
|
|
|
|
|
['/settings/ai', ['Settings']],
|
|
|
|
|
['/settings/household', ['Settings']],
|
|
|
|
|
['/settings/regulars', ['Settings']],
|
|
|
|
|
['/settings/calendar', ['Settings']],
|
|
|
|
|
['/onboarding', []],
|
|
|
|
|
['/cook-from-pantry', []],
|
|
|
|
|
['/dinner/abc123', []],
|
|
|
|
|
])('%s marks %j current', (path, expected) => {
|
|
|
|
|
renderAt(path, width)
|
|
|
|
|
expect(current()).toEqual(expected)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|