mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
A list that is empty now carries its add action in the empty state instead of the header, and on the phone shell the dashboard's add opens the capture sheet. The overflow, mood timestamp, measurement flow, onboarding tour and delegated-write journeys accept either control; the measurement list's empty-state add gets a stable data-slot for that.
195 lines
7.3 KiB
TypeScript
195 lines
7.3 KiB
TypeScript
import { expect, test } from "./setup/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
import { openMenu } from "./open-menu";
|
|
|
|
/**
|
|
* Add-measurement flow — exercises the dashboard's quick-entry dropdown,
|
|
* the dialog-mounted MeasurementForm, the POST to /api/measurements, and
|
|
* the resulting query invalidation that surfaces the row on
|
|
* `/measurements`.
|
|
*
|
|
* Weight is the simplest type (single numeric input + datetime), so it
|
|
* keeps the spec focused on the round-trip rather than the BP three-field
|
|
* batched submit.
|
|
*/
|
|
test.describe("add measurement flow", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
// This journey lands on the dashboard, which is the most expensive page in
|
|
// the product to render cold: the route segment suspends behind the server
|
|
// render and shows `loading.tsx` until it resolves. On a shared runner with
|
|
// the whole suite in flight that has overrun the 30 s default, and the test
|
|
// then died on the quick-add trigger rather than on anything it is about.
|
|
// The subject here is the add-then-appears round trip, not the dashboard's
|
|
// cold-start latency, so the budget is the page's and the assertions stay
|
|
// exactly as tight as they were.
|
|
test.setTimeout(90_000);
|
|
|
|
test("creating a weight reading surfaces it in the list", async ({
|
|
page,
|
|
}) => {
|
|
// Capture the POST payload so we can assert against it independently
|
|
// of any list-fetch response Playwright might cache. Match both
|
|
// `/api/measurements` (POST creates a reading) and the list query
|
|
// `/api/measurements?type=...&limit=...&offset=...` (GET).
|
|
let postedBody: unknown = null;
|
|
await page.route("**/api/measurements*", async (route) => {
|
|
const req = route.request();
|
|
if (req.method() === "POST") {
|
|
postedBody = JSON.parse(req.postData() ?? "null");
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { id: "m_e2e_1", success: true },
|
|
error: null,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
// GET — return the just-posted reading so the list view shows it.
|
|
// The list expects `data.measurements: Measurement[]` plus
|
|
// `data.meta.total`; chart fetches use the same envelope.
|
|
const body = postedBody as {
|
|
type?: string;
|
|
value?: number;
|
|
measuredAt?: string;
|
|
} | null;
|
|
// The list-page `Measurement` shape requires `unit` and `source`
|
|
// — without them the row crashes the render and the e2e poll
|
|
// never sees "78.4". Mock with the same fields a real reading
|
|
// carries.
|
|
const measurements = body
|
|
? [
|
|
{
|
|
id: "m_e2e_1",
|
|
type: body.type,
|
|
value: body.value,
|
|
unit: body.type === "WEIGHT" ? "kg" : "",
|
|
source: "MANUAL",
|
|
measuredAt: body.measuredAt,
|
|
notes: null,
|
|
},
|
|
]
|
|
: [];
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { measurements, meta: { total: measurements.length } },
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
// Dashboard's analytics fetch — empty summaries are fine; we only
|
|
// need the page to render so we can click "Add".
|
|
//
|
|
// v1.4.39.3 — match `/api/analytics` AND any sliced variant
|
|
// (`?slice=summaries`). The v1.4.39.2 dashboard split fires both
|
|
// requests in parallel; the previous string glob missed the slim
|
|
// variant which then fell through to the real route and slowed
|
|
// the page paint enough that the post-mutation list query never
|
|
// surfaced inside the 10 s window.
|
|
await page.route(/\/api\/analytics(\?|$)/, (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { summaries: {}, bpInTargetPct: null, glucoseByContext: {} },
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
await page.route("**/api/mood/analytics", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { entries: [], summary: { count: 0 } },
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
// Open the "Add" dropdown — the dashboard's quick-entry trigger sits
|
|
// at the top-right of `<main>`. Scope the locator there so we don't
|
|
// accidentally match an "Add" button on the sidebar.
|
|
const main = page.locator("main");
|
|
const addTrigger = main
|
|
.getByRole("button", { name: /^add$|hinzufügen|hinzufuegen/i })
|
|
.first();
|
|
// Wait for the trigger itself rather than for the route-level skeleton to
|
|
// go. The skeleton is also absent while the protected shell holds the
|
|
// whole page behind its hydration gate, so its disappearance is not a
|
|
// statement about the suspended segment having resolved; the control the
|
|
// next line clicks is. `networkidle` says nothing about either.
|
|
await expect(addTrigger).toBeVisible({ timeout: 60_000 });
|
|
await page.waitForLoadState("networkidle");
|
|
|
|
await openMenu(page, addTrigger);
|
|
|
|
// v1.5 phase-5: the menu items now have distinct labels — the
|
|
// measurement entry says "Measurement" / "Messung" instead of "Add",
|
|
// so we can target it directly.
|
|
// The desktop rail opens a dropdown, the phone shell the capture sheet
|
|
// the bottom nav's plus opens; the measurement entry is in either.
|
|
await page
|
|
.getByRole("menuitem", { name: /measurement|messung/i })
|
|
.or(page.getByRole("button", { name: /^(measurement|messung)\b/i }))
|
|
.first()
|
|
.click();
|
|
|
|
// Form should be visible inside the dialog. Switch type to WEIGHT
|
|
// (combo-box defaults to BLOOD_PRESSURE).
|
|
const dialog = page.getByRole("dialog");
|
|
await expect(dialog).toBeVisible();
|
|
|
|
// The Select trigger renders the current label — click to open and
|
|
// pick the weight option.
|
|
await dialog.getByRole("combobox").first().click();
|
|
await page
|
|
.getByRole("option", { name: /weight|gewicht/i })
|
|
.first()
|
|
.click();
|
|
|
|
// Weight is single-value mode — fill the value field with 78.4 kg.
|
|
const valueInput = dialog.locator("#value");
|
|
await valueInput.fill("78.4");
|
|
|
|
// Save
|
|
await dialog.getByRole("button", { name: /save|speichern/i }).click();
|
|
|
|
// Dialog closes when the mutation succeeds
|
|
await expect(dialog).toBeHidden({ timeout: 5_000 });
|
|
|
|
// Assert the POST went through with the expected shape
|
|
expect(postedBody).toMatchObject({
|
|
type: "WEIGHT",
|
|
value: 78.4,
|
|
});
|
|
|
|
// Navigate to the list and confirm the entry surfaces. The list
|
|
// page renders entries from /api/measurements which we've mocked
|
|
// to echo the just-posted row.
|
|
await page.goto("/measurements", { waitUntil: "domcontentloaded" });
|
|
|
|
// The list renders a desktop table AND a mobile card grid (one is
|
|
// hidden via `md:hidden`/`hidden md:block`). At least one match
|
|
// means our row landed in the DOM regardless of viewport.
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
await page
|
|
.locator("main")
|
|
.getByText(/78[.,]4/)
|
|
.count(),
|
|
{ timeout: 10_000 },
|
|
)
|
|
.toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|