Files
HealthLog/src/app/__tests__/insights-polish.test.ts
T
Marc-André Bombeck 1f4ba5939d style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean
baseline. Formatting only — no behaviour change. Generator-owned and
vendored artifacts stay untouched via .prettierignore.
2026-06-19 22:20:31 +02:00

290 lines
13 KiB
TypeScript

import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.19 A3 — guards for the polish pass on `/insights` and the
* dashboard:
*
* 1. The dashboard page does NOT mount `<CompareToggle />`. The
* comparison-baseline switch belongs on `/insights` only — the
* dashboard tile-strip is too tight for it (maintainer probe 2026-05-10).
* The underlying `compareBaseline` value is still consumed by
* every dashboard chart; only the on-surface affordance is gone.
* 2. The `/insights` page renders exactly ONE page-level refresh
* affordance — the regenerate icon on `<InsightsTabStrip>`. The
* hero strip no longer takes an onRegenerate prop. v1.4.28 retired
* the InsightAdvisorCard surface entirely.
* 3. The `/insights` page does NOT render the small BP / Weight /
* Pulse trend tiles — those duplicate the dashboard tile-strip.
* 4. The `stripChartTokens()` matcher catches lowercase + mixed-
* case AI-emitted tokens (`metric:blood_pressure_sweet_spot`),
* so the literal token text never reaches the DOM. The
* *render-side* parse matcher stays uppercase-only — only the
* *strip* is permissive.
*
* Lives under `src/app/__tests__` (next to quick-add-labels.test.ts)
* because the consumers are the dashboard + insights page sources.
*/
const ROOT = join(__dirname, "../../..");
const DASHBOARD_PATH = join(ROOT, "src/app/page.tsx");
const INSIGHTS_PATH = join(ROOT, "src/app/insights/page.tsx");
const INSIGHTS_LAYOUT_PATH = join(ROOT, "src/app/insights/layout.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.19 A3 — dashboard polish", () => {
it("dashboard page does NOT render <CompareToggle />", () => {
const src = load(DASHBOARD_PATH);
// The component import + JSX use are both gone. The toggle still
// lives on /insights via a separate import there.
expect(src).not.toMatch(/<CompareToggle\b/);
expect(src).not.toMatch(
/from\s+["']@\/components\/comparison\/compare-toggle["']/,
);
});
it("dashboard page still consumes layout.comparisonBaseline (only the UI is gone)", () => {
// Drift guard — if a future refactor accidentally drops the
// baseline plumbing along with the toggle, every chart on the
// dashboard would lose its comparison overlay silently. The
// resolved layout still carries the value to each chart.
const src = load(DASHBOARD_PATH);
expect(src).toContain("comparisonBaseline");
expect(src).toContain("compareBaseline");
});
});
describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
// v1.4.34 IW-B — `<CoachLaunchProvider>` + `<LayoutCoachMount>` moved
// up to the global `<AuthShell>` so the dashboard hero (and every
// other authed surface) can call `askCoach()` from the same context.
// The insights layout keeps the FAB but no longer wraps the provider
// or mounts the drawer.
it("the global auth-shell wraps the authenticated tree in <CoachLaunchProvider> and mounts the drawer", () => {
const shellSource = load(
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
);
expect(shellSource).toContain('from "@/lib/insights/coach-launch-context"');
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
});
it("the insights layout no longer double-mounts the provider or drawer", () => {
const layout = load(INSIGHTS_LAYOUT_PATH);
expect(layout).not.toContain('from "@/lib/insights/coach-launch-context"');
expect(layout).not.toContain("import { LayoutCoachMount }");
// FAB stays scoped to `/insights/**` because the floating button
// would distract on surfaces with a contextual inline CTA.
expect(layout).toContain("LayoutCoachFab");
});
it("the overview no longer wires the inline ask-the-Coach affordance", () => {
const src = load(INSIGHTS_PATH);
// v1.18.7 — the inline "ask the Coach / suggested questions" block was
// removed from the overview hero. The Coach is reached via the FAB +
// bottom-right drawer, so the hero no longer carries onAskCoach/onPickPrompt
// and the page no longer pulls the launch context.
expect(src).not.toMatch(/<HeroStrip[\s\S]*?onAskCoach=\{/);
expect(src).not.toMatch(/<HeroStrip[\s\S]*?onPickPrompt=\{/);
expect(src).not.toContain("useCoachLaunch");
});
it("the page no longer owns coachOpen + coachPrefill local state", () => {
const src = load(INSIGHTS_PATH);
// The local useState pair retired with MB4. The launch context
// (mounted by the layout) now owns the open + prefill lifecycle.
expect(src).not.toContain("setCoachOpen");
expect(src).not.toContain("setCoachPrefill");
});
});
describe("v1.4.19 A3 — /insights polish", () => {
it("does NOT mount <InsightAdvisorCard> anywhere (v1.4.28 retire)", () => {
const src = load(INSIGHTS_PATH);
// v1.4.28 retired the InsightAdvisorCard surface. The component
// file, every test fixture and the page-level mount are gone;
// Coach drawer is the sole conversational entry point on /insights.
expect(src).not.toContain("InsightAdvisorCard");
expect(src).not.toContain("insight-advisor-card");
});
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => {
// v1.4.25 W4 — the regenerate handler moved out of the mother page
// body entirely and now lives on the layout-mounted
// `<InsightsLayoutShell>` (which renders `<InsightsTabStrip>` with
// the advisor query's regenerate wired in). The mother-page hero
// no longer takes an `onRegenerate` prop, mirroring the v1.4.25 W3
// contract while accommodating the new routed sub-page layout.
const shellSrc = readFileSync(
join(ROOT, "src/components/insights/insights-layout-shell.tsx"),
"utf8",
);
expect(shellSrc).toMatch(
/<InsightsTabStrip[\s\S]*?onRegenerate=[\s\S]*?advisor\.regenerate[\s\S]*?\/>/,
);
const insightsSrc = load(INSIGHTS_PATH);
const heroBlock = insightsSrc.match(/<HeroStrip[\s\S]*?\/>/);
expect(heroBlock).not.toBeNull();
expect(heroBlock?.[0]).not.toContain("onRegenerate");
});
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
const src = load(INSIGHTS_PATH);
// The /insights tile strip used the same TrendCard component the
// dashboard does — duplicating numbers without any extra context.
// The import being gone is the load-bearing assertion; the
// leave-behind comment explaining the removal references
// `<TrendCard>` in backtick-quoted prose, so we don't grep for
// the JSX literal itself.
expect(src).not.toContain('from "@/components/charts/trend-card"');
// Also verify no JSX-style mount survives (`<TrendCard ` + space
// for an attribute, or `<TrendCard\n` for a multi-line element).
expect(src).not.toMatch(/<TrendCard[\s\n]/);
});
it("does NOT contain raw `metric:<name>` template strings in static prose", () => {
const src = load(INSIGHTS_PATH);
// The chart-token pattern (`metric:WEIGHT`) is reserved for AI
// prose and is stripped via `stripChartTokens()` before render.
// The page source itself must not hand-write the token string
// between JSX delimiters.
expect(src).not.toMatch(/>\s*metric:[A-Za-z_]+\s*</);
});
});
describe("v1.4.19 A3 — chart-token leak hardening", () => {
// The visible "metric: blood_pressure_sweet" leak the maintainer saw at the
// bottom of /insights was a model-emitted lowercase chart token
// that the v1.4.16 strip regex (`/metric:[A-Z_]+/g`) didn't catch
// because the character class only matches uppercase. The widened
// matcher in `chart-tokens.ts` strips ANY case so the literal
// string can no longer reach the DOM. Allowlisted *render* paths
// remain uppercase-only — only the strip is permissive.
it("the strip matcher catches lowercase model output", async () => {
const { stripChartTokens } = await import("@/lib/insights/chart-tokens");
expect(
stripChartTokens("BP plateau metric:blood_pressure_sweet today"),
).toBe("BP plateau today");
});
it("the strip matcher catches mixed-case model output", async () => {
const { stripChartTokens } = await import("@/lib/insights/chart-tokens");
expect(stripChartTokens("note metric:BloodPressureSys then")).toBe(
"note then",
);
});
it("the parse matcher still returns ONLY uppercase allowlisted tokens", async () => {
const { parseChartTokens } = await import("@/lib/insights/chart-tokens");
// Lowercase / mixed-case tokens are stripped from prose but never
// mounted as a chart — render path stays strict.
expect(parseChartTokens("metric:blood_pressure_sweet")).toEqual([]);
expect(parseChartTokens("metric:BloodPressureSys")).toEqual([]);
expect(parseChartTokens("metric:WEIGHT")).toEqual(["metric:WEIGHT"]);
});
});
describe("v1.4.22 W5 reconcile (Code-H2) — BD-Zielbereich tile compareDelta uses period-aligned baseline", () => {
/**
* Up to v1.4.22 W4 the BD tile computed `bp30 - bpAll`
* (last-30-days minus all-time) regardless of which window the user
* picked under Settings → Dashboard. The caption still rendered "vs.
* last month" / "vs. last year" via `comparison.captionLastMonth` /
* `comparison.captionLastYear`, so the user read a sentence whose
* numerator was actually a 30d-vs-all-time delta. Every other tile
* routes through `tileCompareDelta()` with `summary.avg30LastMonth`
* / `summary.avg30LastYear` for honest period-aligned math.
*
* This test pins that the dashboard now subtracts the prior-period
* window (priorMonth / priorYear) instead of all-time.
*/
it("subtracts bpInTargetPctPriorMonth when comparisonBaseline=lastMonth", () => {
const src = load(DASHBOARD_PATH);
// The fix introduces `bpComparePrior` keyed by `compareBaseline`.
expect(src).toContain("bpInTargetPctPriorMonth");
expect(src).toContain("bpInTargetPctPriorYear");
// The all-time shortcut from v1.4.22 A2 must not subtract `bpAll`
// for the comparison delta any more.
expect(src).not.toMatch(/bp30\s*-\s*bpAll/);
});
it("ships priorMonth + priorYear pcts in the analytics envelope", () => {
const ROUTE_SRC = readFileSync(
join(ROOT, "src/app/api/analytics/route.ts"),
"utf8",
);
expect(ROUTE_SRC).toContain("bpInTargetPctPriorMonth");
expect(ROUTE_SRC).toContain("bpInTargetPctPriorYear");
});
});
describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
/**
* The comparison-overlay toggle is a global preference. Up to v1.4.21
* the toggle existed both in `/settings/dashboard` (canonical) and
* `/insights` (via `<DailyBriefing metaSlot={<CompareToggle />} />`).
* Per `feedback_settings_no_split.md` the toggle now lives in
* Settings only — the on-surface affordance is gone from `/insights`.
* Every chart still consumes the resolved `comparisonBaseline` value
* the same way it did before, so flipping the Settings toggle still
* propagates to the page on next refetch.
*/
it("/insights does NOT mount <CompareToggle /> anywhere", () => {
const src = load(INSIGHTS_PATH);
expect(src).not.toMatch(/<CompareToggle\b/);
expect(src).not.toMatch(
/from\s+["']@\/components\/comparison\/compare-toggle["']/,
);
});
it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => {
// Drift guard — v1.4.25 W4 split each metric onto its own sub-route
// under `/insights/<slug>`, and `compareBaseline` propagation now
// happens via `useInsightsLayoutPrefs()` on every sub-page. The
// central hook is the load-bearing surface; assert it carries both
// the resolved value and the persisted preference key.
const hookSrc = readFileSync(
join(ROOT, "src/hooks/use-insights-layout-prefs.ts"),
"utf8",
);
expect(hookSrc).toContain("comparisonBaseline");
expect(hookSrc).toContain("compareBaseline");
});
});
describe("v1.16.8 — insights overview cold paint", () => {
it("fires every overview query in parallel — none gated on another query's data", () => {
const src = load(INSIGHTS_PATH);
// The derived batch + analytics + comprehensive reads all gate on
// auth alone. Chaining any of them behind another query's resolved
// payload re-introduces the sequential reveal.
expect(src).toMatch(/useDashboardDerived\(isAuthenticated\)/);
expect(src).toMatch(/const analyticsQuery = useAnalyticsQuery\(\);/);
expect(src).toMatch(/enabled:\s*isAuthenticated/);
expect(src).not.toMatch(/useDashboardDerived\([^)]*data/);
});
it("reserves the health-score column while the analytics payload is pending", () => {
const src = load(INSIGHTS_PATH);
// The hero receives the pending flag so the score card's slot holds
// its footprint from first paint (skeleton in `<HeroStrip>`).
expect(src).toMatch(
/healthScorePending=\{analyticsQuery\.isPending && !analytics\}/,
);
});
it("hero strip renders the score-card skeleton in the reserved column", () => {
const heroSrc = readFileSync(
join(ROOT, "src/components/insights/hero-strip.tsx"),
"utf8",
);
expect(heroSrc).toContain('data-slot="health-score-card-skeleton"');
// The two-column split keys on score-or-pending, not score alone.
expect(heroSrc).toMatch(/\(healthScore \|\| healthScorePending\) &&/);
});
});