mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
The /insights mother page no longer fetches the `/api/insights/general-status` endpoint — InsightAdvisorCard reads `/api/insights/generate` (a POST that doubles as cache-aware read), gated by `/api/insights/comprehensive` returning a non-null payload. The old test mocked a dead endpoint, hit the EmptyState CTA path, and asserted on a literal AI prose substring that the renderer never produces verbatim — twice brittle. Swap the mocks to the real endpoints with a minimal-but-valid `InsightResult` payload, then assert on the populated card's stable structural hook `[data-slot="insight-summary"]`. The hook is set by `InsightAdvisorCard` on the populated-body branch only, so the assertion still proves "the card mounts its summary when the payload arrives" — the actual contract under test — without coupling to AI prose that varies per provider and cache state by design.
250 lines
9.2 KiB
TypeScript
250 lines
9.2 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
|
|
|
/**
|
|
* Authenticated dashboard render — proves the seed user can reach the
|
|
* home dashboard and the core widgets paint without uncaught client
|
|
* errors.
|
|
*
|
|
* The dashboard self-gates each tile on (visible AND has data), so a
|
|
* fresh seed user with zero measurements would render an empty strip
|
|
* and skip all charts. Rather than seed measurements before this spec
|
|
* runs (which couples the spec to a particular signature of the
|
|
* `/api/measurements` shape), we mock `/api/analytics` and
|
|
* `/api/dashboard/widgets` to return a deterministic dataset. The
|
|
* widget layout + chart wrappers are pure enough that asserting on the
|
|
* mocked-data render exercises the same code paths as a real session.
|
|
*/
|
|
test.describe("authenticated dashboard render", () => {
|
|
test.use({ storageState: STORAGE_STATE_PATH });
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
// Mock analytics with a populated weight summary so the weight tile
|
|
// and chart render. `count` must be >0 for the tile to clear the
|
|
// data-floor gate in `src/app/page.tsx`.
|
|
await page.route("**/api/analytics", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
summaries: {
|
|
WEIGHT: {
|
|
latest: 78.5,
|
|
avg7: 78.2,
|
|
avg30: 77.9,
|
|
slope30: { slope: -0.05, direction: "down" },
|
|
count: 30,
|
|
},
|
|
BLOOD_PRESSURE_SYS: {
|
|
latest: 124,
|
|
avg7: 122,
|
|
avg30: 121,
|
|
slope30: { slope: 0.1, direction: "flat" },
|
|
count: 25,
|
|
},
|
|
BLOOD_PRESSURE_DIA: {
|
|
latest: 80,
|
|
avg7: 79,
|
|
avg30: 78,
|
|
slope30: { slope: 0.05, direction: "flat" },
|
|
count: 25,
|
|
},
|
|
PULSE: {
|
|
latest: 68,
|
|
avg7: 70,
|
|
avg30: 71,
|
|
slope30: { slope: -0.2, direction: "down" },
|
|
count: 25,
|
|
},
|
|
},
|
|
bpInTargetPct: 78,
|
|
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,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// The chart's per-type fetch reads `json.data.measurements` and
|
|
// expects a `meta.total`. Return a small dataset so the chart has
|
|
// points to plot — otherwise Recharts mounts a placeholder instead
|
|
// of the recharts-wrapper SVG and the assertion below times out.
|
|
await page.route("**/api/measurements*", (route) => {
|
|
const url = new URL(route.request().url());
|
|
const type = url.searchParams.get("type") ?? "WEIGHT";
|
|
const baseValue =
|
|
type === "BLOOD_PRESSURE_SYS"
|
|
? 124
|
|
: type === "BLOOD_PRESSURE_DIA"
|
|
? 80
|
|
: type === "PULSE"
|
|
? 70
|
|
: 78.5;
|
|
const measurements = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `m_${type}_${i}`,
|
|
type,
|
|
value: baseValue + (i % 3) - 1,
|
|
measuredAt: new Date(Date.now() - i * 86_400_000).toISOString(),
|
|
notes: null,
|
|
}));
|
|
return route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: { measurements, meta: { total: measurements.length } },
|
|
error: null,
|
|
}),
|
|
});
|
|
});
|
|
|
|
// The /insights mother page gates the InsightAdvisorCard on
|
|
// `/api/insights/comprehensive` returning a non-null payload
|
|
// (`totalMeasurements` is the gate). Mock it so the page renders
|
|
// the card surface instead of the EmptyState CTA.
|
|
await page.route("**/api/insights/comprehensive", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
summaries: {},
|
|
bmi: 24.2,
|
|
bmiClassification: null,
|
|
bpClassification: null,
|
|
bpPctInTarget: 78,
|
|
bpTargets: { sysLow: 120, sysHigh: 140, diaLow: 80, diaHigh: 90 },
|
|
weightBpCorrelation: null,
|
|
scatterData: [],
|
|
bpMedicationCorrelation: null,
|
|
bpMedicationScatterData: [],
|
|
medications: [],
|
|
alerts: [],
|
|
hasOpenAiKey: true,
|
|
dataSpanDays: 90,
|
|
totalMeasurements: 25,
|
|
moodSummary: null,
|
|
moodBpCorrelation: null,
|
|
moodBpScatterData: [],
|
|
moodWeightCorrelation: null,
|
|
moodWeightScatterData: [],
|
|
moodPulseCorrelation: null,
|
|
moodPulseScatterData: [],
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
|
|
// InsightAdvisorCard reads `/api/insights/generate` (a POST that
|
|
// doubles as cache-aware read). Mock a minimal-but-valid
|
|
// InsightResult so the card renders the populated body branch with
|
|
// the `data-slot="insight-summary"` container the assertion below
|
|
// pins on. The exact prose is intentionally NOT asserted — AI
|
|
// Insights are HealthLog's differentiator and the rendered text
|
|
// depends on provider + cache state. The contract under test is
|
|
// "an insight card with a populated body is visible", not "the
|
|
// string X is on screen".
|
|
await page.route("**/api/insights/generate", (route) =>
|
|
route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({
|
|
data: {
|
|
insights: {
|
|
insightType: "general",
|
|
summary: "Blood-pressure trend is stable; continue routine.",
|
|
classification: "gut",
|
|
classificationLabel: "Good",
|
|
findings: [],
|
|
correlations: [],
|
|
primaryRecommendation: "Keep up the current routine.",
|
|
recommendations: [],
|
|
dataQuality: {
|
|
coverage: "good",
|
|
gaps: [],
|
|
confidence: "hoch",
|
|
},
|
|
disclaimer: "Not medical advice.",
|
|
},
|
|
cached: true,
|
|
cachedAt: new Date().toISOString(),
|
|
legacyPayload: false,
|
|
},
|
|
error: null,
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
test("dashboard tile strip + chart + insight card render", async ({
|
|
page,
|
|
}) => {
|
|
const consoleErrors: string[] = [];
|
|
page.on("pageerror", (err) => {
|
|
consoleErrors.push(err.message);
|
|
});
|
|
page.on("console", (msg) => {
|
|
if (msg.type() === "error") consoleErrors.push(msg.text());
|
|
});
|
|
|
|
await page.goto("/", { waitUntil: "domcontentloaded" });
|
|
|
|
// Tile strip — `data-slot="dashboard-tile-strip"` is the stable hook
|
|
// the dashboard exposes. The component sets `data-tile-count` to the
|
|
// active tile count; assert it's >0 so we know at least one tile
|
|
// cleared the visibility + data-floor gate.
|
|
const strip = page.locator('[data-slot="dashboard-tile-strip"]');
|
|
await expect(strip).toBeVisible({ timeout: 10_000 });
|
|
const tileCount = Number(await strip.getAttribute("data-tile-count"));
|
|
expect(tileCount).toBeGreaterThan(0);
|
|
|
|
// At least one chart must render. Recharts mounts a `<svg>` with
|
|
// `class="recharts-surface"` (or a `<div class="recharts-wrapper">`
|
|
// around it). Use the recharts class so we don't accidentally match
|
|
// an aria-hidden lucide-react icon.
|
|
await expect(
|
|
page.locator(".recharts-wrapper, .recharts-surface").first(),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// AI insight card: visit the dedicated /insights page (the
|
|
// mother surface that mounts the InsightAdvisorCard). We assert
|
|
// on the populated-card's stable structural hook
|
|
// (`data-slot="insight-summary"`) rather than a literal piece of
|
|
// AI prose. AI Insights are evidence-grounded and dynamic by
|
|
// design — the rendered text changes per provider + cache state
|
|
// and never repeats verbatim, so pinning a literal substring is
|
|
// brittle and contradicts the differentiator. The contract under
|
|
// test is "the insight card mounts its summary body when the
|
|
// payload arrives", which is exactly what the slot signals.
|
|
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
|
await expect(
|
|
page.locator('[data-slot="insight-summary"]'),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Filter out a small set of known-noise messages — we care about
|
|
// real React/JS uncaught errors only. 404s from optional assets
|
|
// (manifest icons, prefetched chunks for unrendered routes) are
|
|
// explicitly tolerated; they don't affect the dashboard render.
|
|
const significant = consoleErrors.filter(
|
|
(msg) =>
|
|
!/ResizeObserver loop|Download the React DevTools|Warning: |\[Fast Refresh\]|Failed to load resource|net::ERR_/i.test(
|
|
msg,
|
|
),
|
|
);
|
|
expect(significant, significant.join("\n")).toEqual([]);
|
|
});
|
|
});
|