diff --git a/src/app/__tests__/insights-polish.test.ts b/src/app/__tests__/insights-polish.test.ts new file mode 100644 index 000000000..aeb7fc91c --- /dev/null +++ b/src/app/__tests__/insights-polish.test.ts @@ -0,0 +1,140 @@ +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 ``. The + * comparison-baseline switch belongs on `/insights` only — the + * dashboard tile-strip is too tight for it (Marc 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 hero's regenerate button. The advisor card + * no longer carries its own onRegenerate handler that + * duplicated the hero one and again surfaced "Analyse starten" + * in the empty-state CTA. The per-recommendation Regenerate + * stays — that's per-card, not page-level. + * 3. The `/insights` page does NOT render the small BP / Weight / + * Pulse trend tiles — those duplicate the dashboard tile-strip. + * 4. The `/insights` page does NOT pass the `aiOverviewTitle` + * ("Persönlicher Berater") subtitle into ``. + * The CardTitle ("KI-Gesundheitsanalyse") is sufficient framing; + * the second subtitle was an empty-section placeholder Marc + * called "Titel da aber NICHTS PASSIERT". + * 5. 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"); + +function load(path: string): string { + return readFileSync(path, "utf8"); +} + +describe("v1.4.19 A3 — dashboard polish", () => { + it("dashboard page does NOT render ", () => { + 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(/ { + // 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.19 A3 — /insights polish", () => { + it("renders exactly ONE on-surface refresh affordance at the page level", () => { + const src = load(INSIGHTS_PATH); + // The hero owns the page-level regenerate button (mounted via + // `onRegenerate={advisor.regenerate}`). The advisor card no + // longer carries its own duplicate handler. + const regenerateProps = src.match(/onRegenerate=\{advisor\.regenerate\}/g); + expect(regenerateProps?.length ?? 0).toBe(1); + }); + + it("does NOT render the duplicate 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 + // `` 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 (`", () => { + const src = load(INSIGHTS_PATH); + // The "Persönlicher Berater" / "Personal advisor" string was a + // dead title above an empty-state body when the user had no + // provider configured. Removed; the CardTitle inside the advisor + // card already says "KI-Gesundheitsanalyse". + expect(src).not.toContain('t("insights.aiOverviewTitle")'); + }); + + it("does NOT contain raw `metric:` 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* { + // The visible "metric: blood_pressure_sweet" leak Marc 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"]); + }); +});