mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
commit
a60111cc9d
29 files changed
+2271
-1012
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+148
-7
@@ -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\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+4
-45
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-116
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user