mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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:
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+8
-116
@@ -1,56 +1,28 @@
|
||||
/**
|
||||
* `<ManualWorkoutForm>` and its save path, and the detail page's delete.
|
||||
* The manual workout save path, and the detail page's delete.
|
||||
*
|
||||
* SSR-only, like the other capture-form suites (vitest runs in `node`, no
|
||||
* testing-library). The rules themselves live in `manual-entry.ts` and are
|
||||
* pinned in its own suite; this one pins the wire and the markup:
|
||||
* The rules themselves live in `manual-entry.ts` and are pinned in its own
|
||||
* suite, and the mounted form (fields, units, layout, editing) is driven in
|
||||
* `manual-workout-form.browser.test.tsx`. This one pins the save and delete
|
||||
* helpers on their own:
|
||||
*
|
||||
* 1. the save posts ONE entry to `POST /api/workouts/batch`, MANUAL, with
|
||||
* the form's external id, and refreshes the workout reads;
|
||||
* 2. a `duplicate` (a second tap) is a success, a `skipped` entry is not;
|
||||
* 3. the form renders its five fields, the distance in the reader's unit,
|
||||
* and a layout that stacks distance / energy on a phone;
|
||||
* 4. delete is offered for a hand-entered workout in one's own record only,
|
||||
* 3. delete is offered for a hand-entered workout in one's own record only,
|
||||
* and calls `DELETE /api/workouts/{id}`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const authRef: { unitPreference: "metric" | "imperial" } = {
|
||||
unitPreference: "metric",
|
||||
};
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: {
|
||||
username: "tester",
|
||||
timezone: "Europe/Berlin",
|
||||
unitPreference: authRef.unitPreference,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
ManualWorkoutForm,
|
||||
saveManualWorkout,
|
||||
sportOptions,
|
||||
} from "../manual-workout-form";
|
||||
import { saveManualWorkout, sportOptions } from "../manual-workout-form";
|
||||
import {
|
||||
canDeleteWorkout,
|
||||
deleteManualWorkout,
|
||||
} from "../delete-workout-button";
|
||||
import {
|
||||
manualWorkoutOriginalFromRow,
|
||||
type ManualWorkoutEntry,
|
||||
} from "@/lib/workouts/manual-entry";
|
||||
import type { ManualWorkoutEntry } from "@/lib/workouts/manual-entry";
|
||||
|
||||
const ENTRY: ManualWorkoutEntry = {
|
||||
sportType: "cycling",
|
||||
@@ -75,16 +47,9 @@ function fakeClient() {
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactElement) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
let fetchSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
authRef.unitPreference = "metric";
|
||||
fetchSpy = vi.spyOn(globalThis, "fetch");
|
||||
});
|
||||
|
||||
@@ -176,43 +141,6 @@ describe("sportOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ManualWorkoutForm> — markup", () => {
|
||||
it("renders the five fields and nothing else", () => {
|
||||
const html = render(<ManualWorkoutForm />);
|
||||
expect(html).toContain('data-testid="manual-workout-sport"');
|
||||
expect(html).toContain('data-testid="manual-workout-start"');
|
||||
expect(html).toContain('data-testid="manual-workout-hours"');
|
||||
expect(html).toContain('data-testid="manual-workout-minutes"');
|
||||
expect(html).toContain('data-testid="manual-workout-distance"');
|
||||
expect(html).toContain('data-testid="manual-workout-energy"');
|
||||
expect(html).toContain("Activity");
|
||||
expect(html).toContain("Duration");
|
||||
expect(html).toContain("Active energy (kcal)");
|
||||
// No heart rate, no sets, no route: a record, not a training log.
|
||||
expect(html).not.toMatch(/heart|bpm|sets|reps/i);
|
||||
});
|
||||
|
||||
it("names the distance in kilometres for a metric reader", () => {
|
||||
expect(render(<ManualWorkoutForm />)).toContain("Distance (km)");
|
||||
});
|
||||
|
||||
it("names the distance in miles for an imperial reader", () => {
|
||||
authRef.unitPreference = "imperial";
|
||||
expect(render(<ManualWorkoutForm />)).toContain("Distance (mi)");
|
||||
});
|
||||
|
||||
it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => {
|
||||
const html = render(<ManualWorkoutForm />);
|
||||
// Two short numeric fields fit a 390 px sheet side by side.
|
||||
expect(html).toMatch(/class="grid grid-cols-2 gap-4"/);
|
||||
// Distance and energy carry longer labels: one column below `sm`, two
|
||||
// from 640 px up.
|
||||
expect(html).toMatch(/class="grid gap-4 sm:grid-cols-2"/);
|
||||
// The action buttons keep the 44 px touch floor on a phone.
|
||||
expect(html).toContain("min-h-11 sm:min-h-9");
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete — a hand-entered workout in one's own record only", () => {
|
||||
it("is offered for a MANUAL workout in one's own record", () => {
|
||||
expect(
|
||||
@@ -254,39 +182,3 @@ describe("delete — a hand-entered workout in one's own record only", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("<ManualWorkoutForm> — editing a stored workout (#1162)", () => {
|
||||
const original = manualWorkoutOriginalFromRow(
|
||||
{
|
||||
sportType: "cycling",
|
||||
startedAt: "2026-09-15T06:00:00.000Z",
|
||||
endedAt: "2026-09-15T07:30:00.000Z",
|
||||
durationSec: 5400,
|
||||
distanceM: 32000,
|
||||
activeEnergyKcal: 640,
|
||||
minHr: null,
|
||||
stepCount: null,
|
||||
elevationM: null,
|
||||
pauseDurationSec: null,
|
||||
storedAvgHr: 131,
|
||||
storedMaxHr: null,
|
||||
},
|
||||
{ timezone: "Europe/Berlin", unitPreference: "metric" },
|
||||
);
|
||||
|
||||
it("opens on the stored values", () => {
|
||||
const html = render(
|
||||
<ManualWorkoutForm edit={{ externalId: ENTRY.externalId, original }} />,
|
||||
);
|
||||
expect(html).toContain('value="2026-09-15T08:00"');
|
||||
expect(html).toContain('value="1"');
|
||||
expect(html).toContain('value="30"');
|
||||
expect(html).toContain('value="32"');
|
||||
expect(html).toContain('value="640"');
|
||||
});
|
||||
|
||||
it("opens empty when it is not editing", () => {
|
||||
const html = render(<ManualWorkoutForm />);
|
||||
expect(html).not.toContain('value="640"');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user