test(workouts): drive the manual workout form in a real browser

Pick a sport, type a duration and distance, press Save and read the
batch body: miles asked of an imperial reader arrive in metres, a blank
start counts back from now, a retry after a failed save reuses the
form's external id, and an edit re-posts the stored instants and the
fields the form does not show. Field errors, the pending state and the
phone layout are measured in the page.

The markup and edit-render cases are deleted from the old suite, which
keeps the save and delete helper checks as manual-workout-save.test.ts.
This commit is contained in:
Marc-André Bombeck committed 2026-10-23 13:09:01 +02:00
1 parent 3ab85f85f9
commit b161ed3bd2
2 files changed
+213 -116

No files matched your search

@@ -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"');
});
});