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

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

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "tests that read a source file by path, per test file under src/. Maintained by src/__tests__/test-realism-ratchet.test.ts.",
"count": 359,
"total": 359,
"count": 356,
"total": 356,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 1,
"__tests__/admin-data-wipe-completeness.test.ts": 1,
@@ -212,7 +212,6 @@
"components/layout/__tests__/offline-banner.test.tsx": 1,
"components/layout/__tests__/sidebar-nav.test.tsx": 1,
"components/measurements/__tests__/duration-read-as.test.tsx": 1,
"components/measurements/__tests__/measurement-form-glucose-unit.test.tsx": 1,
"components/medications/__tests__/add-inventory-dialog.test.tsx": 1,
"components/medications/__tests__/detail-page-cascade.test.tsx": 1,
"components/medications/__tests__/inventory-section-dates.test.tsx": 1,
@@ -262,8 +261,6 @@
"components/timeline/__tests__/no-template-key-guard.test.ts": 1,
"components/ui/__tests__/card-density.test.tsx": 1,
"components/ui/__tests__/card-header-rows.test.ts": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/__tests__/segmented.test.tsx": 1,
"hooks/__tests__/use-insight-status.test.ts": 1,
"lib/__tests__/geo-offline-detection.test.ts": 1,
"lib/__tests__/query-keys.test.ts": 1,
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2945,
"total": 10221,
"count": 2943,
"total": 10219,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1119,7 +1119,6 @@
"components/measurement-reminders/__tests__/vorsorge-section.test.tsx": 7,
"components/measurement-reminders/vorsorge-dashboard-card.tsx": 2,
"components/measurement-reminders/vorsorge-settings.tsx": 2,
"components/measurements/__tests__/measurement-form-unit-label.test.tsx": 1,
"components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx": 4,
"components/measurements/__tests__/measurement-list-bp-badge.test.tsx": 3,
"components/measurements/__tests__/measurement-list-empty.test.tsx": 1,
@@ -1443,7 +1442,6 @@
"components/timeline/__tests__/timeline-view-keys.test.tsx": 1,
"components/timeline/__tests__/value-options.test.ts": 1,
"components/timeline/life-event-form.lazy.tsx": 1,
"components/ui/__tests__/filter-bar.test.tsx": 1,
"components/ui/back-link.tsx": 1,
"components/ui/button.tsx": 1,
"components/ui/card.tsx": 1,
@@ -12,6 +12,12 @@
* same suite: every dependency bump failed on a launch error that said nothing
* about the bump. One workflow was fixed, one class was not.
*
* Since v1.46 the suite also carries the `browser` project
* (`*.browser.test.tsx`, see `tests/browser/`), which launches the same
* headless shell through Vitest's Playwright provider for every interaction
* test. A slice without the browser now fails on many files rather than one;
* the install it needs is the same, so this guard covers both.
*
* The sweep is textual and file-scoped, which is its limit: a workflow with two
* jobs, only one of which installs the browser, satisfies it. It catches the
* thing that actually went wrong — a whole workflow running the suite with no
@@ -58,8 +64,8 @@ describe("the browser the unit suite measures with", () => {
expect(
missing,
"a workflow runs the unit suite without installing the headless " +
"Chromium the hero-geometry check measures with — that check refuses " +
"to skip under CI, so the job fails on a launch error instead",
"Chromium the hero-geometry check and the browser project launch — " +
"neither skips under CI, so the job fails on a launch error instead",
).toEqual([]);
});
});
@@ -1,98 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* The manual entry form and the blood-glucose display unit.
*
* Glucose is stored in mg/dL and shown in whichever of mg/dL and mmol/L the
* account picked. Until the preference could be set at all, the form could
* hardcode "mg/dL" and be right by accident. It no longer can: a reader on
* mmol/L types 5.3, and 5.3 filed as mg/dL is a severe hypo that the
* plausibility band (20–800) accepts without a word and every surface then
* reads back as real. So the label and the stored number have to move
* together — the field asks in the account's unit and the submit path
* inverts to canonical mg/dL.
*
* The label half is rendered. The submit half is asserted against the source:
* this project runs SSR-only component tests (`@testing-library/react` is not
* a dependency), so nothing here can type into the field and press save. What
* the source assertion pins is the wiring — that the glucose branch inverts
* through `toCanonicalMgdl` rather than sending the typed number on — and
* `src/lib/__tests__/glucose.test.ts` proves the conversion itself.
*/
let glucoseUnit: string | null = null;
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: "metric",
glucoseUnit,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
}
const formSource = readFileSync(
join(process.cwd(), "src/components/measurements/measurement-form.tsx"),
"utf8",
);
describe("MeasurementForm — blood-glucose entry unit", () => {
it("asks in mg/dL for an account that never chose", () => {
glucoseUnit = null;
const html = render();
expect(html).toContain("mg/dL");
expect(html).not.toContain("mmol/L");
expect(html).toContain('placeholder="95"');
});
it("asks in mmol/L once the account chose it", () => {
glucoseUnit = "mmol/L";
const html = render();
expect(html).toContain("mmol/L");
// A mmol/L reader must not be shown a three-digit mg/dL example.
expect(html).toContain('placeholder="5.3"');
expect(html).not.toContain('placeholder="95"');
});
it("inverts a glucose entry to canonical mg/dL before it is sent", () => {
expect(formSource).toMatch(
/if \(isGlucoseMode\) \{\s*canonicalValue = toCanonicalMgdl\(typed, glucoseUnit\);/,
);
});
it("resolves the unit from the account rather than assuming one", () => {
expect(formSource).toMatch(
/const glucoseUnit = resolveGlucoseUnit\(user\?\.glucoseUnit\)/,
);
});
});
@@ -6,45 +6,18 @@
* file a reading into a domain whose pages, charts and reads are all hidden,
* or be refused by the route. The form now filters its types through the
* surface map's type ownership (`summary:<type>`), which the test below holds
* equal to the measurement-scope ownership the server gates on.
* equal to the measurement-scope ownership the server gates on. That the
* mounted form neither opens on nor offers such a type is driven in
* `measurement-form.browser.test.tsx`.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { MeasurementType } from "@/generated/prisma/client";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { surfaceModule } from "@/lib/modules/surface";
const auth = vi.hoisted(() => ({
modules: undefined as Record<string, boolean> | undefined,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
role: "USER",
unitPreference: "metric",
glucoseUnit: null,
modules: auth.modules,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import {
MEASUREMENT_FORM_TYPE_VALUES,
MeasurementForm,
measurementFormTypes,
} from "../measurement-form";
@@ -77,17 +50,3 @@ describe("measurementFormTypes", () => {
expect(owned).toBeGreaterThan(0);
});
});
describe("<MeasurementForm> with a type's module off", () => {
it("does not open on that type, even when asked to", () => {
auth.modules = { glucose: false };
const html = renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="BLOOD_GLUCOSE" />
</I18nProvider>,
);
auth.modules = undefined;
expect(html).not.toContain("mg/dL");
expect(html).toContain("mmHg");
});
});
@@ -1,66 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.32.26 (issue #627) — the manual entry form labels its value field in the
* user's preferred unit for a type with a metric/imperial transform. A metric
* user sees "kg"; an imperial user sees "lb". The field is the entry boundary
* that converts the typed number back to canonical SI on submit, so the label
* and the stored unit must agree with the preference.
*/
let preference: "metric" | "imperial" = "metric";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
role: "USER",
unitPreference: preference,
},
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({
invalidateQueries: vi.fn(),
refetchQueries: vi.fn(),
}),
}));
vi.mock("@/lib/api/api-fetch", () => ({
apiPost: vi.fn().mockResolvedValue({}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { MeasurementForm } from "../measurement-form";
function render(): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<MeasurementForm defaultType="WEIGHT" />
</I18nProvider>,
);
}
describe("MeasurementForm — preferred-unit value label", () => {
it("labels the weight field in kg for a metric user", () => {
preference = "metric";
const html = render();
expect(html).toContain("kg");
expect(html).not.toContain("lb");
// The metric placeholder (kg) is shown, not the imperial one.
expect(html).toContain('placeholder="75.5"');
});
it("labels the weight field in lb for an imperial user", () => {
preference = "imperial";
const html = render();
expect(html).toContain("lb");
// The imperial placeholder (lb) is shown.
expect(html).toContain('placeholder="165"');
});
});
@@ -0,0 +1,309 @@
/**
* `<MeasurementForm>` driven by typing and pressing Save, with the request
* body read off the network boundary. What the markup tests could only pin
* against the source is asserted on the wire here: a weight typed in lb, a
* glucose typed in mmol/L and a sleep typed as "7:30" each arrive in their
* canonical unit.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import type { AuthUser } from "@/hooks/use-auth";
import { pinDate } from "../../../../tests/browser/clock";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { MeasurementForm } from "../measurement-form";
const NOW = "2026-09-15T10:00:00.000Z";
let api: FetchStub;
beforeEach(() => {
pinDate(NOW);
api = stubFetch().on("POST", "/api/measurements", { status: 201, data: {} });
});
async function mount(
props: Parameters<typeof MeasurementForm>[0] = {},
user: Partial<AuthUser> = {},
) {
const onSuccess = vi.fn();
const { screen } = await renderWithApp(
<MeasurementForm onSuccess={onSuccess} onCancel={() => {}} {...props} />,
{ user },
);
const save = screen.getByRole("button", { name: "Save" });
return { screen, onSuccess, save };
}
const posted = () => api.calls("POST", "/api/measurements").map((r) => r.body);
describe("<MeasurementForm> blood pressure", () => {
it("sends systolic, diastolic and pulse as one batch at the chosen instant", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("128");
await screen.getByLabelText(/^Diastolic/).fill("84");
await screen.getByLabelText(/^Pulse/).fill("66");
await screen.getByLabelText(/^Notes/).fill("after the stairs");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const at = NOW;
expect(posted()).toEqual([
[
{
type: "BLOOD_PRESSURE_SYS",
value: 128,
measuredAt: at,
notes: "after the stairs",
},
{
type: "BLOOD_PRESSURE_DIA",
value: 84,
measuredAt: at,
notes: "after the stairs",
},
{ type: "PULSE", value: 66, measuredAt: at, notes: "after the stairs" },
],
]);
// Only the write: nothing else in the cache needed refetching.
expect(api.requests.map((r) => `${r.method} ${r.path}`)).toEqual([
"POST /api/measurements",
]);
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
});
it("leaves the pulse out when it was not typed", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("118");
await screen.getByLabelText(/^Diastolic/).fill("76");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [batch] = posted() as Array<Array<{ type: string }>>;
expect(batch.map((e) => e.type)).toEqual([
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
]);
});
it("sends nothing while a required reading is missing", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
const sys = screen
.getByLabelText(/^Systolic/)
.element() as HTMLInputElement;
expect(sys.validity.valueMissing).toBe(true);
expect(api.requests).toEqual([]);
expect(onSuccess).not.toHaveBeenCalled();
});
it("holds Save and the menu while the write is pending", async () => {
const pending = api.deferred("POST", "/api/measurements");
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect.element(save).toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "More options" }))
.toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "Cancel" }))
.toBeDisabled();
expect(onSuccess).not.toHaveBeenCalled();
pending.release({ status: 201, data: {} });
await expect.element(save).toBeEnabled();
expect(onSuccess).toHaveBeenCalledOnce();
});
it("names a refused write in the reader's words and keeps what was typed", async () => {
api.on("POST", "/api/measurements", {
status: 409,
error: "Duplicate measurement",
meta: { errorCode: "measurement.duplicate_timestamp" },
});
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_PRESSURE",
});
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Diastolic/).fill("80");
await save.click();
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("A measurement already exists for this time.");
await expect
.element(screen.getByLabelText(/^Systolic/))
.toHaveAttribute("aria-invalid", "true");
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(120);
expect(onSuccess).not.toHaveBeenCalled();
});
});
describe("<MeasurementForm> units on the way out", () => {
it("asks an imperial reader for lb and stores kg", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "WEIGHT" },
{ unitPreference: "imperial" },
);
const field = screen.getByLabelText("Value (lb)");
await expect.element(field).toHaveAttribute("placeholder", "165");
await field.fill("210");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 95.25, measuredAt: NOW },
]);
});
it("reads a decimal comma for a metric reader", async () => {
const { screen, save, onSuccess } = await mount({ defaultType: "WEIGHT" });
const field = screen.getByLabelText("Value (kg)");
await expect.element(field).toHaveAttribute("placeholder", "75.5");
await field.fill("75,5");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "WEIGHT", value: 75.5, measuredAt: NOW },
]);
});
it("asks in mg/dL for an account that never chose a glucose unit", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "BLOOD_GLUCOSE",
});
const field = screen.getByLabelText("Value (mg/dL)");
await expect.element(field).toHaveAttribute("placeholder", "95");
await field.fill("104");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 104,
measuredAt: NOW,
glucoseContext: "FASTING",
},
]);
});
it("asks a mmol/L reader in mmol/L, stores mg/dL and sends the meal context", async () => {
const { screen, save, onSuccess } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ glucoseUnit: "mmol/L" },
);
const field = screen.getByLabelText("Value (mmol/L)");
await expect.element(field).toHaveAttribute("placeholder", "5.3");
await field.fill("5.5");
await screen.getByRole("combobox", { name: /^Context/ }).click();
await screen
.getByRole("option", { name: "Post-meal (2h after eating)" })
.click();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{
type: "BLOOD_GLUCOSE",
value: 99,
measuredAt: NOW,
glucoseContext: "POSTPRANDIAL",
},
]);
});
it("reads a sleep typed as hours and minutes and stores minutes", async () => {
const { screen, save, onSuccess } = await mount({
defaultType: "SLEEP_DURATION",
});
await screen.getByLabelText("Value (hours)").fill("7:30");
await expect.element(screen.getByText("Read as 7 h 30 min")).toBeVisible();
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()).toEqual([
{ type: "SLEEP_DURATION", value: 450, measuredAt: NOW },
]);
});
it("refuses a duration it cannot read, under the field", async () => {
const { screen, save } = await mount({ defaultType: "SLEEP_DURATION" });
const field = screen.getByLabelText("Value (hours)");
await field.fill("most of the night");
await save.click();
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.toBeVisible();
await expect.element(field).toHaveAttribute("aria-invalid", "true");
expect(api.requests).toEqual([]);
await field.fill("7.5");
await expect
.element(screen.getByText("Enter a duration like 7.5, 7:30 or 7h 30m."))
.not.toBeInTheDocument();
});
});
describe("<MeasurementForm> type", () => {
it("relabels the value field when the type changes", async () => {
const { screen } = await mount({ defaultType: "WEIGHT" });
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Body temperature" }).click();
await expect.element(screen.getByLabelText("Value (°C)")).toBeVisible();
await screen.getByRole("combobox", { name: /^Type/ }).click();
await screen.getByRole("option", { name: "Blood Pressure" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toBeVisible();
});
it("does not open on, or offer, a type whose module is off", async () => {
const { screen } = await mount(
{ defaultType: "BLOOD_GLUCOSE" },
{ modules: { glucose: false } },
);
const type = screen.getByRole("combobox", { name: /^Type/ });
await expect.element(type).toHaveTextContent("Blood Pressure");
await type.click();
await expect
.element(screen.getByRole("option", { name: "Weight" }))
.toBeVisible();
await expect
.element(screen.getByRole("option", { name: "Blood glucose" }))
.not.toBeInTheDocument();
await userEvent.keyboard("{Escape}");
});
it("opens on the type saved last when no type is asked for", async () => {
const first = await mount({ defaultType: "WEIGHT" });
await first.screen.getByLabelText("Value (kg)").fill("80");
await first.save.click();
await expect.poll(() => first.onSuccess.mock.calls.length).toBe(1);
first.screen.unmount();
const { screen } = await mount();
await expect
.element(screen.getByRole("combobox", { name: /^Type/ }))
.toHaveTextContent("Weight");
});
it("clears every field from the menu's Reset", async () => {
const { screen } = await mount({ defaultType: "BLOOD_PRESSURE" });
await screen.getByLabelText(/^Systolic/).fill("120");
await screen.getByLabelText(/^Notes/).fill("left arm");
await screen.getByRole("button", { name: "More options" }).click();
await screen.getByRole("menuitem", { name: "Reset form" }).click();
await expect.element(screen.getByLabelText(/^Systolic/)).toHaveValue(null);
await expect.element(screen.getByLabelText(/^Notes/)).toHaveValue("");
});
});
@@ -0,0 +1,331 @@
/**
* The questionnaire check-in, driven by clicks and keys in a real browser.
*
* The step arithmetic is pinned on its own in `check-in-nav.test.ts`; this
* suite proves the wizard a person uses: one question at a time in the source
* order, Back keeping the answer, the number keys, the review with a way back
* to every answer, the optional PHQ-9 follow-up, the note over an unvalidated
* translation staying on screen with every question, and the body the page
* posts. The questionnaire text loads through the production chunk loader,
* not a seeded cache.
*/
import { describe, expect, it, vi } from "vitest";
import { page, userEvent } from "vitest/browser";
import { WORDING } from "@/lib/mental-health/wording";
import type { Locale } from "@/lib/i18n/config";
import { stubFetch } from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { CheckInWizard } from "../check-in-wizard";
import { MentalWellbeing } from "../mental-wellbeing";
import type { CheckInAnswers, InstrumentId } from "../types";
const WHO5 = WORDING.WHO5.en;
const PHQ9 = WORDING.PHQ9.en;
async function wizard(
instrument: InstrumentId,
{
locale = "en",
isError = false,
}: { locale?: Locale; isError?: boolean } = {},
) {
const onSubmit = vi.fn<(answers: CheckInAnswers) => void>();
const onBack = vi.fn();
const { screen } = await renderWithApp(
<CheckInWizard
instrument={instrument}
onSubmit={onSubmit}
onBack={onBack}
isPending={false}
isError={isError}
/>,
{ locale },
);
const heading = screen.getByRole("heading", { level: 2 });
/** The step on screen asks `text` (waiting out the language's chunk). */
const expectQuestion = (text: string) =>
expect.element(heading).toHaveTextContent(text);
const answer = (name: string) =>
screen.getByRole("radio", { name, exact: true });
const button = (name: string) =>
screen.getByRole("button", { name, exact: true });
return { screen, onSubmit, onBack, expectQuestion, answer, button };
}
/**
* Answer the current step by its position, as a keyboard user does: each new
* step focuses its question, so Tab first reaches the answers, then the
* number key chooses.
*/
async function pressAnswer(position: number) {
await userEvent.tab();
await userEvent.keyboard(String(position));
}
describe("<CheckInWizard> steps", () => {
it("opens on the first question with the stem and the answers in source order", async () => {
const { screen, button } = await wizard("WHO5");
await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
await expect.element(screen.getByText(WHO5.stems.period)).toBeVisible();
await expect.element(screen.getByText("Question 1 of 5")).toBeVisible();
const labels = screen
.getByRole("radio")
.elements()
.map((el) => el.textContent);
expect(labels).toEqual([5, 4, 3, 2, 1, 0].map((v) => WHO5.anchors.all[v]));
await expect.element(button("Back")).toBeDisabled();
await expect.element(button("Next")).toBeDisabled();
await expect.element(button("See my result")).not.toBeInTheDocument();
// The instrument's own explanation heads the check-in; the landing's
// self-test disclaimer does not follow the person into it.
await expect
.element(
screen.getByText(
"5-question well-being check. Answer for the last two weeks; higher numbers mean better well-being.",
),
)
.toBeVisible();
await expect
.element(screen.getByText(/not a diagnosis/i))
.not.toBeInTheDocument();
// English is the validated wording: no note, no switch.
await expect
.element(screen.getByText(/has not been scientifically validated/))
.not.toBeInTheDocument();
});
it("gives each SCI item its own answers under its section stem", async () => {
const SCI = WORDING.SCI.en;
const { screen, expectQuestion } = await wizard("SCI");
await expectQuestion(SCI.items[0]);
await expect.element(screen.getByText(SCI.stems.night)).toBeVisible();
await expect.element(screen.getByText("Question 1 of 8")).toBeVisible();
expect(
screen
.getByRole("radio")
.elements()
.map((el) => el.textContent),
).toEqual([4, 3, 2, 1, 0].map((v) => SCI.anchors.duration[v]));
});
it("advances on a choice, and Back returns with the answer still chosen", async () => {
const { screen, answer, button, expectQuestion } = await wizard("WHO5");
await answer(WHO5.anchors.all[3]).click();
await expect.element(screen.getByText("Question 2 of 5")).toBeVisible();
await expectQuestion(WHO5.items[1]);
await expect.element(button("Next")).toBeDisabled();
await button("Back").click();
await expectQuestion(WHO5.items[0]);
await expect.element(answer(WHO5.anchors.all[3])).toBeChecked();
await button("Next").click();
await expectQuestion(WHO5.items[1]);
});
it("chooses by number key, moves with the arrows and focuses each new question", async () => {
const { screen, answer } = await wizard("WHO5");
await answer(WHO5.anchors.all[5]).click();
await expect
.element(screen.getByRole("heading", { name: WHO5.items[1] }))
.toHaveFocus();
await userEvent.tab();
await expect.element(answer(WHO5.anchors.all[5])).toHaveFocus();
await userEvent.keyboard("{ArrowDown}{ArrowDown}");
await expect.element(answer(WHO5.anchors.all[3])).toHaveFocus();
await userEvent.keyboard("{End}");
await expect.element(answer(WHO5.anchors.all[0])).toHaveFocus();
// "2" is the second answer as printed, which scores 4 on the WHO-5.
await userEvent.keyboard("2");
await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
await screen.getByRole("button", { name: "Back", exact: true }).click();
await expect.element(answer(WHO5.anchors.all[4])).toBeChecked();
});
it("reviews every answer, changes one and returns to the review", async () => {
const { screen, onSubmit, button } = await wizard("WHO5");
for (const position of [1, 2, 3, 4, 5]) await pressAnswer(position);
await expect
.element(screen.getByRole("heading", { name: "Check your answers" }))
.toBeVisible();
await expect
.element(screen.getByText("All 5 questions answered"))
.toBeVisible();
await screen
.getByRole("button", { name: "Change the answer to question 3" })
.click();
await expect.element(screen.getByText("Question 3 of 5")).toBeVisible();
await screen
.getByRole("radio", { name: WHO5.anchors.all[0], exact: true })
.click();
await expect
.element(screen.getByRole("heading", { name: "Check your answers" }))
.toBeVisible();
await button("See my result").click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
items: [5, 4, 0, 2, 1],
functionalDifficulty: undefined,
englishOriginal: false,
});
});
it("asks the PHQ-9 follow-up as an optional step that a second press clears", async () => {
const { screen, onSubmit, answer, button, expectQuestion } =
await wizard("PHQ9");
for (let i = 0; i < 9; i++) await pressAnswer(1);
await expect
.element(screen.getByText("Follow-up question (optional)"))
.toBeVisible();
await expectQuestion(PHQ9.functional!.title);
await expect.element(button("Review answers")).toBeEnabled();
await answer(PHQ9.functional!.anchors[2]).click();
await expect.element(answer(PHQ9.functional!.anchors[2])).toBeChecked();
await answer(PHQ9.functional!.anchors[2]).click();
await expect.element(answer(PHQ9.functional!.anchors[2])).not.toBeChecked();
await button("Review answers").click();
await expect.element(screen.getByText("Not answered")).toBeVisible();
await button("See my result").click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith({
items: [0, 0, 0, 0, 0, 0, 0, 0, 0],
functionalDifficulty: undefined,
englishOriginal: false,
});
});
it("returns to the overview and shows a failed submit", async () => {
const { screen, onBack } = await wizard("GAD7", { isError: true });
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("Something went wrong. Please try again.");
await screen.getByRole("button", { name: "Overview" }).click();
expect(onBack).toHaveBeenCalledOnce();
});
});
describe("<CheckInWizard> in the reader's language", () => {
const SCI_DE = WORDING.SCI.de;
const NOTE =
"Diese Übersetzung ist nicht wissenschaftlich validiert; nimm das Ergebnis als Orientierung.";
it("keeps the note over an unvalidated translation on screen with every question", async () => {
await page.viewport(390, 640);
const { screen, expectQuestion } = await wizard("SCI", { locale: "de" });
expect(SCI_DE.validated).toBe(false);
const note = screen.getByText(NOTE);
for (let i = 0; i < 8; i++) {
await expectQuestion(SCI_DE.items[i]);
await expect.element(note).toBeVisible();
const rect = note.element().getBoundingClientRect();
expect(rect.top, `step ${i + 1}`).toBeGreaterThanOrEqual(0);
expect(rect.bottom, `step ${i + 1}`).toBeLessThanOrEqual(
window.innerHeight,
);
// The first question arrives with the language's chunk, after the
// focus move has run, so it is answered by pointer; the rest by key.
if (i === 0) await screen.getByRole("radio").first().click();
else await pressAnswer(1);
}
await expect
.element(screen.getByRole("heading", { name: "Antworten prüfen" }))
.toBeVisible();
await expect.element(note).toBeVisible();
});
it("switches to the English original and says so in the answers", async () => {
const { screen, onSubmit, expectQuestion } = await wizard("SCI", {
locale: "de",
});
await expectQuestion(SCI_DE.items[0]);
await screen
.getByRole("button", { name: "Englischen Originaltext zeigen" })
.click();
await expect
.element(screen.getByRole("heading", { name: WORDING.SCI.en.items[0] }))
.toHaveAttribute("lang", "en");
// The note stays: the reader is still in a language with no validated form.
await expect.element(screen.getByText(NOTE)).toBeVisible();
await screen.getByRole("radio").first().click();
for (let i = 1; i < 8; i++) await pressAnswer(1);
await screen
.getByRole("button", { name: "Ergebnis ansehen", exact: true })
.click();
expect(onSubmit).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ englishOriginal: true }),
);
});
it("shows a validated translation with no note and no switch", async () => {
const { screen, expectQuestion } = await wizard("PHQ9", { locale: "nl" });
await expectQuestion(WORDING.PHQ9.nl.items[0]);
await expect
.element(screen.getByText(WORDING.PHQ9.nl.stems.period))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: /English/ }))
.not.toBeInTheDocument();
});
});
describe("the check-in from the overview", () => {
it("posts the answers with the instrument and the reader's language, then shows the result", async () => {
const api = stubFetch().on("GET", "/api/mental-health/assessments", {
data: { assessments: [] },
});
const post = api.deferred("POST", "/api/mental-health/assessments");
const { screen } = await renderWithApp(<MentalWellbeing />);
const start =
'[data-instrument="WHO5"] [data-slot="instrument-card-start"]';
await expect.poll(() => document.querySelector(start)).toBeTruthy();
await page.elementLocator(document.querySelector(start)!).click();
await expect.element(screen.getByText(WHO5.items[0])).toBeVisible();
await screen.getByRole("radio").nth(1).click();
for (const position of [2, 3, 1, 2]) await pressAnswer(position);
const submit = screen.getByRole("button", { name: "See my result" });
await submit.click();
await expect
.element(screen.getByRole("button", { name: "Saving…" }))
.toBeDisabled();
expect(api.calls("POST", "/api/mental-health/assessments")).toHaveLength(1);
expect(api.calls("POST", "/api/mental-health/assessments")[0].body).toEqual(
{
instrument: "WHO5",
locale: "en",
items: [4, 4, 3, 5, 4],
englishOriginal: false,
},
);
post.release({
status: 201,
data: {
assessment: {
id: "a-1",
instrument: "WHO5",
locale: "en",
wording: "validated",
totalScore: 80,
severityBand: "good",
item9Flagged: false,
crisisShownAt: null,
takenAt: "2026-09-15T10:00:00.000Z",
},
actionThreshold: 50,
crisis: null,
},
});
await expect
.element(screen.getByText("80", { exact: true }).first())
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "See my result" }))
.not.toBeInTheDocument();
});
});
@@ -6,15 +6,14 @@ import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import en from "../../../../messages/en.json";
import nl from "../../../../messages/nl.json";
import { WORDING } from "@/lib/mental-health/wording";
import { seedQuestionnaireWording } from "@/__tests__/helpers/questionnaire-wording";
/**
* Mental-wellbeing surface redesign (v1.25.3). The SSR-only convention
* (`renderToStaticMarkup`, `environment: "node"`, no `@testing-library/react`)
* means clicks can't be driven here — the wizard's advance/back grammar is
* pinned in `check-in-nav.test.ts`. These tests pin the SSR-observable
* contracts: the disclaimer renders ONLY on the landing (never while testing),
* Mental-wellbeing surface redesign (v1.25.3). The check-in wizard itself is
* driven in a real browser by `check-in-wizard.browser.test.tsx` (steps,
* keys, review, follow-up, the unvalidated-translation note, the posted
* body); its step arithmetic is pinned in `check-in-nav.test.ts`. These tests
* pin the rendered contracts of the rest of the surface: the disclaimer renders ONLY on the landing (never while testing),
* a positive item-9 surfaces the crisis card, and (v1.27.9) the landing is
* intro + instrument cards only — the dated history + trend chart live in the
* per-instrument detail a card click opens, pinned to one instrument.
@@ -74,52 +73,6 @@ describe("landing chrome (v1.25.5 — disclaimer + heading removed)", () => {
// …and the lead description still introduces the screeners.
expect(html).toContain(mh.pageDescription);
});
it("does NOT render any disclaimer inside the check-in wizard", () => {
const html = withProviders(
<CheckInWizard
instrument="PHQ9"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).not.toContain('data-slot="mental-health-disclaimer"');
// …but the standardized instrument explanation IS shown.
expect(html).toContain(mh.instrumentDescription.phq9);
});
});
describe("check-in wizard scaffolding (v1.27.6 — questions only)", () => {
const html = withProviders(
<CheckInWizard
instrument="PHQ9"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
it("no longer renders the question-overview strip", () => {
expect(html).not.toContain('data-slot="wizard-stepper"');
});
it("no longer renders the review recap or the functional follow-up", () => {
expect(html).not.toContain("You have answered");
expect(html).not.toContain('data-slot="check-in-submit"');
});
it("opens on the first question with the Back/Next footer", () => {
expect(html).toContain(WORDING.PHQ9.en.items[0]);
expect(html).toContain('data-slot="check-in-back"');
expect(html).toContain('data-slot="check-in-next"');
// Back is disabled on the first step.
expect(html).toMatch(
/data-slot="check-in-back"[^>]*disabled|disabled[^>]*data-slot="check-in-back"/,
);
});
});
describe("item-9 crisis surfacing on the result", () => {
@@ -243,49 +196,6 @@ describe("WHO-5 / SCI on the same infrastructure (v1.27.9)", () => {
expect(html).not.toContain("Rhythmus");
});
it("WHO-5 wizard opens on item 1 with the six-point scale in source order and the recall stem", () => {
const html = withProviders(
<CheckInWizard
instrument="WHO5"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(WORDING.WHO5.en.items[0]);
expect(html).toContain('data-slot="check-in-stem"');
expect(html).toContain(WORDING.WHO5.en.stems.period);
// Six anchors, 5 → 0 (the WHO form leads with "All of the time").
for (const v of [5, 4, 3, 2, 1, 0]) {
expect(html).toContain(WORDING.WHO5.en.anchors.all[v]);
}
// English items are the validated wording for en — no translation note.
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
expect(html).not.toContain('data-slot="check-in-wording-toggle"');
// No functional follow-up outside the PHQ-9: 5 steps total.
expect(html).toContain("Question 1 of 5");
});
it("SCI wizard opens on item 1 with the item-specific duration anchors and section stem", () => {
const html = withProviders(
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>,
);
expect(html).toContain(WORDING.SCI.en.items[0]);
expect(html).toContain(WORDING.SCI.en.stems.night);
// Item 1 carries the sleep-latency time ranges, 4 → 0.
for (const v of [4, 3, 2, 1, 0]) {
expect(html).toContain(WORDING.SCI.en.anchors.duration[v]);
}
expect(html).toContain("Question 1 of 8");
});
it("shows the gentle PHQ-9 pointer on a WHO-5 total of 50 or below", () => {
const result: CreateResponse = {
assessment: {
@@ -530,23 +440,20 @@ describe("questionnaires in the reader's language", () => {
);
}
function wizard(instrument: "PHQ9" | "SCI") {
return (
<CheckInWizard
instrument={instrument}
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>
);
}
// Kept here rather than in the browser suite: the frame before the
// language's chunk arrives is the one a server render always paints, and a
// browser loads the chunk before a test can look.
it("holds the step's place while the language's text loads", () => {
const html = renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="nl" initialMessages={nl}>
{wizard("SCI")}
<CheckInWizard
instrument="SCI"
onSubmit={() => {}}
onBack={() => {}}
isPending={false}
isError={false}
/>
</I18nProvider>
</QueryClientProvider>,
);
@@ -555,30 +462,6 @@ describe("questionnaires in the reader's language", () => {
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
});
it("the PHQ-9 in Dutch is the validated Dutch form, with no note and no switch", () => {
const html = renderIn("nl", wizard("PHQ9"));
const dutch = WORDING.PHQ9.nl;
expect(html).toContain(dutch.stems.period);
expect(html).toContain(dutch.items[0]);
for (const v of [0, 1, 2, 3]) expect(html).toContain(dutch.anchors.all[v]);
expect(html).not.toContain(WORDING.PHQ9.en.items[0]);
expect(html).not.toContain('data-slot="check-in-unvalidated-note"');
expect(html).not.toContain('data-slot="check-in-wording-toggle"');
});
it("the SCI in Dutch shows the translation under the note, with the switch to the English original", () => {
const html = renderIn("nl", wizard("SCI"));
const dutch = WORDING.SCI.nl;
expect(dutch.validated).toBe(false);
expect(html).toContain(dutch.stems.night);
expect(html).toContain(dutch.items[0]);
expect(html).not.toContain(WORDING.SCI.en.items[0]);
expect(html).toContain('data-slot="check-in-unvalidated-note"');
expect(html).toContain(nl.mentalHealth.wording.unvalidatedNote);
expect(html).toContain('data-slot="check-in-wording-toggle"');
expect(html).toContain(nl.mentalHealth.wording.showOriginal);
});
it("the result and the history mark an unvalidated translation, and only that", () => {
const row = (wording: AssessmentRow["wording"]): AssessmentRow => ({
id: `s-${wording}`,
@@ -1,166 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
import { FilterBar, FilterBarDateRange, FilterBarSelect } from "../filter-bar";
import { primeLocales } from "@/__tests__/helpers/prime-locales";
primeLocales("de");
/**
* v1.16.1 — unified filter rail. SSR-markup guards for the shared
* grammar: pill = trigger + active chip in one element, reset only
* while filtered, count slot, per-pill clear button with a translated
* aria-label.
*/
const options = [
{ value: "WEIGHT", label: "Weight" },
{ value: "PULSE", label: "Pulse" },
];
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
describe("FilterBarSelect", () => {
it("inactive: shows only the dimension name, no clear button", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarSelect
label="Type"
value="ALL"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-slot="filter-bar-pill"');
expect(html).toContain('data-active="false"');
expect(html).toContain("Type");
expect(html).not.toContain("Clear filter");
});
it("active: shows `label: value`, active styling and a labelled clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Type"
value="WEIGHT"
onValueChange={() => {}}
allLabel="All types"
options={options}
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
expect(html).toContain("Weight");
expect(html).toContain('aria-label="Clear filter: Type"');
});
});
describe("FilterBarDateRange", () => {
it("inactive: pill shows the dimension name only", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from=""
to=""
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="false"');
expect(html).toContain("Date range");
});
it("active: pill shows the formatted bounds and a clear button", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarDateRange
label="Date range"
from="2026-05-12"
to="2026-06-10"
onFromChange={() => {}}
onToChange={() => {}}
idPrefix="probe"
/>
</FilterBar>,
);
expect(html).toContain('data-active="true"');
// en dateShort: "05/12" / "06/10" — assert both day fragments are in.
expect(html).toMatch(/05\/12/);
expect(html).toMatch(/06\/10/);
expect(html).toContain('aria-label="Clear filter: Date range"');
});
});
describe("FilterBar shell", () => {
it("shows the reset action only while filtered", () => {
const inactive = render(
<FilterBar isFiltered={false} onReset={() => {}}>
<span />
</FilterBar>,
);
const active = render(
<FilterBar isFiltered onReset={() => {}}>
<span />
</FilterBar>,
);
expect(inactive).not.toContain("Reset");
expect(active).toContain("Reset");
});
it("renders the result count slot", () => {
const html = render(
<FilterBar isFiltered={false} onReset={() => {}} count="42 readings">
<span />
</FilterBar>,
);
expect(html).toContain("42 readings");
expect(html).toContain("tabular-nums");
});
it("localises reset + clear labels in German", () => {
const html = render(
<FilterBar isFiltered onReset={() => {}}>
<FilterBarSelect
label="Quelle"
value="MANUAL"
onValueChange={() => {}}
allLabel="Alle Quellen"
options={[{ value: "MANUAL", label: "Manuell" }]}
/>
</FilterBar>,
"de",
);
expect(html).toContain("Zurücksetzen");
expect(html).toContain('aria-label="Filter entfernen: Quelle"');
});
});
describe("FilterBarSelect — menu positioning (source pin)", () => {
// The pill trigger paints its own label spans and mounts no
// `<SelectValue>`. Radix's default item-aligned placement silently
// skips ALL positioning when the value node is absent — the open
// menu then lays out below the height-locked app shell, fully
// off-viewport, which users report as "clicking does nothing". SSR
// markup cannot exercise an open portal, so this pins the source:
// the content must request popper placement, which has no
// value-node dependency.
it("requests popper placement on the select content", () => {
const src = readFileSync(
join(process.cwd(), "src/components/ui/filter-bar.tsx"),
"utf8",
);
expect(src).toMatch(/<SelectContent\s+position="popper"\s+align="start">/);
});
});
@@ -0,0 +1,81 @@
/**
* Segmented is the one segmented control in the tree. Its behaviour (roving
* focus, toggle mode, disabled, the chosen segment's fill) is driven in a real
* browser by `segmented.browser.test.tsx`; this file holds the source sweeps
* that keep a second control from appearing beside it.
*/
import { describe, expect, it } from "vitest";
describe("one segmented control", () => {
it("is the only segmented-control component in the tree", async () => {
const { join } = await import("node:path");
const { readSourceText, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const defining = walkSourceFiles(src, { floor: 2000 })
.filter((p) => !p.includes("__tests__/"))
.filter((p) =>
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
readSourceText(join(src, p)),
),
);
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { join } = await import("node:path");
const { readSourceText, stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) => pressedByValue(stripComments(readSourceText(join(src, p)))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
@@ -0,0 +1,229 @@
import { useState } from "react";
import { LayoutGrid, Table } from "lucide-react";
import { describe, expect, it, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { renderWithApp } from "../../../../tests/browser/render";
import { Segmented, type SegmentedProps } from "../segmented";
type Zoom = "day" | "week" | "month";
const OPTIONS = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
] as const;
/**
* The control as a caller holds it: the value lives in the parent and moves
* only when `onChange` says so, which is what makes the roving tab stop
* follow the selection.
*/
function Controlled({
initial = "day",
onChange,
...props
}: Partial<Omit<SegmentedProps<Zoom>, "value">> & { initial?: Zoom }) {
const [value, setValue] = useState<Zoom>(initial);
return (
<>
<button type="button">Before</button>
<Segmented
options={OPTIONS}
label="Zoom"
slot="zoom"
{...props}
value={value}
onChange={(next) => {
onChange?.(next);
setValue(next);
}}
/>
<button type="button">After</button>
</>
);
}
async function mount(props: Parameters<typeof Controlled>[0] = {}) {
const onChange = vi.fn();
const { screen } = await renderWithApp(
<Controlled onChange={onChange} {...props} />,
);
const radio = (name: string) => screen.getByRole("radio", { name });
return { screen, onChange, radio };
}
describe("<Segmented> in radio mode", () => {
it("is one tab stop, on the chosen segment", async () => {
const { screen, radio } = await mount({ initial: "week" });
await expect
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
.toBeInTheDocument();
await expect.element(radio("Week")).toBeChecked();
await expect.element(radio("Day")).not.toBeChecked();
await screen.getByRole("button", { name: "Before" }).click();
await userEvent.tab();
await expect.element(radio("Week")).toHaveFocus();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "After" }))
.toHaveFocus();
});
it("moves focus and selection together with the arrow keys", async () => {
const { onChange, radio } = await mount();
await radio("Day").click();
onChange.mockClear();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio("Week")).toHaveFocus();
await expect.element(radio("Week")).toBeChecked();
await userEvent.keyboard("{ArrowDown}");
await expect.element(radio("Month")).toBeChecked();
await userEvent.keyboard("{ArrowLeft}");
await expect.element(radio("Week")).toHaveFocus();
expect(onChange.mock.calls).toEqual([["week"], ["month"], ["week"]]);
});
it("wraps at both ends", async () => {
const { radio } = await mount({ initial: "month" });
await radio("Month").click();
await userEvent.keyboard("{ArrowRight}");
await expect.element(radio("Day")).toHaveFocus();
await expect.element(radio("Day")).toBeChecked();
await userEvent.keyboard("{ArrowUp}");
await expect.element(radio("Month")).toHaveFocus();
await expect.element(radio("Month")).toBeChecked();
});
it("jumps to the first and last segment with Home and End", async () => {
const { onChange, radio } = await mount({ initial: "week" });
await radio("Week").click();
await userEvent.keyboard("{End}");
await expect.element(radio("Month")).toHaveFocus();
await userEvent.keyboard("{Home}");
await expect.element(radio("Day")).toHaveFocus();
await expect.element(radio("Day")).toBeChecked();
expect(onChange).toHaveBeenLastCalledWith("day");
});
it("fires again on the chosen segment, which can open something", async () => {
const { onChange, radio } = await mount({ initial: "day" });
await radio("Day").click();
await radio("Day").click();
expect(onChange.mock.calls).toEqual([["day"], ["day"]]);
});
it("takes no press and no key while disabled", async () => {
const { screen, onChange, radio } = await mount({ disabled: true });
await expect
.element(screen.getByRole("radiogroup", { name: "Zoom" }))
.toHaveAttribute("aria-disabled", "true");
await expect.element(radio("Week")).toBeDisabled();
await radio("Week").click({ force: true });
await screen.getByRole("button", { name: "Before" }).click();
await userEvent.tab();
await userEvent.keyboard("{ArrowRight}{End}");
expect(onChange).not.toHaveBeenCalled();
await expect.element(radio("Day")).toBeChecked();
});
});
describe("<Segmented> in toggle mode", () => {
it("renders pressed buttons and ignores a press on the active one", async () => {
const { screen, onChange } = await mount({ mode: "toggle" });
await expect
.element(screen.getByRole("group", { name: "Zoom" }))
.toBeInTheDocument();
expect(screen.getByRole("radio").elements()).toHaveLength(0);
const day = screen.getByRole("button", { name: "Day" });
const week = screen.getByRole("button", { name: "Week" });
await expect.element(day).toHaveAttribute("aria-pressed", "true");
await day.click();
expect(onChange).not.toHaveBeenCalled();
await week.click();
expect(onChange).toHaveBeenCalledExactlyOnceWith("week");
await expect.element(week).toHaveAttribute("aria-pressed", "true");
await expect.element(day).toHaveAttribute("aria-pressed", "false");
});
it("leaves every segment its own tab stop and the arrows alone", async () => {
const { screen, onChange } = await mount({ mode: "toggle" });
await screen.getByRole("button", { name: "Day" }).click();
await userEvent.keyboard("{ArrowRight}");
expect(onChange).not.toHaveBeenCalled();
await userEvent.tab();
await expect
.element(screen.getByRole("button", { name: "Week" }))
.toHaveFocus();
});
});
describe("<Segmented> look", () => {
it("fills only the chosen segment in the primary colour, on no track", async () => {
const { screen, radio } = await mount({ initial: "week" });
const group = screen.getByRole("radiogroup").element();
const probe = document.createElement("span");
probe.className = "bg-primary";
document.body.append(probe);
const primary = getComputedStyle(probe).backgroundColor;
probe.remove();
const background = (name: string) =>
getComputedStyle(radio(name).element()).backgroundColor;
expect(background("Week")).toBe(primary);
expect(background("Day")).not.toBe(primary);
expect(getComputedStyle(group).backgroundColor).toBe("rgba(0, 0, 0, 0)");
});
it("sets the small size in smaller type and keeps the 36 px floor", async () => {
const { radio } = await mount();
const md = radio("Day").element();
expect(getComputedStyle(md).fontSize).toBe("14px");
expect(md.getBoundingClientRect().height).toBeGreaterThanOrEqual(36);
const { screen } = await renderWithApp(
<Segmented
options={OPTIONS}
value="day"
onChange={() => {}}
label="Range"
size="sm"
/>,
);
const sm = screen
.getByRole("radiogroup", { name: "Range" })
.getByRole("radio", { name: "Day" })
.element();
expect(getComputedStyle(sm).fontSize).toBe("12px");
expect(sm.getBoundingClientRect().height).toBeGreaterThanOrEqual(44);
});
it("names icon-only segments without showing the label", async () => {
const onChange = vi.fn();
const { screen } = await renderWithApp(
<Segmented
mode="toggle"
iconOnly
options={[
{ value: "cards", label: "Cards", icon: LayoutGrid },
{ value: "table", label: "Table", icon: Table },
]}
value="cards"
onChange={onChange}
label="View"
optionSlot={(v) => `medications-view-${v}`}
/>,
);
const table = screen.getByRole("button", { name: "Table" });
await expect.element(table).toHaveAttribute("title", "Table");
await expect
.element(table)
.toHaveAttribute("data-slot", "medications-view-table");
await expect.element(screen.getByText("Cards")).not.toBeInTheDocument();
await table.click();
expect(onChange).toHaveBeenCalledWith("table");
});
});
@@ -1,227 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { isValidElement, type ReactElement, type ReactNode } from "react";
import { LayoutGrid, Table } from "lucide-react";
import { Segmented, type SegmentedProps } from "../segmented";
const OPTIONS = [
{ value: "recent", label: "Last 90 days" },
{ value: "all", label: "All" },
] as const;
type Props = SegmentedProps<"recent" | "all">;
function html(props: Partial<Props> = {}) {
return renderToStaticMarkup(
<Segmented
options={OPTIONS}
value="recent"
onChange={() => {}}
label="History"
slot="history-view"
{...props}
/>,
);
}
/** The segment buttons of a rendered tree, for driving their handlers. */
function buttons(node: ReactNode): ReactElement<{ onClick: () => void }>[] {
const out: ReactElement<{ onClick: () => void }>[] = [];
const walk = (n: ReactNode) => {
if (Array.isArray(n)) return n.forEach(walk);
if (!isValidElement(n)) return;
const el = n as ReactElement<{ children?: ReactNode; onClick: () => void }>;
if (el.type === "button") out.push(el);
walk(el.props.children);
};
walk(node);
return out;
}
vi.mock("@/hooks/use-roving-radio-group", () => ({
useRovingRadioGroup: () => ({
getRadioProps: (i: number) => ({ tabIndex: i === 0 ? 0 : -1 }),
}),
}));
describe("<Segmented>", () => {
it("is a radiogroup with one checked radio by default", () => {
const out = html();
expect(out).toContain('role="radiogroup"');
expect(out).toContain('aria-label="History"');
expect(out.match(/role="radio"/g)?.length).toBe(2);
expect(out.match(/aria-checked="true"/g)?.length).toBe(1);
expect(out).toContain('data-slot="history-view-all"');
});
it("gives every segment the 44 px floor on a coarse pointer", () => {
const out = html();
expect(out.match(/pointer-coarse:min-h-11/g)?.length).toBe(2);
expect(out).toContain("min-h-9");
});
it("fills the chosen segment in the primary colour, on no track", () => {
const out = html();
const segments = out.match(/<button[^>]*>/g) ?? [];
expect(segments).toHaveLength(2);
const chosen = segments.filter((b) => b.includes('aria-checked="true"'));
expect(chosen).toHaveLength(1);
expect(chosen[0]).toContain("bg-primary text-primary-foreground");
const other = segments.find((b) => b.includes('aria-checked="false"'));
expect(other).not.toContain("bg-primary");
const group = out.match(/<div[^>]*role="radiogroup"[^>]*>/)?.[0];
expect(group).not.toContain("bg-muted");
});
it("sets a chart header's range row in the small type", () => {
expect(html()).toContain("text-sm");
const small = html({ size: "sm" });
expect(small).toContain("text-xs");
expect(small).not.toContain("text-sm");
expect(small.match(/min-h-11/g)?.length).toBe(2);
});
it("keeps a disabled chosen segment on the readable token pair", () => {
const chosen = html({ disabled: true })
.match(/<button[^>]*>/g)
?.find((b) => b.includes('aria-checked="true"'));
expect(chosen).toContain("disabled:bg-primary-disabled");
expect(chosen).toContain("disabled:opacity-100");
});
it("renders pressed buttons in toggle mode", () => {
const out = html({ mode: "toggle" });
expect(out).toContain('role="group"');
expect(out).not.toContain('role="radio"');
expect(out).toContain('aria-pressed="true"');
expect(out).toContain('aria-pressed="false"');
});
it("names icon-only segments and sizes them square", () => {
const out = renderToStaticMarkup(
<Segmented
mode="toggle"
iconOnly
options={[
{ value: "cards", label: "Cards", icon: LayoutGrid },
{ value: "table", label: "Table", icon: Table },
]}
value="cards"
onChange={() => {}}
label="View"
optionSlot={(v) => `medications-view-${v}`}
/>,
);
expect(out).toContain('aria-label="Cards"');
expect(out).toContain('title="Table"');
expect(out.match(/pointer-coarse:size-11/g)?.length).toBe(2);
expect(out).toContain('data-slot="medications-view-table"');
expect(out).not.toContain(">Cards<");
});
it("re-fires the chosen segment in radio mode, which can open something", () => {
const onChange = vi.fn();
const tree = Segmented({
options: OPTIONS,
value: "recent",
onChange,
label: "History",
});
buttons(tree)[0].props.onClick();
expect(onChange).toHaveBeenCalledWith("recent");
});
it("ignores a press on the active segment in toggle mode", () => {
const onChange = vi.fn();
const tree = Segmented({
options: OPTIONS,
value: "recent",
onChange,
label: "History",
mode: "toggle",
});
const [active, other] = buttons(tree);
active.props.onClick();
expect(onChange).not.toHaveBeenCalled();
other.props.onClick();
expect(onChange).toHaveBeenCalledWith("all");
});
});
describe("one segmented control", () => {
it("is the only segmented-control component in the tree", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const defining = walkSourceFiles(src, { floor: 2000 })
.filter((p) => !p.includes("__tests__/"))
.filter((p) =>
/export function (?:Segmented\w*|ViewToggle|\w*SegmentedToggle)\b/.test(
readFileSync(join(src, p), "utf8"),
),
);
expect(defining).toEqual(["components/ui/segmented.tsx"]);
});
});
/**
* A row of buttons whose `aria-pressed` compares a state to each button's
* value is a segmented control spelled by hand: six of them sat beside
* `Segmented` (the narrative period, the lab result type, both admin filters,
* the chart range tabs, the mood chart's copy of them), each with its own
* size, fill and gap. New ones are refused; the ones that remain are not one
* choice on one track, and say why.
*/
describe("no hand-built segmented control", () => {
const PRESSED_BY_VALUE = /aria-pressed=\{[^}]*===[^}]*\}/g;
const NOT_A_SEGMENT: Record<string, string> = {
"components/settings/security-section/passkey-list-section.tsx":
"one method is disabled while the account has no passkey; Segmented disables the whole track",
"components/medications/units-per-dose-field.tsx":
"a chip row that opens a free-number field for any other count",
"components/body-sites/body-sites-section.tsx":
"wrapping side chips under a body-site picker, not a track",
"components/illness/log-day-sheet.tsx":
"a graded severity scale per symptom, one square per level",
"components/cycle/log-day-sheet.tsx":
"a graded severity scale per symptom, one circle per level",
"components/charts/chart-overlay-controls.tsx":
"baselines stay pressable while greyed, with a tooltip saying why there is no data",
"components/timeline/timeline-view.tsx":
"a single button that opens the range sheet, beside the zoom Segmented",
};
function pressedByValue(source: string): number {
return (source.match(PRESSED_BY_VALUE) ?? []).length;
}
it("finds none outside the reasoned exceptions", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { stripComments, walkSourceFiles } =
await import("@/__tests__/helpers/source-files");
const src = join(__dirname, "../../..");
const found = walkSourceFiles(src, { floor: 2000 })
.filter((p) => /^(components|app)\//.test(p))
.filter((p) => !p.includes("__tests__/"))
.filter((p) => p !== "components/ui/segmented.tsx")
.filter(
(p) =>
pressedByValue(stripComments(readFileSync(join(src, p), "utf8"))) > 0,
)
.sort();
expect(found).toEqual(Object.keys(NOT_A_SEGMENT).sort());
});
it("counter-check: catches a hand-built pair, not an on/off toggle", () => {
expect(
pressedByValue(`<Button aria-pressed={filter === "all"}>All</Button>
<Button aria-pressed={filter === "failed"}>Failed</Button>`),
).toBe(2);
expect(pressedByValue(`<Button aria-pressed={open}>Show</Button>`)).toBe(0);
});
});
@@ -0,0 +1,205 @@
/**
* `<ManualWorkoutForm>`, the quick-entry sheet's workout form, driven by
* picking, typing and pressing Save. The entry rules live in
* `manual-entry.ts` and its own suite; this one pins what the person sees
* and what reaches `POST /api/workouts/batch`: the field errors, the distance
* asked in the reader's unit and sent in metres, the start counted back from
* now, the pending state, and one external id per opened form.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { page } from "vitest/browser";
import type { AuthUser } from "@/hooks/use-auth";
import { manualWorkoutOriginalFromRow } from "@/lib/workouts/manual-entry";
import { pinDate } from "../../../../tests/browser/clock";
import {
stubFetch,
type FetchStub,
} from "../../../../tests/browser/fetch-stub";
import { renderWithApp } from "../../../../tests/browser/render";
import { ManualWorkoutForm } from "../manual-workout-form";
const NOW = "2026-09-15T10:00:00.000Z";
const BATCH = "/api/workouts/batch";
let api: FetchStub;
beforeEach(() => {
pinDate(NOW);
api = stubFetch().on("POST", BATCH, {
data: { entries: [{ index: 0, status: "inserted" }] },
});
});
async function mount(
props: Parameters<typeof ManualWorkoutForm>[0] = {},
user: Partial<AuthUser> = {},
) {
const onSuccess = vi.fn();
const { screen } = await renderWithApp(
<ManualWorkoutForm onSuccess={onSuccess} onCancel={() => {}} {...props} />,
{ user },
);
const save = screen.getByRole("button", { name: "Save" });
const pickSport = async (name: string) => {
await screen.getByRole("combobox", { name: /^Activity/ }).click();
await screen.getByRole("option", { name }).click();
};
return { screen, onSuccess, save, pickSport };
}
type Posted = { workouts: Array<Record<string, unknown>> };
const posted = () => api.calls("POST", BATCH).map((r) => r.body as Posted);
describe("<ManualWorkoutForm>", () => {
it("names what is missing under each field and sends nothing", async () => {
const { screen, save } = await mount();
await save.click();
await expect.element(screen.getByText("Choose an activity.")).toBeVisible();
await expect
.element(screen.getByText("Enter how long the workout lasted."))
.toBeVisible();
await expect
.element(screen.getByLabelText("Hours"))
.toHaveAttribute("aria-invalid", "true");
expect(api.requests).toEqual([]);
});
it("asks an imperial reader for miles and sends metres, counted back from now", async () => {
const { screen, save, onSuccess, pickSport } = await mount(
{},
{ unitPreference: "imperial" },
);
await pickSport("Running");
await screen.getByLabelText("Hours").fill("1");
await screen.getByLabelText("Minutes").fill("30");
await screen.getByLabelText("Distance (mi)").fill("10");
await screen.getByLabelText("Active energy (kcal)").fill("640");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [body] = posted();
expect(body.workouts).toHaveLength(1);
expect(body.workouts[0]).toEqual({
sportType: "running",
startedAt: "2026-09-15T08:30:00.000Z",
endedAt: NOW,
source: "MANUAL",
externalId: expect.stringMatching(/^manual:/),
totalDistanceM: 16093.4,
totalEnergyKcal: 640,
});
});
it("asks a metric reader for kilometres", async () => {
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Cycling");
await screen.getByLabelText("Minutes").fill("45");
await screen.getByLabelText("Distance (km)").fill("21,5");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()[0].workouts[0]).toMatchObject({
sportType: "cycling",
startedAt: "2026-09-15T09:15:00.000Z",
totalDistanceM: 21500,
});
});
it("holds Save while the write is pending, then reports it once", async () => {
const pending = api.deferred("POST", BATCH);
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Yoga");
await screen.getByLabelText("Minutes").fill("20");
await save.click();
await expect.element(save).toBeDisabled();
await expect
.element(screen.getByRole("button", { name: "Cancel" }))
.toBeDisabled();
pending.release({ data: { entries: [{ index: 0, status: "inserted" }] } });
await expect.element(save).toBeEnabled();
expect(onSuccess).toHaveBeenCalledOnce();
});
it("retries a failed save under the same external id", async () => {
api.on("POST", BATCH, { status: 503, error: "Service unavailable" });
const { screen, save, onSuccess, pickSport } = await mount();
await pickSport("Running");
await screen.getByLabelText("Minutes").fill("30");
await save.click();
await expect
.element(screen.getByRole("alert"))
.toHaveTextContent("Service unavailable");
expect(onSuccess).not.toHaveBeenCalled();
api.on("POST", BATCH, {
data: { entries: [{ index: 0, status: "duplicate" }] },
});
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
const [first, second] = posted();
expect(second.workouts[0].externalId).toBe(first.workouts[0].externalId);
});
it("lays out for a phone: duration side by side, distance over energy", async () => {
await page.viewport(390, 844);
const { screen, save } = await mount();
const top = (label: string) =>
screen.getByLabelText(label).element().getBoundingClientRect().top;
expect(top("Hours")).toBe(top("Minutes"));
expect(top("Active energy (kcal)")).toBeGreaterThan(top("Distance (km)"));
expect(
save.element().getBoundingClientRect().height,
).toBeGreaterThanOrEqual(44);
// A record, not a training log: no heart rate, sets or route to fill in.
await expect
.element(screen.getByText(/heart|bpm|sets|reps/i))
.not.toBeInTheDocument();
});
it("opens an edit on the stored values and re-posts them unchanged", async () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-14T06:00:00.000Z",
endedAt: "2026-09-14T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "UTC", unitPreference: "metric" },
);
const externalId = "manual:5b0f3c1e-1111-4222-8333-444455556666";
api.on("POST", BATCH, {
data: { entries: [{ index: 0, status: "updated" }] },
});
const { screen, save, onSuccess } = await mount({
edit: { externalId, original },
});
await expect
.element(screen.getByRole("combobox", { name: /^Activity/ }))
.toHaveTextContent("Cycling");
await expect.element(screen.getByLabelText("Hours")).toHaveValue("1");
await expect.element(screen.getByLabelText("Minutes")).toHaveValue("30");
await expect
.element(screen.getByLabelText("Distance (km)"))
.toHaveValue("32");
await save.click();
await expect.poll(() => onSuccess.mock.calls.length).toBe(1);
expect(posted()[0].workouts[0]).toMatchObject({
externalId,
startedAt: "2026-09-14T06:00:00.000Z",
endedAt: "2026-09-14T07:30:00.000Z",
totalDistanceM: 32000,
totalEnergyKcal: 640,
avgHeartRate: 131,
});
});
});
@@ -1,56 +1,28 @@
/**
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
* The manual workout save path, and the detail page's delete.
*
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
* testing-library). The rules themselves live in `manual-entry.ts` and are
* pinned in its own suite; this one pins the wire and the markup:
* The rules themselves live in `manual-entry.ts` and are pinned in its own
* suite, and the mounted form (fields, units, layout, editing) is driven in
* `manual-workout-form.browser.test.tsx`. This one pins the save and delete
* helpers on their own:
*
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
* the form's external id, and refreshes the workout reads;
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
* 3. the form renders its five fields, the distance in the reader's unit,
* and a layout that stacks distance / energy on a phone;
* 4. delete is offered for a hand-entered workout in one's own record only,
* 3. delete is offered for a hand-entered workout in one's own record only,
* and calls `DELETE /api/workouts/{id}`.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { QueryClient } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
const authRef: { unitPreference: "metric" | "imperial" } = {
unitPreference: "metric",
};
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
username: "tester",
timezone: "Europe/Berlin",
unitPreference: authRef.unitPreference,
},
}),
}));
import {
ManualWorkoutForm,
saveManualWorkout,
sportOptions,
} from "../manual-workout-form";
import { saveManualWorkout, sportOptions } from "../manual-workout-form";
import {
canDeleteWorkout,
deleteManualWorkout,
} from "../delete-workout-button";
import {
manualWorkoutOriginalFromRow,
type ManualWorkoutEntry,
} from "@/lib/workouts/manual-entry";
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
const ENTRY: ManualWorkoutEntry = {
sportType: "cycling",
@@ -75,16 +47,9 @@ function fakeClient() {
};
}
function render(node: React.ReactElement) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
authRef.unitPreference = "metric";
fetchSpy = vi.spyOn(globalThis, "fetch");
});
@@ -176,43 +141,6 @@ describe("sportOptions", () => {
});
});
describe("<ManualWorkoutForm> — markup", () => {
it("renders the five fields and nothing else", () => {
const html = render(<ManualWorkoutForm />);
expect(html).toContain('data-testid="manual-workout-sport"');
expect(html).toContain('data-testid="manual-workout-start"');
expect(html).toContain('data-testid="manual-workout-hours"');
expect(html).toContain('data-testid="manual-workout-minutes"');
expect(html).toContain('data-testid="manual-workout-distance"');
expect(html).toContain('data-testid="manual-workout-energy"');
expect(html).toContain("Activity");
expect(html).toContain("Duration");
expect(html).toContain("Active energy (kcal)");
// No heart rate, no sets, no route: a record, not a training log.
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
});
it("names the distance in kilometres for a metric reader", () => {
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
});
it("names the distance in miles for an imperial reader", () => {
authRef.unitPreference = "imperial";
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
});
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
const html = render(<ManualWorkoutForm />);
// Two short numeric fields fit a 390 px sheet side by side.
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
// Distance and energy carry longer labels: one column below `sm`, two
// from 640 px up.
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
// The action buttons keep the 44 px touch floor on a phone.
expect(html).toContain("min-h-11 sm:min-h-9");
});
});
describe("delete — a hand-entered workout in one's own record only", () => {
it("is offered for a MANUAL workout in one's own record", () => {
expect(
@@ -254,39 +182,3 @@ describe("delete — a hand-entered workout in one's own record only", () => {
});
});
});
describe("<ManualWorkoutForm> — editing a stored workout (#1162)", () => {
const original = manualWorkoutOriginalFromRow(
{
sportType: "cycling",
startedAt: "2026-09-15T06:00:00.000Z",
endedAt: "2026-09-15T07:30:00.000Z",
durationSec: 5400,
distanceM: 32000,
activeEnergyKcal: 640,
minHr: null,
stepCount: null,
elevationM: null,
pauseDurationSec: null,
storedAvgHr: 131,
storedMaxHr: null,
},
{ timezone: "Europe/Berlin", unitPreference: "metric" },
);
it("opens on the stored values", () => {
const html = render(
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
);
expect(html).toContain('value="2026-09-15T08:00"');
expect(html).toContain('value="1"');
expect(html).toContain('value="30"');
expect(html).toContain('value="32"');
expect(html).toContain('value="640"');
});
it("opens empty when it is not editing", () => {
const html = render(<ManualWorkoutForm />);
expect(html).not.toContain('value="640"');
});
});
@@ -0,0 +1,268 @@
/**
* The URL-owned filter contract, driven the way a list page uses it: a
* `FilterBar` of pills over `useUrlFilterSync`, in a real browser with a real
* address bar. What these pin and the markup tests could not: a facet picked
* from the open menu lands in the query as a new history entry, typed input
* rewrites the current one, the per-pill clear and Reset leave a bare URL, two
* writes in a row both land, and Back or an edited address re-seeds the pills.
*/
import { describe, expect, it } from "vitest";
import { userEvent } from "vitest/browser";
import {
FilterBar,
FilterBarDateRange,
FilterBarSelect,
} from "@/components/ui/filter-bar";
import { Input } from "@/components/ui/input";
import { renderWithApp } from "../../../tests/browser/render";
import {
currentSearch,
navigateExternally,
recordHistory,
setSearch,
} from "../../../tests/browser/url";
import { useUrlFilterSync } from "../use-url-filter-sync";
interface Filters {
type?: string;
source?: string;
q?: string;
from?: string;
to?: string;
}
const KEYS = ["type", "source", "q", "from", "to"] as const;
function parse(params: URLSearchParams): Filters {
const filters: Filters = {};
for (const key of KEYS) {
const value = params.get(key);
if (value) filters[key] = value;
}
return filters;
}
function serialise(filters: Filters): string {
const sp = new URLSearchParams();
for (const key of KEYS) {
const value = filters[key];
if (value) sp.set(key, value);
}
return sp.toString();
}
const TYPES = [
{ value: "WEIGHT", label: "Weight" },
{ value: "PULSE", label: "Pulse" },
];
const SOURCES = [
{ value: "MANUAL", label: "Manual" },
{ value: "WITHINGS", label: "Withings" },
];
/** A list page's filter rail, as the measurements and mood lists build it. */
function FilterRail({ syncToUrl = true }: { syncToUrl?: boolean }) {
const { filters, applyFilters, patchFilters } = useUrlFilterSync<Filters>({
parse,
serialise,
syncToUrl,
});
const isFiltered = Object.keys(filters).length > 0;
return (
<>
<Input
aria-label="Search notes"
value={filters.q ?? ""}
onChange={(e) => patchFilters({ q: e.target.value }, "replace")}
/>
<FilterBar
isFiltered={isFiltered}
onReset={() => applyFilters({})}
count={`${Object.keys(filters).length} active`}
>
<FilterBarDateRange
label="Date range"
from={filters.from ?? ""}
to={filters.to ?? ""}
onFromChange={(from) => patchFilters({ from })}
onToChange={(to) => patchFilters({ to })}
idPrefix="probe"
/>
<FilterBarSelect
label="Type"
value={filters.type ?? "ALL"}
onValueChange={(v) =>
patchFilters({ type: v === "ALL" ? undefined : v })
}
allLabel="All types"
options={TYPES}
/>
<FilterBarSelect
label="Source"
value={filters.source ?? "ALL"}
onValueChange={(v) =>
patchFilters({ source: v === "ALL" ? undefined : v })
}
allLabel="All sources"
options={SOURCES}
/>
</FilterBar>
</>
);
}
async function mount(search = "", props: { syncToUrl?: boolean } = {}) {
setSearch(search);
const history = recordHistory();
const { screen } = await renderWithApp(<FilterRail {...props} />);
const pill = (name: string | RegExp) =>
screen.getByRole("combobox", { name });
const reset = () => screen.getByRole("button", { name: "Reset" });
const pick = async (pillName: string | RegExp, option: string) => {
await pill(pillName).click();
await screen.getByRole("option", { name: option }).click();
};
return { screen, history, pill, reset, pick };
}
/** The URL settles a task after the write; wait for the expected query. */
async function expectSearch(search: string) {
await expect.poll(currentSearch).toBe(search);
}
describe("useUrlFilterSync with FilterBar", () => {
it("seeds the pills from the address it mounts on", async () => {
const { screen, pill, reset } = await mount("?type=WEIGHT&source=WITHINGS");
await expect
.element(pill("Type: Weight"))
.toHaveAttribute("data-active", "true");
await expect.element(pill("Source: Withings")).toBeInTheDocument();
await expect.element(reset()).toBeVisible();
await expect.element(screen.getByText("2 active")).toBeVisible();
});
it("shows no Reset and no clear button while unfiltered", async () => {
const { screen, pill } = await mount();
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect
.element(screen.getByRole("button", { name: "Reset" }))
.not.toBeInTheDocument();
await expect
.element(screen.getByRole("button", { name: /^Clear filter/ }))
.not.toBeInTheDocument();
});
it("writes a facet picked from the open menu as a new history entry", async () => {
const { pill, history, pick } = await mount();
const entries = window.history.length;
await pick("Type", "Pulse");
await expectSearch("?type=PULSE");
expect(history.pushes()).toEqual(["?type=PULSE"]);
expect(history.replaces()).toEqual([]);
expect(window.history.length).toBe(entries + 1);
await expect.element(pill("Type: Pulse")).toBeInTheDocument();
});
it("opens the menu inside the viewport, under its pill", async () => {
const { screen, pill } = await mount();
// Read before opening: the open menu hides the rest of the page from
// the accessibility tree, the pill included.
const trigger = pill("Source").element().getBoundingClientRect();
await pill("Source").click();
const listbox = screen.getByRole("listbox");
await expect.element(listbox).toBeVisible();
// The menu slides in from above its resting place; measure where it rests.
await Promise.all(document.getAnimations().map((a) => a.finished));
const menu = listbox.element().getBoundingClientRect();
expect(menu.top).toBeGreaterThanOrEqual(trigger.bottom - 1);
expect(menu.bottom).toBeLessThanOrEqual(window.innerHeight);
expect(menu.left).toBeGreaterThanOrEqual(0);
await userEvent.keyboard("{Escape}");
await expect.element(listbox).not.toBeInTheDocument();
});
it("rewrites the current entry for typed input instead of stacking one per key", async () => {
const { screen, history } = await mount("?type=WEIGHT");
const entries = window.history.length;
await screen.getByRole("textbox", { name: "Search notes" }).fill("knee");
await expectSearch("?type=WEIGHT&q=knee");
expect(history.pushes()).toEqual([]);
expect(history.replaces().at(-1)).toBe("?type=WEIGHT&q=knee");
expect(window.history.length).toBe(entries);
});
it("drops one facet with its clear button and keeps the others", async () => {
const { screen, pill } = await mount("?type=WEIGHT&source=MANUAL");
await screen.getByRole("button", { name: "Clear filter: Type" }).click();
await expectSearch("?source=MANUAL");
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect.element(pill("Source: Manual")).toBeInTheDocument();
});
it("clears both date bounds in one press, two writes in a row", async () => {
const { screen } = await mount("?from=2026-05-12&to=2026-06-10&type=PULSE");
const range = screen.getByRole("button", { name: /^Date range: / });
await expect.element(range).toHaveTextContent("Date range: 05/12–06/10");
await screen
.getByRole("button", { name: "Clear filter: Date range" })
.click();
await expectSearch("?type=PULSE");
await expect
.element(screen.getByRole("button", { name: "Date range" }))
.toHaveAttribute("data-active", "false");
});
it("resets every facet to a bare URL and hides Reset", async () => {
const { reset, pill, history } = await mount("?type=WEIGHT&source=MANUAL");
await reset().click();
await expectSearch("");
expect(history.pushes()).toEqual([""]);
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await expect
.element(pill("Source"))
.toHaveAttribute("data-active", "false");
await expect.element(reset()).not.toBeInTheDocument();
});
it("re-seeds from an address changed outside the component", async () => {
const { pill, reset } = await mount("?type=WEIGHT");
navigateExternally("?source=WITHINGS");
await expect.element(pill("Source: Withings")).toBeInTheDocument();
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
navigateExternally("");
await expect.element(reset()).not.toBeInTheDocument();
});
it("steps back through filter states with the browser's Back", async () => {
const { pill, pick } = await mount("?type=WEIGHT");
await pick("Type: Weight", "Pulse");
await expectSearch("?type=PULSE");
window.history.back();
await expectSearch("?type=WEIGHT");
await expect.element(pill("Type: Weight")).toBeInTheDocument();
});
it("keeps the state in memory and the address untouched with syncToUrl off", async () => {
const { pill, pick, history } = await mount("?type=WEIGHT", {
syncToUrl: false,
});
await expect.element(pill("Type")).toHaveAttribute("data-active", "false");
await pick("Type", "Pulse");
await expect.element(pill("Type: Pulse")).toBeInTheDocument();
expect(currentSearch()).toBe("?type=WEIGHT");
expect(history.writes).toEqual([]);
});
it("names Reset and the clear buttons in the reader's language", async () => {
setSearch("?source=MANUAL");
const { screen } = await renderWithApp(<FilterRail />, { locale: "de" });
await expect
.element(screen.getByRole("button", { name: "Zurücksetzen" }))
.toBeVisible();
await expect
.element(screen.getByRole("button", { name: "Filter entfernen: Source" }))
.toBeInTheDocument();
});
});
@@ -1,117 +0,0 @@
/**
* The shared URL-owned-filter hook (`use-url-filter-sync.ts`) — the
* documents-vault pattern extracted for the measurements and mood lists.
* Pinned here: mount parses the live query string, a filter change writes
* the serialised form back through the router (push by default, replace on
* request), an empty filter set restores the bare pathname, and
* `syncToUrl: false` detaches the state from the URL entirely.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
let mockSearch = "";
const pushMock = vi.fn();
const replaceMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: replaceMock }),
usePathname: () => "/measurements",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
import { useUrlFilterSync } from "../use-url-filter-sync";
import {
installUrlStateBrowser,
type UrlStateBrowser,
} from "@/__tests__/helpers/url-state-browser";
let browser: UrlStateBrowser = installUrlStateBrowser("/");
afterEach(() => browser.uninstall());
interface ProbeFilters {
type?: string;
source?: string;
}
function parse(params: URLSearchParams): ProbeFilters {
const filters: ProbeFilters = {};
const type = params.get("type");
if (type) filters.type = type;
const source = params.get("source");
if (source) filters.source = source;
return filters;
}
function serialise(filters: ProbeFilters): string {
const sp = new URLSearchParams();
if (filters.type) sp.set("type", filters.type);
if (filters.source) sp.set("source", filters.source);
return sp.toString();
}
interface ProbeResult {
filters: ProbeFilters;
applyFilters: (next: ProbeFilters, mode?: "push" | "replace") => void;
}
function mount(search: string, syncToUrl?: boolean): ProbeResult {
mockSearch = search;
// Ref-shaped capture cell: the render-scoped assignment happens inside a
// closure the control-flow analysis cannot see through.
const captured: { current: ProbeResult | null } = { current: null };
function Probe() {
captured.current = useUrlFilterSync<ProbeFilters>({
parse,
serialise,
syncToUrl,
});
return null;
}
renderToStaticMarkup(<Probe />);
if (!captured.current) throw new Error("probe did not render");
// The live address the writes start from (`@/lib/url-state`).
browser.uninstall();
browser = installUrlStateBrowser(
search ? `/measurements?${search}` : "/measurements",
);
return captured.current;
}
beforeEach(() => {
pushMock.mockClear();
replaceMock.mockClear();
});
describe("useUrlFilterSync", () => {
it("parses the mounted query string into the filter object", () => {
const { filters } = mount("type=WEIGHT&source=WITHINGS");
expect(filters).toEqual({ type: "WEIGHT", source: "WITHINGS" });
});
it("writes a filter change to the URL via push by default", () => {
const { applyFilters } = mount("");
applyFilters({ type: "WEIGHT" });
expect(browser.push).toHaveBeenCalledWith("/measurements?type=WEIGHT");
expect(browser.replace).not.toHaveBeenCalled();
});
it("writes via replace when asked (high-frequency input)", () => {
const { applyFilters } = mount("");
applyFilters({ source: "MANUAL" }, "replace");
expect(browser.replace).toHaveBeenCalledWith("/measurements?source=MANUAL");
expect(browser.push).not.toHaveBeenCalled();
});
it("restores the bare pathname when every facet clears", () => {
const { applyFilters } = mount("type=WEIGHT");
applyFilters({});
expect(browser.push).toHaveBeenCalledWith("/measurements");
});
it("syncToUrl: false ignores the URL and never navigates", () => {
const { filters, applyFilters } = mount("type=WEIGHT", false);
expect(filters).toEqual({});
applyFilters({ type: "PULSE" });
expect(browser.push).not.toHaveBeenCalled();
expect(browser.replace).not.toHaveBeenCalled();
});
});