diff --git a/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx new file mode 100644 index 000000000..305a63e6d --- /dev/null +++ b/src/components/workouts/__tests__/manual-workout-form.browser.test.tsx @@ -0,0 +1,205 @@ +/** + * ``, 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[0] = {}, + user: Partial = {}, +) { + const onSuccess = vi.fn(); + const { screen } = await renderWithApp( + {}} {...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> }; +const posted = () => api.calls("POST", BATCH).map((r) => r.body as Posted); + +describe("", () => { + 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, + }); + }); +}); diff --git a/src/components/workouts/__tests__/manual-workout-form.test.tsx b/src/components/workouts/__tests__/manual-workout-save.test.ts similarity index 57% rename from src/components/workouts/__tests__/manual-workout-form.test.tsx rename to src/components/workouts/__tests__/manual-workout-save.test.ts index ff947bbf6..e04b69829 100644 --- a/src/components/workouts/__tests__/manual-workout-form.test.tsx +++ b/src/components/workouts/__tests__/manual-workout-save.test.ts @@ -1,56 +1,28 @@ /** - * `` 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( - {node}, - ); -} - let fetchSpy: ReturnType; beforeEach(() => { - authRef.unitPreference = "metric"; fetchSpy = vi.spyOn(globalThis, "fetch"); }); @@ -176,43 +141,6 @@ describe("sportOptions", () => { }); }); -describe(" — markup", () => { - it("renders the five fields and nothing else", () => { - const html = render(); - 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()).toContain("Distance (km)"); - }); - - it("names the distance in miles for an imperial reader", () => { - authRef.unitPreference = "imperial"; - expect(render()).toContain("Distance (mi)"); - }); - - it("keeps hours and minutes side by side and stacks distance and energy on a phone", () => { - const html = render(); - // 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(" — 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( - , - ); - 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(); - expect(html).not.toContain('value="640"'); - }); -});