Files
HealthLog/e2e/v1427-insights-empty-state.spec.ts
T
Marc-André Bombeck dbb5979a97 chore(release): v1.4.39.3 — dashboard slim/thick merge robustness and list-page precision
The post-deploy CI for v1.4.39.2 surfaced eight e2e failures across
chromium-desktop and chromium-mobile (chart-overlay-controls,
dashboard, charts-mobile, measurement-flow) that all routed through
two narrow regressions and one durable test-fixture gap.

### Fixed

- Dashboard slim/thick merge no longer blanks the tile strip when the
  slim slice resolves with an empty summaries record. The v1.4.39.2
  inline `slim?.summaries ?? thick?.summaries` short-circuited on
  JavaScript's truthy-empty-object even when thick carried the full
  payload. The merge moved to a pure `mergeSlimAndThickAnalytics`
  helper that uses object emptiness as the discriminator: populated
  slim still wins on overlapping fields (progressive-paint contract
  preserved), empty slim falls through to thick, both empty leaves
  the dashboard's data-floor gates to render the appropriate empty
  state. Eight new behaviour-level unit tests cover the edge cases.
- `MeasurementList` non-grouped rows now preserve decimal precision
  on both the desktop table and the mobile card grid. The v1.4.37 W7c
  collapsed-list view passed every value through `fmt.integer`, which
  kept the per-day step / activity aggregates correct but silently
  truncated single weight / body-fat / temperature readings — 78.4 kg
  rendered as "78 kg". Grouped rows (cumulative HK type day
  aggregates) stay on `fmt.integer` because their readings are
  integer-only; non-grouped rows now use `fmt.number` which honours
  up to three fraction digits without forcing a minimum.

### Changed

- Playwright route mocks for `/api/analytics` migrate from the
  literal `**/api/analytics` string glob to the regex form
  `/\/api\/analytics(\?|$)/` across every authenticated spec. The
  string glob does not match the sliced URL form
  `/api/analytics?slice=summaries` the v1.4.39.2 dashboard split
  fires alongside the thick request, so the slim request fell
  through to the real route on the seeded test user that returns
  empty summaries. Ten specs migrate: dashboard,
  chart-overlay-controls, measurement-flow, charts-mobile, a11y,
  v1427-responsive-sheet, v1427-coach-launch,
  v1427-measurements-add-param, v1427-insights-empty-state,
  onboarding-tour-passthrough. The regex form is the durable
  alignment with the v1.4.37 W-CI lesson that already swept
  `onboarding-flicker.spec.ts` and `mobile-viewport.spec.ts`.

### Notes

- No schema migration. Runtime + test-fixture-only changes.
- `pnpm test --run` green at 4662 / 4663 (1 long-standing skip).
- Full Playwright suite green at 116 passing / 36 skipped / 152 total
  on both `chromium-desktop` and `chromium-mobile` profiles, against
  the production Next.js build. Closes every failure from CI run
  26213226723.
2026-05-21 10:31:18 +02:00

158 lines
5.2 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* v1.4.27 F17 — 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", () => {
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);
});
}
});