test(measurements): drive the measurement form in a real browser

Type into the form, press Save and read the request body off the fetch
boundary: blood pressure as one batch, a weight typed in lb stored in
kg, a decimal comma, glucose typed in mmol/L stored in mg/dL with its
meal context, a sleep typed as 7:30 stored as 450 minutes. Native
required validation, the pending state, a refused write's localised
message, the module-off type list, the last-used type and the menu's
Reset are covered too.

The unit-label and glucose-unit markup tests are deleted; their source
pins are replaced by the wire assertions. The module-types file keeps
its pure ownership checks and drops its render.

The browser helper gains a Date-only clock pin, clears localStorage
between tests, carries an envelope's meta, and answers the providers'
locale mirror without counting it as the component's request.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:07:29 +02:00
1 parent cc36f46f10
commit 3ab85f85f9
9 files changed
+356 -220

No files matched your search

@@ -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": 358,
"total": 358,
"count": 357,
"total": 357,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -213,7 +213,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,
@@ -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": 2949,
"total": 10293,
"count": 2948,
"total": 10292,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1122,7 +1122,6 @@
"components/measurement-reminders/vorsorge-dashboard-card.tsx": 2,
"components/measurement-reminders/vorsorge-section.tsx": 26,
"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,
@@ -1,98 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The manual entry form and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
* account picked. Until the preference could be set at all, the form could
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
* plausibility band (20–800) accepts without a word and every surface then
* reads back as real. So the label and the stored number have to move
* together — the field asks in the account's unit and the submit path
* inverts to canonical mg/dL.
*
* The label half is rendered. The submit half is asserted against the source:
* this project runs SSR-only component tests (`@testing-library/react` is not
* a dependency), so nothing here can type into the field and press save. What
* the source assertion pins is the wiring — that the glucose branch inverts
* through `toCanonicalMgdl` rather than sending the typed number on — and
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
*/
let glucoseUnit: string | null = null;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: "metric",
glucoseUnit,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
}
const formSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
"utf8",
);
describe("MeasurementForm — blood-glucose entry unit", () => {
it("asks in mg/dL for an account that never chose", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
expect(html).toContain('placeholder="95"');
});
it("asks in mmol/L once the account chose it", () => {
glucoseUnit = "mmol/L";
const html = render();
expect(html).toContain("mmol/L");
// A mmol/L reader must not be shown a three-digit mg/dL example.
expect(html).toContain('placeholder="5.3"');
expect(html).not.toContain('placeholder="95"');
});
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
expect(formSource).toMatch(
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
);
});
it("resolves the unit from the account rather than assuming one", () => {
expect(formSource).toMatch(
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
);
});
});
@@ -6,45 +6,18 @@
* file a reading into a domain whose pages, charts and reads are all hidden,
* or be refused by the route. The form now filters its types through the
* surface map's type ownership (`summary:<type>`), which the test below holds
* equal to the measurement-scope ownership the server gates on.
* equal to the measurement-scope ownership the server gates on. That the
* mounted form neither opens on nor offers such a type is driven in
* `measurement-form.browser.test.tsx`.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { surfaceModule } from "@/lib/modules/surface";
const auth = vi.hoisted(() => ({
modules: undefined as Record<string, boolean> | undefined,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
role: "USER",
unitPreference: "metric",
glucoseUnit: null,
modules: auth.modules,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MeasurementForm,
measurementFormTypes,
} from "../measurement-form";
@@ -77,17 +50,3 @@ describe("measurementFormTypes", () => {
expect(owned).toBeGreaterThan(0);
});
});
describe("<MeasurementForm> with a type's module off", () => {
it("does not open on that type, even when asked to", () => {
auth.modules = { glucose: false };
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
auth.modules = undefined;
expect(html).not.toContain("mg/dL");
expect(html).toContain("mmHg");
});
});
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: preference,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
describe("MeasurementForm — preferred-unit value label", () => {
it("labels the weight field in kg for a metric user", () => {
preference = "metric";
const html = render();
expect(html).toContain("kg");
expect(html).not.toContain("lb");
// The metric placeholder (kg) is shown, not the imperial one.
expect(html).toContain('placeholder="75.5"');
});
it("labels the weight field in lb for an imperial user", () => {
preference = "imperial";
const html = render();
expect(html).toContain("lb");
// The imperial placeholder (lb) is shown.
expect(html).toContain('placeholder="165"');
});
});
@@ -0,0 +1,309 @@
/**
* `<MeasurementForm>` driven by typing and pressing Save, with the request
* body read off the network boundary. What the markup tests could only pin
* against the source is asserted on the wire here: a weight typed in lb, a
* glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their
* canonical unit.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import type { AuthUser } from "@/hooks/use-auth";
import { pinDate } from "../../../../tests/browser/clock";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { MeasurementForm } from "../measurement-form";
const NOW = "2026-09-15T10:00:00.000Z";
let api: FetchStub;
beforeEach(() => {
pinDate(NOW);
api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} });
});
async function mount(
props: Parameters<typeof MeasurementForm>[0] = {},
user: Partial<AuthUser> = {},
) {
const onSuccess = vi.fn();
const { screen } = await renderWithApp(
<MeasurementForm onSuccess={onSuccess} onCancel={() => {}} {...props} />,
{ user },
);
const save = screen.getByRole("button", { name: "Save" });
return { screen, onSuccess, save };
}
const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body);
describe("<MeasurementForm> blood pressure", () => {
it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("128");
await screen.getByLabelText(/^Diastolic/).fill("84");
await screen.getByLabelText(/^Pulse/).fill("66");
await screen.getByLabelText(/^Notes/).fill("after the stairs");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const at = NOW;
expect(posted()).toEqual([
[
{
type: "BLOOD_PRESSURE_SYS",
value: 128,
measuredAt: at,
notes: "after the stairs",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 84,
measuredAt: at,
notes: "after the stairs",
},
{ type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" },
],
]);
// Only the write: nothing else in the cache needed refetching.
expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
"POST /api/measurements",
]);
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
});
it("leaves the pulse out when it was not typed", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("118");
await screen.getByLabelText(/^Diastolic/).fill("76");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [batch] = posted() as Array<Array<{ type: string }>>;
expect(batch.map((e) => e.type)).toEqual([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
});
it("sends nothing while a required reading is missing", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
const sys = screen
.getByLabelText(/^Systolic/)
.element() as HTMLInputElement;
expect(sys.validity.valueMissing).toBe(true);
expect(api.requests).toEqual([]);
expect(onSuccess).not.toHaveBeenCalled();
});
it("holds Save and the menu while the write is pending", async () => {
const pending = api.deferred("POST", "/api/measurements");
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect.element(save).toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "More options" }))
.toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "Cancel" }))
.toBeDisabled();
expect(onSuccess).not.toHaveBeenCalled();
pending.release({ status: 201, data: {} });
await expect.element(save).toBeEnabled();
expect(onSuccess).toHaveBeenCalledOnce();
});
it("names a refused write in the reader's words and keeps what was typed", async () => {
api.on("POST", "/api/measurements", {
status: 409,
error: "Duplicate measurement",
meta: { errorCode: "measurement.duplicate_timestamp" },
});
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("A measurement already exists for this time.");
await expect
.element(screen.getByLabelText(/^Systolic/))
.toHaveAttribute("aria-invalid", "true");
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120);
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe("<MeasurementForm> units on the way out", () => {
it("asks an imperial reader for lb and stores kg", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "WEIGHT" },
{ unitPreference: "imperial" },
);
const field = screen.getByLabelText("Value (lb)");
await expect.element(field).toHaveAttribute("placeholder", "165");
await field.fill("210");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 95.25, measuredAt: NOW },
]);
});
it("reads a decimal comma for a metric reader", async () => {
const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" });
const field = screen.getByLabelText("Value (kg)");
await expect.element(field).toHaveAttribute("placeholder", "75.5");
await field.fill("75,5");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 75.5, measuredAt: NOW },
]);
});
it("asks in mg/dL for an account that never chose a glucose unit", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_GLUCOSE",
});
const field = screen.getByLabelText("Value (mg/dL)");
await expect.element(field).toHaveAttribute("placeholder", "95");
await field.fill("104");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 104,
measuredAt: NOW,
glucoseContext: "FASTING",
},
]);
});
it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ glucoseUnit: "mmol/L" },
);
const field = screen.getByLabelText("Value (mmol/L)");
await expect.element(field).toHaveAttribute("placeholder", "5.3");
await field.fill("5.5");
await screen.getByRole("combobox", { name: /^Context/ }).click();
await screen
.getByRole("option", { name: "Post-meal (2h after eating)" })
.click();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 99,
measuredAt: NOW,
glucoseContext: "POSTPRANDIAL",
},
]);
});
it("reads a sleep typed as hours and minutes and stores minutes", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "SLEEP_DURATION",
});
await screen.getByLabelText("Value (hours)").fill("7:30");
await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "SLEEP_DURATION", value: 450, measuredAt: NOW },
]);
});
it("refuses a duration it cannot read, under the field", async () => {
const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" });
const field = screen.getByLabelText("Value (hours)");
await field.fill("most of the night");
await save.click();
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.toBeVisible();
await expect.element(field).toHaveAttribute("aria-invalid", "true");
expect(api.requests).toEqual([]);
await field.fill("7.5");
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.not.toBeInTheDocument();
});
});
describe("<MeasurementForm> type", () => {
it("relabels the value field when the type changes", async () => {
const { screen } = await mount({ defaultType: "WEIGHT" });
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Body temperature" }).click();
await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible();
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Blood Pressure" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible();
});
it("does not open on, or offer, a type whose module is off", async () => {
const { screen } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ modules: { glucose: false } },
);
const type = screen.getByRole("combobox", { name: /^Type/ });
await expect.element(type).toHaveTextContent("Blood Pressure");
await type.click();
await expect
.element(screen.getByRole("option", { name: "Weight" }))
.toBeVisible();
await expect
.element(screen.getByRole("option", { name: "Blood glucose" }))
.not.toBeInTheDocument();
await userEvent.keyboard("{Escape}");
});
it("opens on the type saved last when no type is asked for", async () => {
const first = await mount({ defaultType: "WEIGHT" });
await first.screen.getByLabelText("Value (kg)").fill("80");
await first.save.click();
await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1);
first.screen.unmount();
const { screen } = await mount();
await expect
.element(screen.getByRole("combobox", { name: /^Type/ }))
.toHaveTextContent("Weight");
});
it("clears every field from the menu's Reset", async () => {
const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" });
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Notes/).fill("left arm");
await screen.getByRole("button", { name: "More options" }).click();
await screen.getByRole("menuitem", { name: "Reset form" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue("");
});
});
+14
View File
@@ -0,0 +1,14 @@
/**
* Pin `Date` for a browser test, and nothing else.
*
* Forms default their timestamp to "now" and refuse a future one, so a test
* that asserts the instant a form sends has to say what now is. Only `Date`
* is faked: timers, animation frames and the microtask queue keep running,
* which is what Radix, TanStack Query and the URL-state queue wait on.
* `setup.ts` restores the real clock after every test.
*/
import { vi } from "vitest";
export function pinDate(iso: string): void {
vi.useFakeTimers({ toFake: ["Date"], now: new Date(iso) });
}
+19 -2
View File
@@ -14,6 +14,11 @@
* 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 {
@@ -30,6 +35,8 @@ export interface StubResponse {
/** 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 = (
@@ -65,6 +72,9 @@ export interface FetchStub {
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];
@@ -73,8 +83,11 @@ function matches(route: Route, method: string, path: string): boolean {
: route.path.test(path);
}
function envelope({ status = 200, data = null, error }: StubResponse) {
const body = error ? { data: null, error } : { data, error: null };
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" },
@@ -110,6 +123,10 @@ export function stubFetch(): FetchStub {
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]
+6 -3
View File
@@ -8,13 +8,14 @@
* - 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` and the page's starting address come
* back, so a stub or a URL write never reaches the next test. Rendered
* - 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 } from "vitest";
import { afterEach, beforeAll, beforeEach, vi } from "vitest";
import { loadMessages } from "@/lib/i18n/load-locale";
@@ -33,5 +34,7 @@ beforeEach(() => {
afterEach(() => {
restoreFetch();
vi.useRealTimers();
restoreAddress();
window.localStorage.clear();
});