mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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\)/,
|
||||
);
|
||||
});
|
||||
});
|
||||
+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,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) });
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user