mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +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
@@ -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"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user