Merge branch 'feat/v146-ui-browser' into feat/v146-ui

# Conflicts:
#	src/__tests__/baselines/test-source-reads.json
#	src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 14:20:08 +02:00
commit a60111cc9d
29 files changed
+2271 -1012

No files matched your search

+40 -1
View File
@@ -11,6 +11,7 @@ is about choosing and writing tests.
| Pure logic: statistics, time zones, cadence, parsers, unit conversion, formatting | `src/**/__tests__/*.test.ts` (unit) | `pnpm test` |
| Laws of that logic over random inputs | `*.property.test.ts` next to the unit tests | `pnpm test` |
| What a component renders for a given state | `src/**/__tests__/*.test.tsx` (unit) | `pnpm test` |
| How a component behaves when someone types, clicks or presses keys, and what it sends | `src/**/__tests__/*.browser.test.tsx` | `pnpm test` |
| A structural rule about the tree (a guard) | `src/__tests__/` with a stated floor | `pnpm test` |
| A route: auth, scope, validation, what it stores and what it answers | `tests/integration/routes/` with the kit | `pnpm test:integration` |
| Anything whose truth lives in Postgres: constraints, `where` semantics, transactions, locks, cascades, time zones in SQL, backups, jobs | `tests/integration/` | `pnpm test:integration` |
@@ -136,6 +137,43 @@ into a fresh account and reads it back there. A new setting column without a
table entry fails the file. A column with no write route says why in its
entry.
## Browser tests
Interaction belongs in the `browser` project: `*.browser.test.tsx` files run
in headless Chromium through Vitest's Playwright provider, as part of
`pnpm test`. Run them alone with `pnpm test --project browser`, or one file
with `pnpm exec vitest run --project browser <path>`. The Node projects never
collect these files and this project collects nothing else. CI installs the
browser it needs (`playwright install --only-shell chromium`); locally, run
that once.
Reach for it when the claim is about behaviour a markup test cannot see:
focus, keys, an open menu, a pending state, the body a form posts, the URL a
filter writes. A `renderToStaticMarkup` grep stays the tool for what a given
state renders, and bottom-sheet gestures stay in `e2e/`.
The helper is `tests/browser/`:
- `renderWithApp(ui, { locale, user })` mounts the app's own providers: a real
TanStack `QueryClient` (no retries) and `I18nProvider` with the real
catalog. The signed-in account is seeded where `useAuth` reads it, with
`authUser()` defaults under `user`. The app stylesheet and the default
theme class are loaded, so layout, visibility and colour tokens resolve.
- `stubFetch()` replaces `fetch` and nothing above it. Answer a route with
`.on(method, path, { status, data, error, meta })`, hold one with
`.deferred(...)` to assert a pending state, and read the parsed bodies from
`.calls(method, path)`. An unmatched request gets a 404 envelope and is
recorded. Do not mock `@tanstack/react-query` or `@/lib/api/api-fetch`.
- `next/navigation` is aliased to a shim over the page's real `location` and
`history`; `url.ts` sets the starting query, records pushes and replaces,
and simulates Back or an edited address.
- `pinDate(iso)` fakes `Date` only, so timers and animation frames still run.
Every test gets the real `fetch`, clock, address and an empty `localStorage`
back afterwards. Locate by role and accessible name, the way a person finds a
control; a `Label` adds a colon to its name, so match a labelled combobox with
`/^Type/` rather than `"Type"`.
## The integration database
Global setup boots one Postgres container and migrates one template database.
@@ -174,7 +212,8 @@ node scripts/test-durations.mjs unit unit.json
The nightly workflow (`.github/workflows/test-nightly.yml`) uploads a file
regenerated from CI timings, which is the better source.
The unit suite is three projects, decided per file from its source
The unit suite is three Node projects and the `browser` project above. The
Node ones are decided per file from its source
(`tests/unit-projects.ts`): `guards` and `unit` share a module graph within a
worker, `isolated` gives each file a fresh one. A file lands in `isolated` when
it mocks, spies, stubs env or globals, fakes timers or resets modules. If a
+1 -1
View File
@@ -101,7 +101,7 @@ No `text-[9px]`. No off-scale sizes (`text-[28px]`); round to the neighbouring s
| A sheet from the bottom edge (phones) | `<SheetContent side="bottom">` or `ResponsiveSheet`, both of which render `BottomSheetContent` (`ui/bottom-sheet.tsx`): grip, swipe to close, X on the title row. `preview="58dvh"` for a sheet that opens part-way and pulls up to 90 dvh (its footer `data-sheet-footer`, its scroll body `data-sheet-body`); `draggable={false}` only for a sheet that fills the window | a hand-rolled grab handle or drag; a `fixed bottom-0` panel standing in for a sheet |
| Date / time entry | `DateField` / `TimeField` / `DateTimeField` | native `type="date | time | datetime-local"` inputs (browser-locale trap, v1.25.4) |
| Small text action inside a line (Open schedule, Select all, View all, Retry, Learn more) | `InlineAction` (`ui/inline-action.tsx`): tone `primary` / `muted` / `foreground`, size `xs` / `sm`, `asChild` for a `Link`; owns the focus ring and `pointer-coarse:min-h-11` | a hand-rolled underlined `<button>` / `<Link>` (`inline-action-guard.test.ts`) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented.test.tsx` holds the definition to one module) |
| Mutually exclusive choice on one track (range, zoom, cards vs table, recent vs all) | `Segmented` (`ui/segmented.tsx`): no track, the chosen segment filled in primary (the chart tabs' look; a muted track with a raised segment was tried in 1.43.8 and dropped in 1.43.10 as too heavy); `mode="radio"` (default, roving focus) or `"toggle"` (view switches, form sheets), `iconOnly` for icon segments, `size="sm"` for range rows in a chart header; 44 px per segment on a coarse pointer | a row of ghost/secondary Buttons, a fifth segmented variant (`segmented-single-implementation.test.ts` holds the definition to one module) |
| A grade beside a reading (confidence, score gain, band, lab range flag) | `ScorePill` (`ui/score-pill.tsx`): tone `success` / `info` / `warning` / `destructive` / `neutral`, `numeric` for figures; `CONFIDENCE_TONE` maps both confidence scales | a hand-rolled `rounded-full px-2 py-0.5 text-xs bg-*/15` chip (`score-pill-guard.test.ts`); `TagChip` (neutral label) and `Badge` (connection/account state) stay what they are |
| "This vital on these days vs those days" board | `MetricCrosstab` (`insights/metric-crosstab.tsx`): caller formats the numbers, the board owns row, neutral signed delta, confidence chip, dismissal | a fourth copy of the crosstab list |
| A trend / stat tile in a grid (KPI strip, derived tiles, their skeletons and building states) | `TrendTile` (`ui/trend-tile.tsx`) | the hand-spelled `bg-card border-border … p-4 md:p-6` frame |
+4
View File
@@ -100,6 +100,9 @@
"e2e/**/*.test.ts",
"tests/**/*.test.ts",
"tests/**/setup.ts",
// Reached only through the browser project's `next/navigation` alias in
// vitest.config.mts, which no import statement names.
"tests/browser/next-navigation.ts",
"vitest.config.ts",
"vitest.config.integration.ts",
"playwright.config.ts",
@@ -111,6 +114,7 @@
"src/**/*.{ts,tsx}!",
"scripts/**/*.{ts,mjs}!",
"tests/**/*.ts",
"tests/**/*.tsx",
"e2e/**/*.ts",
],
"ignore": [
+3 -1
View File
@@ -121,6 +121,7 @@
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@types/web-push": "^3.6.4",
"@vitest/browser-playwright": "^5.0.5",
"@vitest/coverage-v8": "^5.0.5",
"ajv": "^8.20.0",
"eslint": "^9",
@@ -137,6 +138,7 @@
"testcontainers": "^12.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6",
"vitest": "^5.0.5"
"vitest": "^5.0.5",
"vitest-browser-react": "^2.3.0"
}
}
+148 -7
View File
@@ -241,9 +241,12 @@ importers:
'@types/web-push':
specifier: ^3.6.4
version: 3.6.4
'@vitest/browser-playwright':
specifier: ^5.0.5
version: 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/coverage-v8':
specifier: ^5.0.5
version: 5.0.5(vitest@5.0.5)
version: 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
ajv:
specifier: ^8.20.0
version: 8.20.0
@@ -291,7 +294,10 @@ importers:
version: 6.0.3
vitest:
specifier: ^5.0.5
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
version: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest-browser-react:
specifier: ^2.3.0
version: 2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5)
packages:
@@ -708,6 +714,9 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blazediff/core@1.10.0':
resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==}
'@cfworker/json-schema@4.1.1':
resolution: {integrity: sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==}
@@ -3407,6 +3416,17 @@ packages:
'@visx/vendor@4.0.0-alpha.0':
resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==}
'@vitest/browser-playwright@5.0.5':
resolution: {integrity: sha512-lcdThFUUcMV9mGGV1SUiZXPxfjgW4CssA7o3STxzh8WK0tT2r526kc12tZIBRZfoLMwkhxjLmMXkWXONzdsDQw==}
peerDependencies:
playwright: '*'
vitest: 5.0.5
'@vitest/browser@5.0.5':
resolution: {integrity: sha512-IDMoZX/iZ8rkilw7weaSgN8tJ7iZ9hUv0wYoKXyiyjWjWFwLSWAY707DcVTgxTYOudWC1E3HA9LYn7kVa9a7uQ==}
peerDependencies:
vitest: 5.0.5
'@vitest/coverage-v8@5.0.5':
resolution: {integrity: sha512-4KpkGdIrSqBihx/JyP/e+S+IKB98etg1XDQiLAxksLfcShnwi1VReGeOT9NI43hLHbknosKp/1CCLn9Zgcs8mg==}
peerDependencies:
@@ -3435,9 +3455,20 @@ packages:
vite:
optional: true
'@vitest/pretty-format@5.0.5':
resolution: {integrity: sha512-24sSdIlMA9DVPXnYiB9siwU0bTd887Y9jX3+vabztCSsvHwo7ipvJ/a2L3Rljw8+2rK1f5fCj3h5U9I/Jsqgbw==}
'@vitest/spy@5.0.5':
resolution: {integrity: sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==}
'@vitest/ui@5.0.5':
resolution: {integrity: sha512-dCoGUOdgmCaSB2g+WQ/spPCBkytAWdNUkFSXY4z+eYp2d8JEOY8v0uDRaJTG9qf/kTlTVZ4CuYMYMz/f1HS/qQ==}
peerDependencies:
vitest: 5.0.5
'@vitest/utils@5.0.5':
resolution: {integrity: sha512-/rBu7gzLDQI/SC79Tn98b5DwOgaHU/Gt4I9OVeP3YofzNaYtbImRCOJQmT1S6vOJg9gEIlWC+r3QxKkjuSa1nQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@@ -5981,6 +6012,10 @@ packages:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
pngjs@7.0.0:
resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==}
engines: {node: '>=14.19.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -6529,6 +6564,10 @@ packages:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
sirv@3.0.2:
resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==}
engines: {node: '>=18'}
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
@@ -7065,6 +7104,20 @@ packages:
yaml:
optional: true
vitest-browser-react@2.3.0:
resolution: {integrity: sha512-S5vdGHHA93j2Z6dbmIJI1wtxT3AXPTRlmC1Meo+ekG011yNnEutMAnHrwyj8qFlGhgBYA8ENGK6+o1Q+vnFz6A==}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
vitest: ^4.0.0 || ^5.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
vitest@5.0.5:
resolution: {integrity: sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==}
engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
@@ -7197,6 +7250,18 @@ packages:
utf-8-validate:
optional: true
ws@8.22.0:
resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
wsl-utils@1.0.1:
resolution: {integrity: sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==}
engines: {node: '>=20'}
@@ -7885,6 +7950,8 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blazediff/core@1.10.0': {}
'@cfworker/json-schema@4.1.1':
optional: true
@@ -10055,7 +10122,7 @@ snapshots:
'@stryker-mutator/util': 10.0.0
semver: 7.8.5
tslib: 2.8.1
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@swc/helpers@0.5.23':
dependencies:
@@ -10562,7 +10629,38 @@ snapshots:
d3-time-format: 4.1.0
internmap: 2.0.3
'@vitest/coverage-v8@5.0.5(vitest@5.0.5)':
'@vitest/browser-playwright@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
dependencies:
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
playwright: 1.64.1
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)':
dependencies:
'@blazediff/core': 1.10.0
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/ui': 5.0.5(vitest@5.0.5)
'@vitest/utils': 5.0.5
magic-string: 1.4.3
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
ws: 8.22.0
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/coverage-v8@5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/istanbul-lib-coverage': 1.0.2
@@ -10572,7 +10670,9 @@ snapshots:
obug: 3.0.0
std-env: 4.3.0
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
optionalDependencies:
'@vitest/browser': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/istanbul-lib-coverage@1.0.2': {}
@@ -10590,8 +10690,28 @@ snapshots:
msw: 2.14.6(@types/node@26.6.5)(typescript@6.0.3)
vite: 8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)
'@vitest/pretty-format@5.0.5':
dependencies:
tinyrainbow: 3.2.0
'@vitest/spy@5.0.5': {}
'@vitest/ui@5.0.5(vitest@5.0.5)':
dependencies:
'@vitest/utils': 5.0.5
fflate: 0.8.3
flatted: 3.4.4
pathe: 2.0.3
sirv: 3.0.2
tinyrainbow: 3.2.0
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
'@vitest/utils@5.0.5':
dependencies:
'@vitest/pretty-format': 5.0.5
convert-source-map: 2.0.0
tinyrainbow: 3.2.0
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@@ -13216,6 +13336,8 @@ snapshots:
pngjs@5.0.0: {}
pngjs@7.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss-selector-parser@7.1.6:
@@ -13897,6 +14019,12 @@ snapshots:
mrmime: 2.0.1
totalist: 3.0.1
sirv@3.0.2:
dependencies:
'@polka/url': 1.0.0-next.29
mrmime: 2.0.1
totalist: 3.0.1
sisteransi@1.0.5: {}
smart-buffer@4.2.0: {}
@@ -14483,7 +14611,16 @@ snapshots:
jiti: 2.7.0
yaml: 2.9.1
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/coverage-v8@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
vitest-browser-react@2.3.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(vitest@5.0.5):
dependencies:
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
vitest: 5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
optionalDependencies:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
vitest@5.0.5(@opentelemetry/api@1.9.1)(@types/node@26.6.5)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1)):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))
@@ -14502,7 +14639,9 @@ snapshots:
optionalDependencies:
'@opentelemetry/api': 1.9.1
'@types/node': 26.6.5
'@vitest/coverage-v8': 5.0.5(vitest@5.0.5)
'@vitest/browser-playwright': 5.0.5(msw@2.14.6(@types/node@26.6.5)(typescript@6.0.3))(playwright@1.64.1)(vite@8.1.6(@types/node@26.6.5)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.5)
'@vitest/coverage-v8': 5.0.5(@vitest/browser@5.0.5)(vitest@5.0.5)
'@vitest/ui': 5.0.5(vitest@5.0.5)
transitivePeerDependencies:
- msw
@@ -14625,6 +14764,8 @@ snapshots:
ws@7.5.13: {}
ws@8.22.0: {}
wsl-utils@1.0.1:
dependencies:
is-wsl: 3.1.1
@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 359,
"total": 359,
"count": 356,
"total": 356,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -212,7 +212,6 @@
"components/layout/__tests__/offline-banner.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 1,
"components/measurements/__tests__/duration-read-as.test.tsx": 1,
"components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
"components/medications/__tests__/inventory-section-dates.test.tsx": 1,
@@ -262,8 +261,6 @@
"components/timeline/__tests__/no-template-key-guard.test.ts": 1,
"components/ui/__tests__/card-density.test.tsx": 1,
"components/ui/__tests__/card-header-rows.test.ts": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/__tests__/segmented.test.tsx": 1,
"hooks/__tests__/use-insight-status.test.ts": 1,
"lib/__tests__/geo-offline-detection.test.ts": 1,
"lib/__tests__/query-keys.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2945,
"total": 10221,
"count": 2943,
"total": 10219,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1119,7 +1119,6 @@
"components/measurement-reminders/__tests__/vorsorge-section.test.tsx": 7,
"components/measurement-reminders/vorsorge-dashboard-card.tsx": 2,
"components/measurement-reminders/vorsorge-settings.tsx": 2,
"components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1,
"components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4,
"components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3,
"components/measurements/__tests__/measurement-list-empty.test.tsx": 1,
@@ -1443,7 +1442,6 @@
"components/timeline/__tests__/timeline-view-keys.test.tsx": 1,
"components/timeline/__tests__/value-options.test.ts": 1,
"components/timeline/life-event-form.lazy.tsx": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/back-link.tsx": 1,
"components/ui/button.tsx": 1,
"components/ui/card.tsx": 1,
@@ -12,6 +12,12 @@
* same suite: every dependency bump failed on a launch error that said nothing
* about the bump. One workflow was fixed, one class was not.
*
* Since v1.46 the suite also carries the `browser` project
* (`*.browser.test.tsx`, see `tests/browser/`), which launches the same
* headless shell through Vitest's Playwright provider for every interaction
* test. A slice without the browser now fails on many files rather than one;
* the install it needs is the same, so this guard covers both.
*
* The sweep is textual and file-scoped, which is its limit: a workflow with two
* jobs, only one of which installs the browser, satisfies it. It catches the
* thing that actually went wrong — a whole workflow running the suite with no
@@ -58,8 +64,8 @@ describe("the browser the unit suite measures with", () => {
expect(
missing,
"a workflow runs the unit suite without installing the headless " +
"Chromium the hero-geometry check measures with — that check refuses " +
"to skip under CI, so the job fails on a launch error instead",
"Chromium the hero-geometry check and the browser project launch — " +
"neither skips under CI, so the job fails on a launch error instead",
).toEqual([]);
});
});
@@ -1,98 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The manual entry form and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
* account picked. Until the preference could be set at all, the form could
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
* plausibility band (20–800) accepts without a word and every surface then
* reads back as real. So the label and the stored number have to move
* together — the field asks in the account's unit and the submit path
* inverts to canonical mg/dL.
*
* The label half is rendered. The submit half is asserted against the source:
* this project runs SSR-only component tests (`@testing-library/react` is not
* a dependency), so nothing here can type into the field and press save. What
* the source assertion pins is the wiring — that the glucose branch inverts
* through `toCanonicalMgdl` rather than sending the typed number on — and
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
*/
let glucoseUnit: string | null = null;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: "metric",
glucoseUnit,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
}
const formSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
"utf8",
);
describe("MeasurementForm — blood-glucose entry unit", () => {
it("asks in mg/dL for an account that never chose", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
expect(html).toContain('placeholder="95"');
});
it("asks in mmol/L once the account chose it", () => {
glucoseUnit = "mmol/L";
const html = render();
expect(html).toContain("mmol/L");
// A mmol/L reader must not be shown a three-digit mg/dL example.
expect(html).toContain('placeholder="5.3"');
expect(html).not.toContain('placeholder="95"');
});
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
expect(formSource).toMatch(
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
);
});
it("resolves the unit from the account rather than assuming one", () => {
expect(formSource).toMatch(
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
);
});
});
@@ -6,45 +6,18 @@
* file a reading into a domain whose pages, charts and reads are all hidden,
* or be refused by the route. The form now filters its types through the
* surface map's type ownership (`summary:<type>`), which the test below holds
* equal to the measurement-scope ownership the server gates on.
* equal to the measurement-scope ownership the server gates on. That the
* mounted form neither opens on nor offers such a type is driven in
* `measurement-form.browser.test.tsx`.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { surfaceModule } from "@/lib/modules/surface";
const auth = vi.hoisted(() => ({
modules: undefined as Record<string, boolean> | undefined,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
role: "USER",
unitPreference: "metric",
glucoseUnit: null,
modules: auth.modules,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MeasurementForm,
measurementFormTypes,
} from "../measurement-form";
@@ -77,17 +50,3 @@ describe("measurementFormTypes", () => {
expect(owned).toBeGreaterThan(0);
});
});
describe("<MeasurementForm> with a type's module off", () => {
it("does not open on that type, even when asked to", () => {
auth.modules = { glucose: false };
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
auth.modules = undefined;
expect(html).not.toContain("mg/dL");
expect(html).toContain("mmHg");
});
});
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: preference,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
describe("MeasurementForm — preferred-unit value label", () => {
it("labels the weight field in kg for a metric user", () => {
preference = "metric";
const html = render();
expect(html).toContain("kg");
expect(html).not.toContain("lb");
// The metric placeholder (kg) is shown, not the imperial one.
expect(html).toContain('placeholder="75.5"');
});
it("labels the weight field in lb for an imperial user", () => {
preference = "imperial";
const html = render();
expect(html).toContain("lb");
// The imperial placeholder (lb) is shown.
expect(html).toContain('placeholder="165"');
});
});
@@ -0,0 +1,309 @@
/**
* `<MeasurementForm>` driven by typing and pressing Save, with the request
* body read off the network boundary. What the markup tests could only pin
* against the source is asserted on the wire here: a weight typed in lb, a
* glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their
* canonical unit.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import type { AuthUser } from "@/hooks/use-auth";
import { pinDate } from "../../../../tests/browser/clock";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { MeasurementForm } from "../measurement-form";
const NOW = "2026-09-15T10:00:00.000Z";
let api: FetchStub;
beforeEach(() => {
pinDate(NOW);
api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} });
});
async function mount(
props: Parameters<typeof MeasurementForm>[0] = {},
user: Partial<AuthUser> = {},
) {
const onSuccess = vi.fn();
const { screen } = await renderWithApp(
<MeasurementForm onSuccess={onSuccess} onCancel={() => {}} {...props} />,
{ user },
);
const save = screen.getByRole("button", { name: "Save" });
return { screen, onSuccess, save };
}
const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body);
describe("<MeasurementForm> blood pressure", () => {
it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("128");
await screen.getByLabelText(/^Diastolic/).fill("84");
await screen.getByLabelText(/^Pulse/).fill("66");
await screen.getByLabelText(/^Notes/).fill("after the stairs");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const at = NOW;
expect(posted()).toEqual([
[
{
type: "BLOOD_PRESSURE_SYS",
value: 128,
measuredAt: at,
notes: "after the stairs",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 84,
measuredAt: at,
notes: "after the stairs",
},
{ type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" },
],
]);
// Only the write: nothing else in the cache needed refetching.
expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
"POST /api/measurements",
]);
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
});
it("leaves the pulse out when it was not typed", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("118");
await screen.getByLabelText(/^Diastolic/).fill("76");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [batch] = posted() as Array<Array<{ type: string }>>;
expect(batch.map((e) => e.type)).toEqual([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
});
it("sends nothing while a required reading is missing", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
const sys = screen
.getByLabelText(/^Systolic/)
.element() as HTMLInputElement;
expect(sys.validity.valueMissing).toBe(true);
expect(api.requests).toEqual([]);
expect(onSuccess).not.toHaveBeenCalled();
});
it("holds Save and the menu while the write is pending", async () => {
const pending = api.deferred("POST", "/api/measurements");
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect.element(save).toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "More options" }))
.toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "Cancel" }))
.toBeDisabled();
expect(onSuccess).not.toHaveBeenCalled();
pending.release({ status: 201, data: {} });
await expect.element(save).toBeEnabled();
expect(onSuccess).toHaveBeenCalledOnce();
});
it("names a refused write in the reader's words and keeps what was typed", async () => {
api.on("POST", "/api/measurements", {
status: 409,
error: "Duplicate measurement",
meta: { errorCode: "measurement.duplicate_timestamp" },
});
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("A measurement already exists for this time.");
await expect
.element(screen.getByLabelText(/^Systolic/))
.toHaveAttribute("aria-invalid", "true");
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120);
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe("<MeasurementForm> units on the way out", () => {
it("asks an imperial reader for lb and stores kg", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "WEIGHT" },
{ unitPreference: "imperial" },
);
const field = screen.getByLabelText("Value (lb)");
await expect.element(field).toHaveAttribute("placeholder", "165");
await field.fill("210");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 95.25, measuredAt: NOW },
]);
});
it("reads a decimal comma for a metric reader", async () => {
const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" });
const field = screen.getByLabelText("Value (kg)");
await expect.element(field).toHaveAttribute("placeholder", "75.5");
await field.fill("75,5");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 75.5, measuredAt: NOW },
]);
});
it("asks in mg/dL for an account that never chose a glucose unit", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_GLUCOSE",
});
const field = screen.getByLabelText("Value (mg/dL)");
await expect.element(field).toHaveAttribute("placeholder", "95");
await field.fill("104");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 104,
measuredAt: NOW,
glucoseContext: "FASTING",
},
]);
});
it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ glucoseUnit: "mmol/L" },
);
const field = screen.getByLabelText("Value (mmol/L)");
await expect.element(field).toHaveAttribute("placeholder", "5.3");
await field.fill("5.5");
await screen.getByRole("combobox", { name: /^Context/ }).click();
await screen
.getByRole("option", { name: "Post-meal (2h after eating)" })
.click();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 99,
measuredAt: NOW,
glucoseContext: "POSTPRANDIAL",
},
]);
});
it("reads a sleep typed as hours and minutes and stores minutes", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "SLEEP_DURATION",
});
await screen.getByLabelText("Value (hours)").fill("7:30");
await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "SLEEP_DURATION", value: 450, measuredAt: NOW },
]);
});
it("refuses a duration it cannot read, under the field", async () => {
const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" });
const field = screen.getByLabelText("Value (hours)");
await field.fill("most of the night");
await save.click();
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.toBeVisible();
await expect.element(field).toHaveAttribute("aria-invalid", "true");
expect(api.requests).toEqual([]);
await field.fill("7.5");
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.not.toBeInTheDocument();
});
});
describe("<MeasurementForm> type", () => {
it("relabels the value field when the type changes", async () => {
const { screen } = await mount({ defaultType: "WEIGHT" });
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Body temperature" }).click();
await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible();
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Blood Pressure" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible();
});
it("does not open on, or offer, a type whose module is off", async () => {
const { screen } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ modules: { glucose: false } },
);
const type = screen.getByRole("combobox", { name: /^Type/ });
await expect.element(type).toHaveTextContent("Blood Pressure");
await type.click();
await expect
.element(screen.getByRole("option", { name: "Weight" }))
.toBeVisible();
await expect
.element(screen.getByRole("option", { name: "Blood glucose" }))
.not.toBeInTheDocument();
await userEvent.keyboard("{Escape}");
});
it("opens on the type saved last when no type is asked for", async () => {
const first = await mount({ defaultType: "WEIGHT" });
await first.screen.getByLabelText("Value (kg)").fill("80");
await first.save.click();
await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1);
first.screen.unmount();
const { screen } = await mount();
await expect
.element(screen.getByRole("combobox", { name: /^Type/ }))
.toHaveTextContent("Weight");
});
it("clears every field from the menu's Reset", async () => {
const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" });
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Notes/).fill("left arm");
await screen.getByRole("button", { name: "More options" }).click();
await screen.getByRole("menuitem", { name: "Reset form" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue("");
});
});
@@ -0,0 +1,331 @@
/**
* The questionnaire check-in, driven by clicks and keys in a real browser.
*
* The step arithmetic is pinned on its own in `check-in-nav.test.ts`; this
* suite proves the wizard a person uses: one question at a time in the source
* order, Back keeping the answer, the number keys, the review with a way back
* to every answer, the optional PHQ-9 follow-up, the note over an unvalidated
* translation staying on screen with every question, and the body the page
* posts. The questionnaire text loads through the production chunk loader,
* not a seeded cache.
*/
import { describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { WORDING } from "@/lib/mental-health/wording";
import type { Locale } from "@/lib/i18n/config";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { CheckInWizard } from "../check-in-wizard";
import { MentalWellbeing } from "../mental-wellbeing";
import type { CheckInAnswers, InstrumentId } from "../types";
const WHO5 = WORDING.WHO5.en;
const PHQ9 = WORDING.PHQ9.en;
async function wizard(
instrument: InstrumentId,
{
locale = "en",
isError = false,
}: { locale?: Locale; isError?: boolean } = {},
) {
const onSubmit = vi.fn<(answers: CheckInAnswers) => void>();
const onBack = vi.fn();
const { screen } = await renderWithApp(
<CheckInWizard
instrument={instrument}
onSubmit={onSubmit}
onBack={onBack}
isPending={false}
isError={isError}
/>,
{ locale },
);
const heading = screen.getByRole("heading", { level: 2 });
/** The step on screen asks `text` (waiting out the language's chunk). */
const expectQuestion = (text: string) =>
expect.element(heading).toHaveTextContent(text);
const answer = (name: string) =>
screen.getByRole("radio", { name, exact: true });
const button = (name: string) =>
screen.getByRole("button", { name, exact: true });
return { screen, onSubmit, onBack, expectQuestion, answer, button };
}
/**
* Answer the current step by its position, as a keyboard user does: each new
* step focuses its question, so Tab first reaches the answers, then the
* number key chooses.
*/
async function pressAnswer(position: number) {
await userEvent.tab();
await userEvent.keyboard(String(position));
}
describe("<CheckInWizard> steps", () => {
it("opens on the first question with the stem and the answers in source order", async () => {
const { screen, button } = await wizard("WHO5");
await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
await expect.element(screen.getByText(WHO5.stems.period)).toBeVisible();
await expect.element(screen.getByText("Question 1 of 5")).toBeVisible();
const labels = screen
.getByRole("radio")
.elements()
.map((el) => el.textContent);
expect(labels).toEqual([5, 4, 3, 2, 1, 0].map((v) => WHO5.anchors.all[v]));
await expect.element(button("Back")).toBeDisabled();
await expect.element(button("Next")).toBeDisabled();
await expect.element(button("See my result")).not.toBeInTheDocument();
// The instrument's own explanation heads the check-in; the landing's
// self-test disclaimer does not follow the person into it.
await expect
.element(
screen.getByText(
"5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
),
)
.toBeVisible();
await expect
.element(screen.getByText(/not a diagnosis/i))
.not.toBeInTheDocument();
// English is the validated wording: no note, no switch.
await expect
.element(screen.getByText(/has not been scientifically validated/))
.not.toBeInTheDocument();
});
it("gives each SCI item its own answers under its section stem", async () => {
const SCI = WORDING.SCI.en;
const { screen, expectQuestion } = await wizard("SCI");
await expectQuestion(SCI.items[0]);
await expect.element(screen.getByText(SCI.stems.night)).toBeVisible();
await expect.element(screen.getByText("Question 1 of 8")).toBeVisible();
expect(
screen
.getByRole("radio")
.elements()
.map((el) => el.textContent),
).toEqual([4, 3, 2, 1, 0].map((v) => SCI.anchors.duration[v]));
});
it("advances on a choice, and Back returns with the answer still chosen", async () => {
const { screen, answer, button, expectQuestion } = await wizard("WHO5");
await answer(WHO5.anchors.all[3]).click();
await expect.element(screen.getByText("Question 2 of 5")).toBeVisible();
await expectQuestion(WHO5.items[1]);
await expect.element(button("Next")).toBeDisabled();
await button("Back").click();
await expectQuestion(WHO5.items[0]);
await expect.element(answer(WHO5.anchors.all[3])).toBeChecked();
await button("Next").click();
await expectQuestion(WHO5.items[1]);
});
it("chooses by number key, moves with the arrows and focuses each new question", async () => {
const { screen, answer } = await wizard("WHO5");
await answer(WHO5.anchors.all[5]).click();
await expect
.element(screen.getByRole("heading", { name: WHO5.items[1] }))
.toHaveFocus();
await userEvent.tab();
await expect.element(answer(WHO5.anchors.all[5])).toHaveFocus();
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
await expect.element(answer(WHO5.anchors.all[3])).toHaveFocus();
await userEvent.keyboard("{End}");
await expect.element(answer(WHO5.anchors.all[0])).toHaveFocus();
// "2" is the second answer as printed, which scores 4 on the WHO-5.
await userEvent.keyboard("2");
await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
await screen.getByRole("button", { name: "Back", exact: true }).click();
await expect.element(answer(WHO5.anchors.all[4])).toBeChecked();
});
it("reviews every answer, changes one and returns to the review", async () => {
const { screen, onSubmit, button } = await wizard("WHO5");
for (const position of [1, 2, 3, 4, 5]) await pressAnswer(position);
await expect
.element(screen.getByRole("heading", { name: "Check your answers" }))
.toBeVisible();
await expect
.element(screen.getByText("All 5 questions answered"))
.toBeVisible();
await screen
.getByRole("button", { name: "Change the answer to question 3" })
.click();
await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
await screen
.getByRole("radio", { name: WHO5.anchors.all[0], exact: true })
.click();
await expect
.element(screen.getByRole("heading", { name: "Check your answers" }))
.toBeVisible();
await button("See my result").click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
items: [5, 4, 0, 2, 1],
functionalDifficulty: undefined,
englishOriginal: false,
});
});
it("asks the PHQ-9 follow-up as an optional step that a second press clears", async () => {
const { screen, onSubmit, answer, button, expectQuestion } =
await wizard("PHQ9");
for (let i = 0; i < 9; i++) await pressAnswer(1);
await expect
.element(screen.getByText("Follow-up question (optional)"))
.toBeVisible();
await expectQuestion(PHQ9.functional!.title);
await expect.element(button("Review answers")).toBeEnabled();
await answer(PHQ9.functional!.anchors[2]).click();
await expect.element(answer(PHQ9.functional!.anchors[2])).toBeChecked();
await answer(PHQ9.functional!.anchors[2]).click();
await expect.element(answer(PHQ9.functional!.anchors[2])).not.toBeChecked();
await button("Review answers").click();
await expect.element(screen.getByText("Not answered")).toBeVisible();
await button("See my result").click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
items: [0, 0, 0, 0, 0, 0, 0, 0, 0],
functionalDifficulty: undefined,
englishOriginal: false,
});
});
it("returns to the overview and shows a failed submit", async () => {
const { screen, onBack } = await wizard("GAD7", { isError: true });
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("Something went wrong. Please try again.");
await screen.getByRole("button", { name: "Overview" }).click();
expect(onBack).toHaveBeenCalledOnce();
});
});
describe("<CheckInWizard> in the reader's language", () => {
const SCI_DE = WORDING.SCI.de;
const NOTE =
"Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.";
it("keeps the note over an unvalidated translation on screen with every question", async () => {
await page.viewport(390, 640);
const { screen, expectQuestion } = await wizard("SCI", { locale: "de" });
expect(SCI_DE.validated).toBe(false);
const note = screen.getByText(NOTE);
for (let i = 0; i < 8; i++) {
await expectQuestion(SCI_DE.items[i]);
await expect.element(note).toBeVisible();
const rect = note.element().getBoundingClientRect();
expect(rect.top, `step ${i + 1}`).toBeGreaterThanOrEqual(0);
expect(rect.bottom, `step ${i + 1}`).toBeLessThanOrEqual(
window.innerHeight,
);
// The first question arrives with the language's chunk, after the
// focus move has run, so it is answered by pointer; the rest by key.
if (i === 0) await screen.getByRole("radio").first().click();
else await pressAnswer(1);
}
await expect
.element(screen.getByRole("heading", { name: "Antworten prüfen" }))
.toBeVisible();
await expect.element(note).toBeVisible();
});
it("switches to the English original and says so in the answers", async () => {
const { screen, onSubmit, expectQuestion } = await wizard("SCI", {
locale: "de",
});
await expectQuestion(SCI_DE.items[0]);
await screen
.getByRole("button", { name: "Englischen Originaltext zeigen" })
.click();
await expect
.element(screen.getByRole("heading", { name: WORDING.SCI.en.items[0] }))
.toHaveAttribute("lang", "en");
// The note stays: the reader is still in a language with no validated form.
await expect.element(screen.getByText(NOTE)).toBeVisible();
await screen.getByRole("radio").first().click();
for (let i = 1; i < 8; i++) await pressAnswer(1);
await screen
.getByRole("button", { name: "Ergebnis ansehen", exact: true })
.click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ englishOriginal: true }),
);
});
it("shows a validated translation with no note and no switch", async () => {
const { screen, expectQuestion } = await wizard("PHQ9", { locale: "nl" });
await expectQuestion(WORDING.PHQ9.nl.items[0]);
await expect
.element(screen.getByText(WORDING.PHQ9.nl.stems.period))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /English/ }))
.not.toBeInTheDocument();
});
});
describe("the check-in from the overview", () => {
it("posts the answers with the instrument and the reader's language, then shows the result", async () => {
const api = stubFetch().on("GET", "/api/mental-health/assessments", {
data: { assessments: [] },
});
const post = api.deferred("POST", "/api/mental-health/assessments");
const { screen } = await renderWithApp(<MentalWellbeing />);
const start =
'[data-instrument="WHO5"] [data-slot="instrument-card-start"]';
await expect.poll(() => document.querySelector(start)).toBeTruthy();
await page.elementLocator(document.querySelector(start)!).click();
await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
await screen.getByRole("radio").nth(1).click();
for (const position of [2, 3, 1, 2]) await pressAnswer(position);
const submit = screen.getByRole("button", { name: "See my result" });
await submit.click();
await expect
.element(screen.getByRole("button", { name: "Saving…" }))
.toBeDisabled();
expect(api.calls("POST", "/api/mental-health/assessments")).toHaveLength(1);
expect(api.calls("POST", "/api/mental-health/assessments")[0].body).toEqual(
{
instrument: "WHO5",
locale: "en",
items: [4, 4, 3, 5, 4],
englishOriginal: false,
},
);
post.release({
status: 201,
data: {
assessment: {
id: "a-1",
instrument: "WHO5",
locale: "en",
wording: "validated",
totalScore: 80,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-09-15T10:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
},
});
await expect
.element(screen.getByText("80", { exact: true }).first())
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "See my result" }))
.not.toBeInTheDocument();
});
});
@@ -6,15 +6,14 @@ import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
import nl from "../../../../messages/nl.json";
import { WORDING } from "@/lib/mental-health/wording";
import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording";
/**
* Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention
* (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`)
* means clicks can't be driven here — the wizard's advance/back grammar is
* pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
* contracts: the disclaimer renders ONLY on the landing (never while testing),
* Mental-wellbeing surface redesign (v1.25.3). The check-in wizard itself is
* driven in a real browser by `check-in-wizard.browser.test.tsx` (steps,
* keys, review, follow-up, the unvalidated-translation note, the posted
* body); its step arithmetic is pinned in `check-in-nav.test.ts`. These tests
* pin the rendered contracts of the rest of the surface: the disclaimer renders ONLY on the landing (never while testing),
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
* intro + instrument cards only — the dated history + trend chart live in the
* per-instrument detail a card click opens, pinned to one instrument.
@@ -74,52 +73,6 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => {
// …and the lead description still introduces the screeners.
expect(html).toContain(mh.pageDescription);
});
it("does NOT render any disclaimer inside the check-in wizard", () => {
const html = withProviders(
<CheckInWizard
instrument="PHQ9"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).not.toContain('data-slot="mental-health-disclaimer"');
// …but the standardized instrument explanation IS shown.
expect(html).toContain(mh.instrumentDescription.phq9);
});
});
describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => {
const html = withProviders(
<CheckInWizard
instrument="PHQ9"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
it("no longer renders the question-overview strip", () => {
expect(html).not.toContain('data-slot="wizard-stepper"');
});
it("no longer renders the review recap or the functional follow-up", () => {
expect(html).not.toContain("You have answered");
expect(html).not.toContain('data-slot="check-in-submit"');
});
it("opens on the first question with the Back/Next footer", () => {
expect(html).toContain(WORDING.PHQ9.en.items[0]);
expect(html).toContain('data-slot="check-in-back"');
expect(html).toContain('data-slot="check-in-next"');
// Back is disabled on the first step.
expect(html).toMatch(
/data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/,
);
});
});
describe("item-9 crisis surfacing on the result", () => {
@@ -243,49 +196,6 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
expect(html).not.toContain("Rhythmus");
});
it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
const html = withProviders(
<CheckInWizard
instrument="WHO5"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(WORDING.WHO5.en.items[0]);
expect(html).toContain('data-slot="check-in-stem"');
expect(html).toContain(WORDING.WHO5.en.stems.period);
// Six anchors, 5 → 0 (the WHO form leads with "All of the time").
for (const v of [5, 4, 3, 2, 1, 0]) {
expect(html).toContain(WORDING.WHO5.en.anchors.all[v]);
}
// English items are the validated wording for en — no translation note.
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
expect(html).not.toContain('data-slot="check-in-wording-toggle"');
// No functional follow-up outside the PHQ-9: 5 steps total.
expect(html).toContain("Question 1 of 5");
});
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
const html = withProviders(
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(WORDING.SCI.en.items[0]);
expect(html).toContain(WORDING.SCI.en.stems.night);
// Item 1 carries the sleep-latency time ranges, 4 → 0.
for (const v of [4, 3, 2, 1, 0]) {
expect(html).toContain(WORDING.SCI.en.anchors.duration[v]);
}
expect(html).toContain("Question 1 of 8");
});
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
const result: CreateResponse = {
assessment: {
@@ -530,23 +440,20 @@ describe("questionnaires in the reader's language", () => {
);
}
function wizard(instrument: "PHQ9" | "SCI") {
return (
<CheckInWizard
instrument={instrument}
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>
);
}
// Kept here rather than in the browser suite: the frame before the
// language's chunk arrives is the one a server render always paints, and a
// browser loads the chunk before a test can look.
it("holds the step's place while the language's text loads", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="nl" initialMessages={nl}>
{wizard("SCI")}
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>
</I18nProvider>
</QueryClientProvider>,
);
@@ -555,30 +462,6 @@ describe("questionnaires in the reader's language", () => {
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
});
it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => {
const html = renderIn("nl", wizard("PHQ9"));
const dutch = WORDING.PHQ9.nl;
expect(html).toContain(dutch.stems.period);
expect(html).toContain(dutch.items[0]);
for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]);
expect(html).not.toContain(WORDING.PHQ9.en.items[0]);
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
expect(html).not.toContain('data-slot="check-in-wording-toggle"');
});
it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => {
const html = renderIn("nl", wizard("SCI"));
const dutch = WORDING.SCI.nl;
expect(dutch.validated).toBe(false);
expect(html).toContain(dutch.stems.night);
expect(html).toContain(dutch.items[0]);
expect(html).not.toContain(WORDING.SCI.en.items[0]);
expect(html).toContain('data-slot="check-in-unvalidated-note"');
expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote);
expect(html).toContain('data-slot="check-in-wording-toggle"');
expect(html).toContain(nl.mentalHealth.wording.showOriginal);
});
it("the result and the history mark an unvalidated translation, and only that", () => {
const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({
id: `s-${wording}`,
@@ -1,166 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
/**
* v1.16.1 — unified filter rail. SSR-markup guards for the shared
* grammar: pill = trigger + active chip in one element, reset only
* while filtered, count slot, per-pill clear button with a translated
* aria-label.
*/
const options = [
{ value: "WEIGHT", label: "Weight" },
{ value: "PULSE", label: "Pulse" },
];
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("FilterBarSelect", () => {
it("inactive: shows only the dimension name, no clear button", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarSelect
label="Type"
value="ALL"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-slot="filter-bar-pill"');
expect(html).toContain('data-active="false"');
expect(html).toContain("Type");
expect(html).not.toContain("Clear filter");
});
it("active: shows `label: value`, active styling and a labelled clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Type"
value="WEIGHT"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
expect(html).toContain("Weight");
expect(html).toContain('aria-label="Clear filter: Type"');
});
});
describe("FilterBarDateRange", () => {
it("inactive: pill shows the dimension name only", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from=""
to=""
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="false"');
expect(html).toContain("Date range");
});
it("active: pill shows the formatted bounds and a clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from="2026-05-12"
to="2026-06-10"
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
// en dateShort: "05/12" / "06/10" — assert both day fragments are in.
expect(html).toMatch(/05\/12/);
expect(html).toMatch(/06\/10/);
expect(html).toContain('aria-label="Clear filter: Date range"');
});
});
describe("FilterBar shell", () => {
it("shows the reset action only while filtered", () => {
const inactive = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<span />
</FilterBar>,
);
const active = render(
<FilterBar isFiltered onReset={() => {}}>
<span />
</FilterBar>,
);
expect(inactive).not.toContain("Reset");
expect(active).toContain("Reset");
});
it("renders the result count slot", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}} count="42 readings">
<span />
</FilterBar>,
);
expect(html).toContain("42 readings");
expect(html).toContain("tabular-nums");
});
it("localises reset + clear labels in German", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Quelle"
value="MANUAL"
onValueChange={() => {}}
allLabel="Alle Quellen"
options={[{ value: "MANUAL", label: "Manuell" }]}
/>
</FilterBar>,
"de",
);
expect(html).toContain("Zurücksetzen");
expect(html).toContain('aria-label="Filter entfernen: Quelle"');
});
});
describe("FilterBarSelect — menu positioning (source pin)", () => {
// The pill trigger paints its own label spans and mounts no
// `<SelectValue>`. Radix's default item-aligned placement silently
// skips ALL positioning when the value node is absent — the open
// menu then lays out below the height-locked app shell, fully
// off-viewport, which users report as "clicking does nothing". SSR
// markup cannot exercise an open portal, so this pins the source:
// the content must request popper placement, which has no
// value-node dependency.
it("requests popper placement on the select content", () => {
const src = readFileSync(
join(process.cwd(), "src/components/ui/filter-bar.tsx"),
"utf8",
);
expect(src).toMatch(/<SelectContent\s+position="popper"\s+align="start">/);
});
});
@@ -0,0 +1,81 @@
/**
* Segmented is the one segmented control in the tree. Its behaviour (roving
* focus, toggle mode, disabled, the chosen segment's fill) is driven in a real
* browser by `segmented.browser.test.tsx`; this file holds the source sweeps
* that keep a second control from appearing beside it.
*/
import { describe, expect, it } from "vitest";
describe("one segmented control", () => {
it("is the only segmented-control component in the tree", async () => {
const { join } = await import("node:path");
const { readSourceText, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const defining = walkSourceFiles(src, { floor: 2000 })
.filter((p) => !p.includes("__tests__/"))
.filter((p) =>
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
readSourceText(join(src, p)),
),
);
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { join } = await import("node:path");
const { readSourceText, stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) => pressedByValue(stripComments(readSourceText(join(src, p)))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
@@ -0,0 +1,229 @@
import { useState } from "react";
import { LayoutGrid, Table } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { renderWithApp } from "../../../../tests/browser/render";
import { Segmented, type SegmentedProps } from "../segmented";
type Zoom = "day" | "week" | "month";
const OPTIONS = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
] as const;
/**
* The control as a caller holds it: the value lives in the parent and moves
* only when `onChange` says so, which is what makes the roving tab stop
* follow the selection.
*/
function Controlled({
initial = "day",
onChange,
...props
}: Partial<Omit<SegmentedProps<Zoom>, "value">> & { initial?: Zoom }) {
const [value, setValue] = useState<Zoom>(initial);
return (
<>
<button type="button">Before</button>
<Segmented
options={OPTIONS}
label="Zoom"
slot="zoom"
{...props}
value={value}
onChange={(next) => {
onChange?.(next);
setValue(next);
}}
/>
<button type="button">After</button>
</>
);
}
async function mount(props: Parameters<typeof Controlled>[0] = {}) {
const onChange = vi.fn();
const { screen } = await renderWithApp(
<Controlled onChange={onChange} {...props} />,
);
const radio = (name: string) => screen.getByRole("radio", { name });
return { screen, onChange, radio };
}
describe("<Segmented> in radio mode", () => {
it("is one tab stop, on the chosen segment", async () => {
const { screen, radio } = await mount({ initial: "week" });
await expect
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
.toBeInTheDocument();
await expect.element(radio("Week")).toBeChecked();
await expect.element(radio("Day")).not.toBeChecked();
await screen.getByRole("button", { name: "Before" }).click();
await userEvent.tab();
await expect.element(radio("Week")).toHaveFocus();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "After" }))
.toHaveFocus();
});
it("moves focus and selection together with the arrow keys", async () => {
const { onChange, radio } = await mount();
await radio("Day").click();
onChange.mockClear();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio("Week")).toHaveFocus();
await expect.element(radio("Week")).toBeChecked();
await userEvent.keyboard("{ArrowDown}");
await expect.element(radio("Month")).toBeChecked();
await userEvent.keyboard("{ArrowLeft}");
await expect.element(radio("Week")).toHaveFocus();
expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]);
});
it("wraps at both ends", async () => {
const { radio } = await mount({ initial: "month" });
await radio("Month").click();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio("Day")).toHaveFocus();
await expect.element(radio("Day")).toBeChecked();
await userEvent.keyboard("{ArrowUp}");
await expect.element(radio("Month")).toHaveFocus();
await expect.element(radio("Month")).toBeChecked();
});
it("jumps to the first and last segment with Home and End", async () => {
const { onChange, radio } = await mount({ initial: "week" });
await radio("Week").click();
await userEvent.keyboard("{End}");
await expect.element(radio("Month")).toHaveFocus();
await userEvent.keyboard("{Home}");
await expect.element(radio("Day")).toHaveFocus();
await expect.element(radio("Day")).toBeChecked();
expect(onChange).toHaveBeenLastCalledWith("day");
});
it("fires again on the chosen segment, which can open something", async () => {
const { onChange, radio } = await mount({ initial: "day" });
await radio("Day").click();
await radio("Day").click();
expect(onChange.mock.calls).toEqual([["day"], ["day"]]);
});
it("takes no press and no key while disabled", async () => {
const { screen, onChange, radio } = await mount({ disabled: true });
await expect
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
.toHaveAttribute("aria-disabled", "true");
await expect.element(radio("Week")).toBeDisabled();
await radio("Week").click({ force: true });
await screen.getByRole("button", { name: "Before" }).click();
await userEvent.tab();
await userEvent.keyboard("{ArrowRight}{End}");
expect(onChange).not.toHaveBeenCalled();
await expect.element(radio("Day")).toBeChecked();
});
});
describe("<Segmented> in toggle mode", () => {
it("renders pressed buttons and ignores a press on the active one", async () => {
const { screen, onChange } = await mount({ mode: "toggle" });
await expect
.element(screen.getByRole("group", { name: "Zoom" }))
.toBeInTheDocument();
expect(screen.getByRole("radio").elements()).toHaveLength(0);
const day = screen.getByRole("button", { name: "Day" });
const week = screen.getByRole("button", { name: "Week" });
await expect.element(day).toHaveAttribute("aria-pressed", "true");
await day.click();
expect(onChange).not.toHaveBeenCalled();
await week.click();
expect(onChange).toHaveBeenCalledExactlyOnceWith("week");
await expect.element(week).toHaveAttribute("aria-pressed", "true");
await expect.element(day).toHaveAttribute("aria-pressed", "false");
});
it("leaves every segment its own tab stop and the arrows alone", async () => {
const { screen, onChange } = await mount({ mode: "toggle" });
await screen.getByRole("button", { name: "Day" }).click();
await userEvent.keyboard("{ArrowRight}");
expect(onChange).not.toHaveBeenCalled();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "Week" }))
.toHaveFocus();
});
});
describe("<Segmented> look", () => {
it("fills only the chosen segment in the primary colour, on no track", async () => {
const { screen, radio } = await mount({ initial: "week" });
const group = screen.getByRole("radiogroup").element();
const probe = document.createElement("span");
probe.className = "bg-primary";
document.body.append(probe);
const primary = getComputedStyle(probe).backgroundColor;
probe.remove();
const background = (name: string) =>
getComputedStyle(radio(name).element()).backgroundColor;
expect(background("Week")).toBe(primary);
expect(background("Day")).not.toBe(primary);
expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)");
});
it("sets the small size in smaller type and keeps the 36 px floor", async () => {
const { radio } = await mount();
const md = radio("Day").element();
expect(getComputedStyle(md).fontSize).toBe("14px");
expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36);
const { screen } = await renderWithApp(
<Segmented
options={OPTIONS}
value="day"
onChange={() => {}}
label="Range"
size="sm"
/>,
);
const sm = screen
.getByRole("radiogroup", { name: "Range" })
.getByRole("radio", { name: "Day" })
.element();
expect(getComputedStyle(sm).fontSize).toBe("12px");
expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it("names icon-only segments without showing the label", async () => {
const onChange = vi.fn();
const { screen } = await renderWithApp(
<Segmented
mode="toggle"
iconOnly
options={[
{ value: "cards", label: "Cards", icon: LayoutGrid },
{ value: "table", label: "Table", icon: Table },
]}
value="cards"
onChange={onChange}
label="View"
optionSlot={(v) => `medications-view-${v}`}
/>,
);
const table = screen.getByRole("button", { name: "Table" });
await expect.element(table).toHaveAttribute("title", "Table");
await expect
.element(table)
.toHaveAttribute("data-slot", "medications-view-table");
await expect.element(screen.getByText("Cards")).not.toBeInTheDocument();
await table.click();
expect(onChange).toHaveBeenCalledWith("table");
});
});
@@ -1,227 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { LayoutGrid, Table } from "lucide-react";
import { Segmented, type SegmentedProps } from "../segmented";
const OPTIONS = [
{ value: "recent", label: "Last 90 days" },
{ value: "all", label: "All" },
] as const;
type Props = SegmentedProps<"recent" | "all">;
function html(props: Partial<Props> = {}) {
return renderToStaticMarkup(
<Segmented
options={OPTIONS}
value="recent"
onChange={() => {}}
label="History"
slot="history-view"
{...props}
/>,
);
}
/** The segment buttons of a rendered tree, for driving their handlers. */
function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] {
const out: ReactElement<{ onClick: () => void }>[] = [];
const walk = (n: ReactNode) => {
if (Array.isArray(n)) return n.forEach(walk);
if (!isValidElement(n)) return;
const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>;
if (el.type === "button") out.push(el);
walk(el.props.children);
};
walk(node);
return out;
}
vi.mock("@/hooks/use-roving-radio-group", () => ({
useRovingRadioGroup: () => ({
getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }),
}),
}));
describe("<Segmented>", () => {
it("is a radiogroup with one checked radio by default", () => {
const out = html();
expect(out).toContain('role="radiogroup"');
expect(out).toContain('aria-label="History"');
expect(out.match(/role="radio"/g)?.length).toBe(2);
expect(out.match(/aria-checked="true"/g)?.length).toBe(1);
expect(out).toContain('data-slot="history-view-all"');
});
it("gives every segment the 44 px floor on a coarse pointer", () => {
const out = html();
expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2);
expect(out).toContain("min-h-9");
});
it("fills the chosen segment in the primary colour, on no track", () => {
const out = html();
const segments = out.match(/<button[^>]*>/g) ?? [];
expect(segments).toHaveLength(2);
const chosen = segments.filter((b) => b.includes('aria-checked="true"'));
expect(chosen).toHaveLength(1);
expect(chosen[0]).toContain("bg-primary text-primary-foreground");
const other = segments.find((b) => b.includes('aria-checked="false"'));
expect(other).not.toContain("bg-primary");
const group = out.match(/<div[^>]*role="radiogroup"[^>]*>/)?.[0];
expect(group).not.toContain("bg-muted");
});
it("sets a chart header's range row in the small type", () => {
expect(html()).toContain("text-sm");
const small = html({ size: "sm" });
expect(small).toContain("text-xs");
expect(small).not.toContain("text-sm");
expect(small.match(/min-h-11/g)?.length).toBe(2);
});
it("keeps a disabled chosen segment on the readable token pair", () => {
const chosen = html({ disabled: true })
.match(/<button[^>]*>/g)
?.find((b) => b.includes('aria-checked="true"'));
expect(chosen).toContain("disabled:bg-primary-disabled");
expect(chosen).toContain("disabled:opacity-100");
});
it("renders pressed buttons in toggle mode", () => {
const out = html({ mode: "toggle" });
expect(out).toContain('role="group"');
expect(out).not.toContain('role="radio"');
expect(out).toContain('aria-pressed="true"');
expect(out).toContain('aria-pressed="false"');
});
it("names icon-only segments and sizes them square", () => {
const out = renderToStaticMarkup(
<Segmented
mode="toggle"
iconOnly
options={[
{ value: "cards", label: "Cards", icon: LayoutGrid },
{ value: "table", label: "Table", icon: Table },
]}
value="cards"
onChange={() => {}}
label="View"
optionSlot={(v) => `medications-view-${v}`}
/>,
);
expect(out).toContain('aria-label="Cards"');
expect(out).toContain('title="Table"');
expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2);
expect(out).toContain('data-slot="medications-view-table"');
expect(out).not.toContain(">Cards<");
});
it("re-fires the chosen segment in radio mode, which can open something", () => {
const onChange = vi.fn();
const tree = Segmented({
options: OPTIONS,
value: "recent",
onChange,
label: "History",
});
buttons(tree)[0].props.onClick();
expect(onChange).toHaveBeenCalledWith("recent");
});
it("ignores a press on the active segment in toggle mode", () => {
const onChange = vi.fn();
const tree = Segmented({
options: OPTIONS,
value: "recent",
onChange,
label: "History",
mode: "toggle",
});
const [active, other] = buttons(tree);
active.props.onClick();
expect(onChange).not.toHaveBeenCalled();
other.props.onClick();
expect(onChange).toHaveBeenCalledWith("all");
});
});
describe("one segmented control", () => {
it("is the only segmented-control component in the tree", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const defining = walkSourceFiles(src, { floor: 2000 })
.filter((p) => !p.includes("__tests__/"))
.filter((p) =>
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
readFileSync(join(src, p), "utf8"),
),
);
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) =>
pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
@@ -0,0 +1,205 @@
/**
* `<ManualWorkoutForm>`, the quick-entry sheet's workout form, driven by
* picking, typing and pressing Save. The entry rules live in
* `manual-entry.ts` and its own suite; this one pins what the person sees
* and what reaches `POST /api/workouts/batch`: the field errors, the distance
* asked in the reader's unit and sent in metres, the start counted back from
* now, the pending state, and one external id per opened form.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import type { AuthUser } from "@/hooks/use-auth";
import { manualWorkoutOriginalFromRow } from "@/lib/workouts/manual-entry";
import { pinDate } from "../../../../tests/browser/clock";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { ManualWorkoutForm } from "../manual-workout-form";
const NOW = "2026-09-15T10:00:00.000Z";
const BATCH = "/api/workouts/batch";
let api: FetchStub;
beforeEach(() => {
pinDate(NOW);
api = stubFetch().on("POST", BATCH, {
data: { entries: [{ index: 0, status: "inserted" }] },
});
});
async function mount(
props: Parameters<typeof ManualWorkoutForm>[0] = {},
user: Partial<AuthUser> = {},
) {
const onSuccess = vi.fn();
const { screen } = await renderWithApp(
<ManualWorkoutForm onSuccess={onSuccess} onCancel={() => {}} {...props} />,
{ user },
);
const save = screen.getByRole("button", { name: "Save" });
const pickSport = async (name: string) => {
await screen.getByRole("combobox", { name: /^Activity/ }).click();
await screen.getByRole("option", { name }).click();
};
return { screen, onSuccess, save, pickSport };
}
type Posted = { workouts: Array<Record<string, unknown>> };
const posted = () => api.calls("POST", BATCH).map((r) => r.body as Posted);
describe("<ManualWorkoutForm>", () => {
it("names what is missing under each field and sends nothing", async () => {
const { screen, save } = await mount();
await save.click();
await expect.element(screen.getByText("Choose an activity.")).toBeVisible();
await expect
.element(screen.getByText("Enter how long the workout lasted."))
.toBeVisible();
await expect
.element(screen.getByLabelText("Hours"))
.toHaveAttribute("aria-invalid", "true");
expect(api.requests).toEqual([]);
});
it("asks an imperial reader for miles and sends metres, counted back from now", async () => {
const { screen, save, onSuccess, pickSport } = await mount(
{},
{ unitPreference: "imperial" },
);
await pickSport("Running");
await screen.getByLabelText("Hours").fill("1");
await screen.getByLabelText("Minutes").fill("30");
await screen.getByLabelText("Distance (mi)").fill("10");
await screen.getByLabelText("Active energy (kcal)").fill("640");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [body] = posted();
expect(body.workouts).toHaveLength(1);
expect(body.workouts[0]).toEqual({
sportType: "running",
startedAt: "2026-09-15T08:30:00.000Z",
endedAt: NOW,
source: "MANUAL",
externalId: expect.stringMatching(/^manual:/),
totalDistanceM: 16093.4,
totalEnergyKcal: 640,
});
});
it("asks a metric reader for kilometres", async () => {
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Cycling");
await screen.getByLabelText("Minutes").fill("45");
await screen.getByLabelText("Distance (km)").fill("21,5");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()[0].workouts[0]).toMatchObject({
sportType: "cycling",
startedAt: "2026-09-15T09:15:00.000Z",
totalDistanceM: 21500,
});
});
it("holds Save while the write is pending, then reports it once", async () => {
const pending = api.deferred("POST", BATCH);
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Yoga");
await screen.getByLabelText("Minutes").fill("20");
await save.click();
await expect.element(save).toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "Cancel" }))
.toBeDisabled();
pending.release({ data: { entries: [{ index: 0, status: "inserted" }] } });
await expect.element(save).toBeEnabled();
expect(onSuccess).toHaveBeenCalledOnce();
});
it("retries a failed save under the same external id", async () => {
api.on("POST", BATCH, { status: 503, error: "Service unavailable" });
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Running");
await screen.getByLabelText("Minutes").fill("30");
await save.click();
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("Service unavailable");
expect(onSuccess).not.toHaveBeenCalled();
api.on("POST", BATCH, {
data: { entries: [{ index: 0, status: "duplicate" }] },
});
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [first, second] = posted();
expect(second.workouts[0].externalId).toBe(first.workouts[0].externalId);
});
it("lays out for a phone: duration side by side, distance over energy", async () => {
await page.viewport(390, 844);
const { screen, save } = await mount();
const top = (label: string) =>
screen.getByLabelText(label).element().getBoundingClientRect().top;
expect(top("Hours")).toBe(top("Minutes"));
expect(top("Active energy (kcal)")).toBeGreaterThan(top("Distance (km)"));
expect(
save.element().getBoundingClientRect().height,
).toBeGreaterThanOrEqual(44);
// A record, not a training log: no heart rate, sets or route to fill in.
await expect
.element(screen.getByText(/heart|bpm|sets|reps/i))
.not.toBeInTheDocument();
});
it("opens an edit on the stored values and re-posts them unchanged", async () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-14T06:00:00.000Z",
endedAt: "2026-09-14T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "UTC", unitPreference: "metric" },
);
const externalId = "manual:5b0f3c1e-1111-4222-8333-444455556666";
api.on("POST", BATCH, {
data: { entries: [{ index: 0, status: "updated" }] },
});
const { screen, save, onSuccess } = await mount({
edit: { externalId, original },
});
await expect
.element(screen.getByRole("combobox", { name: /^Activity/ }))
.toHaveTextContent("Cycling");
await expect.element(screen.getByLabelText("Hours")).toHaveValue("1");
await expect.element(screen.getByLabelText("Minutes")).toHaveValue("30");
await expect
.element(screen.getByLabelText("Distance (km)"))
.toHaveValue("32");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()[0].workouts[0]).toMatchObject({
externalId,
startedAt: "2026-09-14T06:00:00.000Z",
endedAt: "2026-09-14T07:30:00.000Z",
totalDistanceM: 32000,
totalEnergyKcal: 640,
avgHeartRate: 131,
});
});
});
@@ -1,56 +1,28 @@
/**
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
* The manual workout save path, and the detail page's delete.
*
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
* testing-library). The rules themselves live in `manual-entry.ts` and are
* pinned in its own suite; this one pins the wire and the markup:
* The rules themselves live in `manual-entry.ts` and are pinned in its own
* suite, and the mounted form (fields, units, layout, editing) is driven in
* `manual-workout-form.browser.test.tsx`. This one pins the save and delete
* helpers on their own:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. the form renders its five fields, the distance in the reader's unit,
* and a layout that stacks distance / energy on a phone;
* 4. delete is offered for a hand-entered workout in one's own record only,
* 3. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { QueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const authRef: { unitPreference: "metric" | "imperial" } = {
unitPreference: "metric",
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
username: "tester",
timezone: "Europe/Berlin",
unitPreference: authRef.unitPreference,
},
}),
}));
import {
ManualWorkoutForm,
saveManualWorkout,
sportOptions,
} from "../manual-workout-form";
import { saveManualWorkout, sportOptions } from "../manual-workout-form";
import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import {
manualWorkoutOriginalFromRow,
type ManualWorkoutEntry,
} from "@/lib/workouts/manual-entry";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
@@ -75,16 +47,9 @@ function fakeClient() {
};
}
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
authRef.unitPreference = "metric";
fetchSpy = vi.spyOn(globalThis, "fetch");
});
@@ -176,43 +141,6 @@ describe("sportOptions", () => {
});
});
describe("<ManualWorkoutForm> — markup", () => {
it("renders the five fields and nothing else", () => {
const html = render(<ManualWorkoutForm />);
expect(html).toContain('data-testid="manual-workout-sport"');
expect(html).toContain('data-testid="manual-workout-start"');
expect(html).toContain('data-testid="manual-workout-hours"');
expect(html).toContain('data-testid="manual-workout-minutes"');
expect(html).toContain('data-testid="manual-workout-distance"');
expect(html).toContain('data-testid="manual-workout-energy"');
expect(html).toContain("Activity");
expect(html).toContain("Duration");
expect(html).toContain("Active energy (kcal)");
// No heart rate, no sets, no route: a record, not a training log.
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
});
it("names the distance in kilometres for a metric reader", () => {
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
});
it("names the distance in miles for an imperial reader", () => {
authRef.unitPreference = "imperial";
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
});
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
const html = render(<ManualWorkoutForm />);
// Two short numeric fields fit a 390 px sheet side by side.
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
// Distance and energy carry longer labels: one column below `sm`, two
// from 640 px up.
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
// The action buttons keep the 44 px touch floor on a phone.
expect(html).toContain("min-h-11 sm:min-h-9");
});
});
describe("delete — a hand-entered workout in one's own record only", () => {
it("is offered for a MANUAL workout in one's own record", () => {
expect(
@@ -254,39 +182,3 @@ describe("delete — a hand-entered workout in one's own record only", () => {
});
});
});
describe("<ManualWorkoutForm> — editing a stored workout (#1162)", () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "Europe/Berlin", unitPreference: "metric" },
);
it("opens on the stored values", () => {
const html = render(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
expect(html).toContain('value="2026-09-15T08:00"');
expect(html).toContain('value="1"');
expect(html).toContain('value="30"');
expect(html).toContain('value="32"');
expect(html).toContain('value="640"');
});
it("opens empty when it is not editing", () => {
const html = render(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -0,0 +1,268 @@
/**
* The URL-owned filter contract, driven the way a list page uses it: a
* `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real
* address bar. What these pin and the markup tests could not: a facet picked
* from the open menu lands in the query as a new history entry, typed input
* rewrites the current one, the per-pill clear and Reset leave a bare URL, two
* writes in a row both land, and Back or an edited address re-seeds the pills.
*/
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import {
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import { Input } from "@/components/ui/input";
import { renderWithApp } from "../../../tests/browser/render";
import {
currentSearch,
navigateExternally,
recordHistory,
setSearch,
} from "../../../tests/browser/url";
import { useUrlFilterSync } from "../use-url-filter-sync";
interface Filters {
type?: string;
source?: string;
q?: string;
from?: string;
to?: string;
}
const KEYS = ["type", "source", "q", "from", "to"] as const;
function parse(params: URLSearchParams): Filters {
const filters: Filters = {};
for (const key of KEYS) {
const value = params.get(key);
if (value) filters[key] = value;
}
return filters;
}
function serialise(filters: Filters): string {
const sp = new URLSearchParams();
for (const key of KEYS) {
const value = filters[key];
if (value) sp.set(key, value);
}
return sp.toString();
}
const TYPES = [
{ value: "WEIGHT", label: "Weight" },
{ value: "PULSE", label: "Pulse" },
];
const SOURCES = [
{ value: "MANUAL", label: "Manual" },
{ value: "WITHINGS", label: "Withings" },
];
/** A list page's filter rail, as the measurements and mood lists build it. */
function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
const { filters, applyFilters, patchFilters } = useUrlFilterSync<Filters>({
parse,
serialise,
syncToUrl,
});
const isFiltered = Object.keys(filters).length > 0;
return (
<>
<Input
aria-label="Search notes"
value={filters.q ?? ""}
onChange={(e) => patchFilters({ q: e.target.value }, "replace")}
/>
<FilterBar
isFiltered={isFiltered}
onReset={() => applyFilters({})}
count={`${Object.keys(filters).length} active`}
>
<FilterBarDateRange
label="Date range"
from={filters.from ?? ""}
to={filters.to ?? ""}
onFromChange={(from) => patchFilters({ from })}
onToChange={(to) => patchFilters({ to })}
idPrefix="probe"
/>
<FilterBarSelect
label="Type"
value={filters.type ?? "ALL"}
onValueChange={(v) =>
patchFilters({ type: v === "ALL" ? undefined : v })
}
allLabel="All types"
options={TYPES}
/>
<FilterBarSelect
label="Source"
value={filters.source ?? "ALL"}
onValueChange={(v) =>
patchFilters({ source: v === "ALL" ? undefined : v })
}
allLabel="All sources"
options={SOURCES}
/>
</FilterBar>
</>
);
}
async function mount(search = "", props: { syncToUrl?: boolean } = {}) {
setSearch(search);
const history = recordHistory();
const { screen } = await renderWithApp(<FilterRail {...props} />);
const pill = (name: string | RegExp) =>
screen.getByRole("combobox", { name });
const reset = () => screen.getByRole("button", { name: "Reset" });
const pick = async (pillName: string | RegExp, option: string) => {
await pill(pillName).click();
await screen.getByRole("option", { name: option }).click();
};
return { screen, history, pill, reset, pick };
}
/** The URL settles a task after the write; wait for the expected query. */
async function expectSearch(search: string) {
await expect.poll(currentSearch).toBe(search);
}
describe("useUrlFilterSync with FilterBar", () => {
it("seeds the pills from the address it mounts on", async () => {
const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS");
await expect
.element(pill("Type: Weight"))
.toHaveAttribute("data-active", "true");
await expect.element(pill("Source: Withings")).toBeInTheDocument();
await expect.element(reset()).toBeVisible();
await expect.element(screen.getByText("2 active")).toBeVisible();
});
it("shows no Reset and no clear button while unfiltered", async () => {
const { screen, pill } = await mount();
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect
.element(screen.getByRole("button", { name: "Reset" }))
.not.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: /^Clear filter/ }))
.not.toBeInTheDocument();
});
it("writes a facet picked from the open menu as a new history entry", async () => {
const { pill, history, pick } = await mount();
const entries = window.history.length;
await pick("Type", "Pulse");
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual(["?type=PULSE"]);
expect(history.replaces()).toEqual([]);
expect(window.history.length).toBe(entries + 1);
await expect.element(pill("Type: Pulse")).toBeInTheDocument();
});
it("opens the menu inside the viewport, under its pill", async () => {
const { screen, pill } = await mount();
// Read before opening: the open menu hides the rest of the page from
// the accessibility tree, the pill included.
const trigger = pill("Source").element().getBoundingClientRect();
await pill("Source").click();
const listbox = screen.getByRole("listbox");
await expect.element(listbox).toBeVisible();
// The menu slides in from above its resting place; measure where it rests.
await Promise.all(document.getAnimations().map((a) => a.finished));
const menu = listbox.element().getBoundingClientRect();
expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1);
expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight);
expect(menu.left).toBeGreaterThanOrEqual(0);
await userEvent.keyboard("{Escape}");
await expect.element(listbox).not.toBeInTheDocument();
});
it("rewrites the current entry for typed input instead of stacking one per key", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
await screen.getByRole("textbox", { name: "Search notes" }).fill("knee");
await expectSearch("?type=WEIGHT&q=knee");
expect(history.pushes()).toEqual([]);
expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee");
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
await expectSearch("?source=MANUAL");
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect.element(pill("Source: Manual")).toBeInTheDocument();
});
it("clears both date bounds in one press, two writes in a row", async () => {
const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE");
const range = screen.getByRole("button", { name: /^Date range: / });
await expect.element(range).toHaveTextContent("Date range: 05/12–06/10");
await screen
.getByRole("button", { name: "Clear filter: Date range" })
.click();
await expectSearch("?type=PULSE");
await expect
.element(screen.getByRole("button", { name: "Date range" }))
.toHaveAttribute("data-active", "false");
});
it("resets every facet to a bare URL and hides Reset", async () => {
const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL");
await reset().click();
await expectSearch("");
expect(history.pushes()).toEqual([""]);
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect
.element(pill("Source"))
.toHaveAttribute("data-active", "false");
await expect.element(reset()).not.toBeInTheDocument();
});
it("re-seeds from an address changed outside the component", async () => {
const { pill, reset } = await mount("?type=WEIGHT");
navigateExternally("?source=WITHINGS");
await expect.element(pill("Source: Withings")).toBeInTheDocument();
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
navigateExternally("");
await expect.element(reset()).not.toBeInTheDocument();
});
it("steps back through filter states with the browser's Back", async () => {
const { pill, pick } = await mount("?type=WEIGHT");
await pick("Type: Weight", "Pulse");
await expectSearch("?type=PULSE");
window.history.back();
await expectSearch("?type=WEIGHT");
await expect.element(pill("Type: Weight")).toBeInTheDocument();
});
it("keeps the state in memory and the address untouched with syncToUrl off", async () => {
const { pill, pick, history } = await mount("?type=WEIGHT", {
syncToUrl: false,
});
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await pick("Type", "Pulse");
await expect.element(pill("Type: Pulse")).toBeInTheDocument();
expect(currentSearch()).toBe("?type=WEIGHT");
expect(history.writes).toEqual([]);
});
it("names Reset and the clear buttons in the reader's language", async () => {
setSearch("?source=MANUAL");
const { screen } = await renderWithApp(<FilterRail />, { locale: "de" });
await expect
.element(screen.getByRole("button", { name: "Zurücksetzen" }))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Filter entfernen: Source" }))
.toBeInTheDocument();
});
});
@@ -1,117 +0,0 @@
/**
* The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the
* documents-vault pattern extracted for the measurements and mood lists.
* Pinned here: mount parses the live query string, a filter change writes
* the serialised form back through the router (push by default, replace on
* request), an empty filter set restores the bare pathname, and
* `syncToUrl: false` detaches the state from the URL entirely.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
import { useUrlFilterSync } from "../use-url-filter-sync";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
let browser: UrlStateBrowser = installUrlStateBrowser("/");
afterEach(() => browser.uninstall());
interface ProbeFilters {
type?: string;
source?: string;
}
function parse(params: URLSearchParams): ProbeFilters {
const filters: ProbeFilters = {};
const type = params.get("type");
if (type) filters.type = type;
const source = params.get("source");
if (source) filters.source = source;
return filters;
}
function serialise(filters: ProbeFilters): string {
const sp = new URLSearchParams();
if (filters.type) sp.set("type", filters.type);
if (filters.source) sp.set("source", filters.source);
return sp.toString();
}
interface ProbeResult {
filters: ProbeFilters;
applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void;
}
function mount(search: string, syncToUrl?: boolean): ProbeResult {
mockSearch = search;
// Ref-shaped capture cell: the render-scoped assignment happens inside a
// closure the control-flow analysis cannot see through.
const captured: { current: ProbeResult | null } = { current: null };
function Probe() {
captured.current = useUrlFilterSync<ProbeFilters>({
parse,
serialise,
syncToUrl,
});
return null;
}
renderToStaticMarkup(<Probe />);
if (!captured.current) throw new Error("probe did not render");
// The live address the writes start from (`@/lib/url-state`).
browser.uninstall();
browser = installUrlStateBrowser(
search ? `/measurements?${search}` : "/measurements",
);
return captured.current;
}
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
});
describe("useUrlFilterSync", () => {
it("parses the mounted query string into the filter object", () => {
const { filters } = mount("type=WEIGHT&source=WITHINGS");
expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" });
});
it("writes a filter change to the URL via push by default", () => {
const { applyFilters } = mount("");
applyFilters({ type: "WEIGHT" });
expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT");
expect(browser.replace).not.toHaveBeenCalled();
});
it("writes via replace when asked (high-frequency input)", () => {
const { applyFilters } = mount("");
applyFilters({ source: "MANUAL" }, "replace");
expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL");
expect(browser.push).not.toHaveBeenCalled();
});
it("restores the bare pathname when every facet clears", () => {
const { applyFilters } = mount("type=WEIGHT");
applyFilters({});
expect(browser.push).toHaveBeenCalledWith("/measurements");
});
it("syncToUrl: false ignores the URL and never navigates", () => {
const { filters, applyFilters } = mount("type=WEIGHT", false);
expect(filters).toEqual({});
applyFilters({ type: "PULSE" });
expect(browser.push).not.toHaveBeenCalled();
expect(browser.replace).not.toHaveBeenCalled();
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Pin `Date` for a browser test, and nothing else.
*
* Forms default their timestamp to "now" and refuse a future one, so a test
* that asserts the instant a form sends has to say what now is. Only `Date`
* is faked: timers, animation frames and the microtask queue keep running,
* which is what Radix, TanStack Query and the URL-state queue wait on.
* `setup.ts` restores the real clock after every test.
*/
import { vi } from "vitest";
export function pinDate(iso: string): void {
vi.useFakeTimers({ toFake: ["Date"], now: new Date(iso) });
}
+175
View File
@@ -0,0 +1,175 @@
/**
* The network boundary for browser tests.
*
* Components talk to the server through `@/lib/api/api-fetch`, which calls
* the global `fetch`. Replacing `fetch` and nothing else keeps everything
* above it real: the envelope unwrapping, the idempotency header, TanStack
* Query's caching and retries, the component's own pending and error states.
* Never mock `@tanstack/react-query` or the API wrapper in this project; a
* test that does asserts its own stand-in.
*
* Every request is recorded with its parsed JSON body, so a form test asserts
* the payload the server would receive. A route can answer later than the
* request (`deferred`), which is how a test holds a form in its pending
* state. A request no route matches fails with a 404 envelope and is recorded
* too, so an unexpected call is visible rather than hanging.
*
* One request is ambient: `I18nProvider` mirrors the active locale to
* `PUT /api/auth/me/locale` on every mount, as it does in the app. The stub
* answers it and leaves it out of `requests`, so "nothing was sent" means
* nothing the component under test sent.
*
* `setup.ts` restores the real `fetch` after every test.
*/
export interface RecordedRequest {
method: string;
/** Path and query, without the origin. */
path: string;
headers: Headers;
/** The parsed JSON body, or the raw text when it is not JSON. */
body: unknown;
}
export interface StubResponse {
status?: number;
/** The envelope's `data`. Ignored when `error` is set. */
data?: unknown;
error?: string;
/** The envelope's `meta` (an error's `errorCode`, a list's totals). */
meta?: Record<string, unknown>;
}
type Responder = (
request: RecordedRequest,
) => StubResponse | Promise<StubResponse>;
interface Route {
method: string;
path: string | RegExp;
respond: Responder;
}
export interface FetchStub {
/** Answer `method path` (exact path, or a pattern) with `response`. */
on(
method: string,
path: string | RegExp,
response: StubResponse | Responder,
): FetchStub;
/**
* Answer `method path` only when `release` is called, for asserting the
* pending state in between.
*/
deferred(
method: string,
path: string | RegExp,
): { release: (response?: StubResponse) => void };
/** Every request so far, in order. */
requests: RecordedRequest[];
/** The requests that hit `method path`. */
calls(method: string, path: string | RegExp): RecordedRequest[];
}
let realFetch: typeof fetch | null = null;
/** Sent by the providers on every mount, not by the component under test. */
const AMBIENT = [{ method: "PUT", path: "/api/auth/me/locale" }];
function matches(route: Route, method: string, path: string): boolean {
if (route.method !== method) return false;
const bare = path.split("?")[0];
return typeof route.path === "string"
? route.path === bare
: route.path.test(path);
}
function envelope({ status = 200, data = null, error, meta }: StubResponse) {
const body = {
...(error ? { data: null, error } : { data, error: null }),
...(meta ? { meta } : {}),
};
return new Response(JSON.stringify(body), {
status: error && status === 200 ? 400 : status,
headers: { "content-type": "application/json" },
});
}
async function record(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<RecordedRequest> {
const request = new Request(input, init);
const url = new URL(request.url, window.location.href);
const text = request.method === "GET" ? "" : await request.text();
let body: unknown = text === "" ? undefined : text;
try {
if (text !== "") body = JSON.parse(text);
} catch {
// Keep the raw text.
}
return {
method: request.method,
path: `${url.pathname}${url.search}`,
headers: request.headers,
body,
};
}
/** Replace `fetch` for the rest of the test. */
export function stubFetch(): FetchStub {
realFetch ??= window.fetch;
const routes: Route[] = [];
const requests: RecordedRequest[] = [];
window.fetch = async (input, init) => {
const request = await record(input, init);
const ambient = AMBIENT.some(
(a) => a.method === request.method && a.path === request.path,
);
if (ambient) return envelope({ data: request.body ?? null });
requests.push(request);
// The newest route wins, so a test can override a default it set up.
const route = [...routes]
.reverse()
.find((r) => matches(r, request.method, request.path));
if (!route) {
return envelope({
status: 404,
error: `no stubbed route for ${request.method} ${request.path}`,
});
}
return envelope(await route.respond(request));
};
const stub: FetchStub = {
requests,
on(method, path, response) {
routes.push({
method,
path,
respond: typeof response === "function" ? response : () => response,
});
return stub;
},
deferred(method, path) {
let release: (response?: StubResponse) => void = () => {};
const answer = new Promise<StubResponse>((resolve) => {
release = (response = {}) => resolve(response);
});
routes.push({ method, path, respond: () => answer });
return { release: (response) => release(response) };
},
calls(method, path) {
return requests.filter((r) =>
matches({ method, path, respond: () => ({}) }, r.method, r.path),
);
},
};
return stub;
}
/** Called by `setup.ts` after each test. */
export function restoreFetch(): void {
if (realFetch) window.fetch = realFetch;
realFetch = null;
}
+89
View File
@@ -0,0 +1,89 @@
/**
* `next/navigation` for the browser project, aliased in `vitest.config.mts`.
*
* The App Router's hooks throw unless a router is mounted, and mounting one
* means booting Next. What a component needs from them is smaller: the
* current path and query, and a way to change them. This module answers both
* from the page's real `location` and `history`, so `@/lib/url-state` writes
* through the same `pushState` / `replaceState` it uses in the app.
*
* `useSearchParams` follows the address bar the way the router does: it
* re-reads on `popstate` and on the URL-state event, one task after the write
* rather than synchronously, which is the "adopted a render later" gap the
* helper's queue exists for. A test that only asserts what the page shows
* never sees the gap; one that writes twice in a row exercises it.
*
* `redirect` and `notFound` throw like Next's do, so a component that calls
* one fails its test loudly instead of rendering past it.
*/
import { useMemo, useSyncExternalStore } from "react";
import { URL_STATE_EVENT } from "@/lib/url-state";
function subscribe(onChange: () => void): () => void {
// The router adopts a write after the event loop turns, not inside the
// dispatch that announced it.
const deferred = () => setTimeout(onChange, 0);
window.addEventListener("popstate", deferred);
window.addEventListener(URL_STATE_EVENT, deferred);
return () => {
window.removeEventListener("popstate", deferred);
window.removeEventListener(URL_STATE_EVENT, deferred);
};
}
const readSearch = () => window.location.search;
const readPathname = () => window.location.pathname;
export function useSearchParams(): URLSearchParams {
const search = useSyncExternalStore(subscribe, readSearch, readSearch);
return useMemo(() => new URLSearchParams(search), [search]);
}
export function usePathname(): string {
return useSyncExternalStore(subscribe, readPathname, readPathname);
}
function navigate(mode: "push" | "replace", href: string): void {
const url = new URL(href, window.location.href);
if (mode === "push") window.history.pushState(null, "", url);
else window.history.replaceState(null, "", url);
window.dispatchEvent(new PopStateEvent("popstate"));
}
const router = {
push: (href: string) => navigate("push", href),
replace: (href: string) => navigate("replace", href),
back: () => window.history.back(),
forward: () => window.history.forward(),
refresh: () => {},
prefetch: () => {},
};
export function useRouter(): typeof router {
return router;
}
export function useParams(): Record<string, string> {
return {};
}
export function useSelectedLayoutSegment(): string | null {
return null;
}
export function useSelectedLayoutSegments(): string[] {
return [];
}
export function redirect(href: string): never {
throw new Error(`redirect(${href}) called in a browser test`);
}
export function permanentRedirect(href: string): never {
throw new Error(`permanentRedirect(${href}) called in a browser test`);
}
export function notFound(): never {
throw new Error("notFound() called in a browser test");
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Render a component the way the app mounts it, in a real browser.
*
* The providers are the app's own: `I18nProvider` with a real catalog and a
* real TanStack `QueryClient`. The signed-in account is seeded into that
* client under `queryKeys.authMe()`, exactly where `useAuth` reads it once
* `/api/auth/me` has answered, so a component that asks for the account gets
* it without a request. Everything else a component fetches goes through the
* network and is answered by `stubFetch()` from `./fetch-stub`.
*
* The app stylesheet is loaded by `setup.ts` for every browser test, so
* `hidden`, `pointer-events-none`, popper placement and the breakpoints lay
* out as they do in the app.
*
* const { screen, queryClient } = await renderWithApp(<MeasurementForm />, {
* user: { unitPreference: "imperial" },
* });
* await screen.getByLabelText("Value (lb)").fill("210");
*/
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, type RenderResult } from "vitest-browser-react";
import type { AuthUser } from "@/hooks/use-auth";
import type { Locale } from "@/lib/i18n/config";
import { I18nProvider } from "@/lib/i18n/context";
import { loadMessages } from "@/lib/i18n/load-locale";
import { queryKeys } from "@/lib/query-keys";
/** A signed-in account with every preference at its default. */
export function authUser(overrides: Partial<AuthUser> = {}): AuthUser {
return {
id: "user-1",
username: "user",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "UTC",
onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
onboardingTourCompleted: true,
onboardingTourProgress: null,
avatarUrl: null,
glucoseUnit: null,
unitPreference: "metric",
timeFormat: "AUTO",
dateFormat: "AUTO",
disableCoach: false,
fullName: null,
insurerName: null,
insurerIkNumber: null,
insuranceNumber: null,
lastReportPracticeName: null,
reportSelection: null,
cycleTrackingEnabled: false,
...overrides,
};
}
export interface RenderWithAppOptions {
/** The reader's language. Its catalog is loaded before the first render. */
locale?: Locale;
/**
* The signed-in account, merged over `authUser()`. `null` leaves the
* account unanswered, as before `/api/auth/me` returns.
*/
user?: Partial<AuthUser> | null;
/** Bring a client to seed more reads before rendering. */
queryClient?: QueryClient;
}
export interface AppRender {
screen: RenderResult;
queryClient: QueryClient;
}
/** A client configured like a test should want it: no retries, no refetch. */
export function testQueryClient(): QueryClient {
return new QueryClient({
defaultOptions: {
queries: { retry: false, refetchOnWindowFocus: false },
mutations: { retry: false },
},
});
}
export async function renderWithApp(
ui: ReactNode,
{ locale = "en", user = {}, queryClient }: RenderWithAppOptions = {},
): Promise<AppRender> {
const client = queryClient ?? testQueryClient();
if (user !== null) client.setQueryData(queryKeys.authMe(), authUser(user));
if (locale !== "en") await loadMessages(locale);
const screen = await render(
<QueryClientProvider client={client}>
<I18nProvider initialLocale={locale}>{ui}</I18nProvider>
</QueryClientProvider>,
);
return { screen, queryClient: client };
}
+40
View File
@@ -0,0 +1,40 @@
/**
* Setup for the `browser` project (`*.browser.test.tsx`, headless Chromium).
*
* - The app stylesheet, compiled by Vite through the project's PostCSS
* config, so components lay out and hide as they do in the app, under the
* theme class the boot script sets for a first visit (`dark`, see
* `src/lib/pwa/theme-color.ts`); without one no colour token resolves.
* - The EN catalog, the fallback floor of every `t()` chain, loaded through
* the production loader. `renderWithApp` loads any other locale it is
* asked for.
* - After every test: the real `fetch`, the real clock, the page's starting
* address and an empty `localStorage` come back, so a stub, a pinned date,
* a URL write or a remembered preference never reaches the next test. Rendered
* trees are unmounted by `vitest-browser-react` itself.
*/
import "@/app/globals.css";
import { afterEach, beforeAll, beforeEach, vi } from "vitest";
import { loadMessages } from "@/lib/i18n/load-locale";
import { restoreFetch } from "./fetch-stub";
import { rememberAddress, restoreAddress } from "./url";
document.documentElement.classList.add("dark");
beforeAll(async () => {
await loadMessages("en");
});
beforeEach(() => {
rememberAddress();
});
afterEach(() => {
restoreFetch();
vi.useRealTimers();
restoreAddress();
window.localStorage.clear();
});
+89
View File
@@ -0,0 +1,89 @@
/**
* The page's address, for URL-state tests in the browser project.
*
* Each test file runs in its own page, and `setup.ts` puts the address the
* page started on back after every test, so a test may write the URL freely.
* Only the query and hash are ever changed: the path is the test runner's
* own, and a component that writes state keeps the path it found.
*/
import { resetUrlStateForTests } from "@/lib/url-state";
export interface HistoryRecord {
/** Every `pushState` and `replaceState` since `recordHistory()`, in order. */
writes: Array<{ mode: "push" | "replace"; search: string }>;
pushes: () => string[];
replaces: () => string[];
}
let restoreHistory: (() => void) | null = null;
/**
* Record history writes without changing them: the real methods still run,
* so the page's location moves exactly as it would in the app.
*/
export function recordHistory(): HistoryRecord {
restoreHistory?.();
const { history } = window;
const push = history.pushState.bind(history);
const replace = history.replaceState.bind(history);
const writes: HistoryRecord["writes"] = [];
const searchOf = (url: string | URL | null | undefined) =>
url == null
? window.location.search
: new URL(url, window.location.href).search;
history.pushState = (data, unused, url) => {
push(data, unused, url);
writes.push({ mode: "push", search: searchOf(url) });
};
history.replaceState = (data, unused, url) => {
replace(data, unused, url);
writes.push({ mode: "replace", search: searchOf(url) });
};
restoreHistory = () => {
history.pushState = push;
history.replaceState = replace;
restoreHistory = null;
};
return {
writes,
pushes: () => writes.filter((w) => w.mode === "push").map((w) => w.search),
replaces: () =>
writes.filter((w) => w.mode === "replace").map((w) => w.search),
};
}
/** Start the test on `search` (`""` for a bare path). */
export function setSearch(search: string): void {
const query = search === "" || search.startsWith("?") ? search : `?${search}`;
window.history.replaceState(null, "", `${window.location.pathname}${query}`);
}
/**
* Change the query the way the person would from outside the component:
* Back, Forward or an edited address. The router hears `popstate`.
*/
export function navigateExternally(search: string): void {
setSearch(search);
window.dispatchEvent(new PopStateEvent("popstate"));
}
/** The current query string, `?`-prefixed or empty. */
export function currentSearch(): string {
return window.location.search;
}
let initialHref: string | null = null;
/** Called by `setup.ts` before each test. */
export function rememberAddress(): void {
initialHref = window.location.href;
}
/** Called by `setup.ts` after each test. */
export function restoreAddress(): void {
restoreHistory?.();
resetUrlStateForTests();
if (initialHref !== null && window.location.href !== initialHref) {
window.history.replaceState(null, "", initialHref);
}
}
+105 -24
View File
@@ -1,3 +1,4 @@
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";
import { globSync } from "node:fs";
import { dirname, resolve } from "node:path";
@@ -10,12 +11,17 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
* Three projects, decided per file from its source (`tests/unit-projects.ts`):
* `guards` and `unit` share a module graph inside a worker (`isolate:
* false`), because their files neither mock nor stub anything; `isolated`
* keeps one fresh graph per file for the ones that do. Run one with
* `pnpm test --project guards`.
* Four projects. Three run in Node and are decided per file from its source
* (`tests/unit-projects.ts`): `guards` and `unit` share a module graph inside
* a worker (`isolate: false`), because their files neither mock nor stub
* anything; `isolated` keeps one fresh graph per file for the ones that do.
* The fourth, `browser`, runs `*.browser.test.tsx` in headless Chromium for
* components whose behaviour is interaction (see `tests/browser/`). Run one
* with `pnpm test --project guards`.
*/
/** Files the `browser` project owns; no Node project collects them. */
const BROWSER_TEST = /\.browser\.test\.tsx$/;
const projectFiles = classifyUnitFiles(
__dirname,
globSync(
@@ -28,28 +34,34 @@ const projectFiles = classifyUnitFiles(
{
cwd: __dirname,
exclude: (path) =>
path.startsWith("tests/integration") || path.includes("node_modules"),
path.startsWith("tests/integration") ||
path.includes("node_modules") ||
BROWSER_TEST.test(path),
},
).sort(),
);
/** What the three Node projects share; the browser project sets its own. */
const nodeTest = {
globals: true,
environment: "node",
// Pin the host timezone. CI runs UTC; without this a contributor in any
// other zone sees a different suite than the gate does — verified: a
// UTC+14 host fails five tests on an otherwise green tree. Pinning makes
// local and CI agree, and makes "which zone does this test mean?" an
// explicit choice the fixture has to state rather than a property of the
// machine it runs on. Tests that need a different HOST zone set
// `process.env.TZ` themselves and restore it afterwards (see
// `next-due-day-label.test.ts`).
env: { TZ: "UTC" },
// Primes the i18n locale cache so provider mounts resolve every
// locale synchronously (production gets the active bundle as an RSC
// prop instead — see src/lib/i18n/load-locale.ts).
setupFiles: ["./vitest.setup.ts"],
};
export default defineConfig({
test: {
globals: true,
environment: "node",
// Pin the host timezone. CI runs UTC; without this a contributor in any
// other zone sees a different suite than the gate does — verified: a
// UTC+14 host fails five tests on an otherwise green tree. Pinning makes
// local and CI agree, and makes "which zone does this test mean?" an
// explicit choice the fixture has to state rather than a property of the
// machine it runs on. Tests that need a different HOST zone set
// `process.env.TZ` themselves and restore it afterwards (see
// `next-due-day-label.test.ts`).
env: { TZ: "UTC" },
// Primes the i18n locale cache so provider mounts resolve every
// locale synchronously (production gets the active bundle as an RSC
// prop instead — see src/lib/i18n/load-locale.ts).
setupFiles: ["./vitest.setup.ts"],
// `--shard` splits by measured duration rather than file count, and files
// start longest first. See `tests/durations/sequencer.ts`.
sequence: {
@@ -77,15 +89,84 @@ export default defineConfig({
projects: [
{
extends: true,
test: { name: "guards", include: projectFiles.guards, isolate: false },
test: {
...nodeTest,
name: "guards",
include: projectFiles.guards,
isolate: false,
},
},
{
extends: true,
test: { name: "unit", include: projectFiles.shared, isolate: false },
test: {
...nodeTest,
name: "unit",
include: projectFiles.shared,
isolate: false,
},
},
{
extends: true,
test: { name: "isolated", include: projectFiles.isolated },
test: {
...nodeTest,
name: "isolated",
include: projectFiles.isolated,
},
},
// Not `extends: true` and none of `nodeTest`: the Node setup file reads
// the file system and mocks the write meter. Each file gets its own
// page, so a stubbed `fetch` or a rewritten URL cannot leak into the
// next file.
{
resolve: {
alias: {
"@": resolve(__dirname, "src"),
// The App Router's hooks throw outside a mounted router. The shim
// reads and writes the page's real location instead.
"next/navigation": resolve(
__dirname,
"tests/browser/next-navigation.ts",
),
},
},
// Next's client modules (`next/link` among them) read
// `process.env.*`, which Next's own bundler inlines at build time.
// A page has no `process`; this inlines an environment with no
// public variables set, as a build without any would.
define: {
"process.env": JSON.stringify({
NODE_ENV: process.env.NODE_ENV ?? "test",
}),
},
// Pre-bundle every dependency the browser files reach before the
// first one loads. Discovered late, a dependency makes Vite re-bundle
// and reload the page mid-run, which fails the file that was
// importing it; CI starts every run on an empty cache. `force`
// makes a local run start there too: a cache warmed before a new
// browser file existed does not know that file's dependencies, and
// the late re-bundle loads a second React ("Invalid hook call").
// A cold bundle costs about half a second.
optimizeDeps: {
force: true,
entries: ["src/**/*.browser.test.tsx", "tests/browser/**/*.{ts,tsx}"],
},
test: {
name: "browser",
include: ["src/**/*.browser.test.tsx"],
globals: true,
setupFiles: ["./tests/browser/setup.ts"],
browser: {
enabled: true,
headless: true,
// Pinned like the Node projects' `TZ`, so a date the form
// derives from the clock means the same instant everywhere.
provider: playwright({
contextOptions: { timezoneId: "UTC", locale: "en-US" },
}),
instances: [{ browser: "chromium" }],
screenshotFailures: false,
},
},
},
],
coverage: {