Files
HealthLog/e2e/v1427-insights-empty-state.spec.ts
Marc-André Bombeck fea1d1e0b5 chore: strip planning tags from comments and enable the rule
Output of `node scripts/strip-planning-tags.mjs --write`: 3,157 comment
lines in 1,308 files lose the work-item identifier at their head. Release
versions and issue references stay; nothing outside comment text
changes, which the script checks per file against a comment-free
printout. healthlog/no-planning-tags is enabled at error level with this
commit, so the tree it lands on is clean.
2026-10-20 09:08:40 +02:00

164 lines
5.5 KiB
TypeScript

import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
import { aiBlockAvailable, serveAiBlock } from "./setup/ai-capabilities";
/**
* v1.4.27 — insights metric-availability gating.
*
* When a metric has zero observations the routed sub-page early-
* returns an empty-state component with TWO mandatory affordances:
*
* 1. A CTA `<Link>` pointing at `/measurements?add=<TYPE>` so the
* user lands on the matching add-measurement form.
* 2. A `<CoachLaunchButton>` so the same metric is reachable from
* the Coach without first creating data.
*
* The spec mocks `/api/analytics` to return zero counts for the
* relevant metric, navigates to each sub-page, and asserts both
* affordances render. Three sub-pages cover the BP / weight / sleep
* branches.
*/
test.describe("v1.4.27 — insights empty-state with metric gating", () => {
// AI surfaces render from the `ai` block of `/api/auth/me`, and the suite
// has no reachable provider; every capability reads available here so the
// mocked AI routes below are what the surfaces talk to.
test.beforeEach(async ({ page }) => {
await serveAiBlock(page, aiBlockAvailable());
});
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(async ({ page }) => {
// Zero-count summaries — every gated sub-page short-circuits to
// its EmptyState branch.
// v1.4.39.3 — regex form matches the slim slice the v1.4.39.2
// dashboard split fires alongside the thick request.
await page.route(/\/api\/analytics(\?|$)/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
summaries: {
WEIGHT: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
BLOOD_PRESSURE_SYS: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
BLOOD_PRESSURE_DIA: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
SLEEP_DURATION: {
latest: null,
avg7: null,
avg30: null,
slope30: null,
count: 0,
},
},
bpInTargetPct: null,
glucoseByContext: {},
},
error: null,
}),
}),
);
await page.route("**/api/measurements*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: { measurements: [], meta: { total: 0 } },
error: null,
}),
}),
);
await page.route("**/api/insights/**-status*", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
text: null,
hasProvider: false,
cached: false,
updatedAt: null,
},
error: null,
}),
}),
);
});
for (const { slug, addType, headingMatch } of [
{
slug: "blutdruck",
addType: "BLOOD_PRESSURE",
headingMatch: /blood pressure|blutdruck/i,
},
{ slug: "gewicht", addType: "WEIGHT", headingMatch: /weight|gewicht/i },
{ slug: "schlaf", addType: "SLEEP", headingMatch: /sleep|schlaf/i },
] as const) {
test(`/insights/${slug} renders the empty state + Coach launch when count = 0`, async ({
page,
}) => {
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
// EmptyState primitive carries `data-slot="empty-state"` (see
// `src/components/ui/empty-state.tsx`).
const emptyState = page.locator('[data-slot="empty-state"]').first();
await expect(emptyState).toBeVisible({ timeout: 10_000 });
// The CTA link points at /measurements?add=<TYPE>. For the
// sleep page the wiring may still be wired through a non-
// measurement entry-point (sleep ingest happens via the iOS
// app), so the assertion is split: BP + weight pin on the
// exact `?add=` href; sleep just asserts the empty state and
// Coach launch render. The brief lists this as a known gap.
if (slug !== "schlaf") {
const cta = emptyState.getByRole("link").first();
await expect(cta).toBeVisible();
await expect(cta).toHaveAttribute(
"href",
new RegExp(`/measurements\\?add=${addType}`),
);
}
// The Coach launch button is rendered alongside the empty state.
// The DOM carries both the FAB + inline branches; CSS picks the
// visible one. Pin on whichever branch is the visible one for
// the current project.
const coachAffordance = page
.locator(
'[data-slot="coach-launch-fab"], [data-slot="coach-launch-inline"]',
)
.first();
await expect(coachAffordance).toBeAttached({ timeout: 10_000 });
// The page heading proves we're on the right sub-page (the
// empty state shell renders a SubPageShell with the metric
// title). Pin on the sub-page slot's h1 so we don't accidentally
// match a sidebar / nav element.
await expect(
page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
).toContainText(headingMatch);
});
}
});