diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 49f419399..317554153 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -80,6 +80,12 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true }); + vi.mocked(generateBiomarkerStatus).mockResolvedValue({ + hasProvider: true, + text: "ok", + cached: true, + updatedAt: new Date().toISOString(), + }); }); describe("GET /api/insights/biomarker-assessment", () => { @@ -130,4 +136,68 @@ describe("GET /api/insights/biomarker-assessment", () => { const body = (await res.json()) as { meta?: { errorCode?: string } }; expect(body.meta?.errorCode).toBe("assistant.disabled.insightStatus"); }); + + it("preserves the safe terminal fallback produced when a causal claim is screened", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(generateBiomarkerStatus).mockResolvedValueOnce({ + hasProvider: false, + text: "The assessment could not be completed safely. Your recorded measurements remain available.", + cached: true, + updatedAt: null, + }); + + const res = await callGet(makeReq("bm-screened")); + const body = (await res.json()) as { + data: { + hasProvider: boolean; + text: string | null; + cached: boolean; + updatedAt: string | null; + }; + }; + + expect(res.status).toBe(200); + expect(body.data).toEqual({ + hasProvider: false, + text: "The assessment could not be completed safely. Your recorded measurements remain available.", + cached: true, + updatedAt: null, + }); + expect(JSON.stringify(body)).not.toContain("medication caused"); + expect(JSON.stringify(body)).not.toContain("causal_claim"); + }); + + it("keeps a preparing cache miss distinct from a terminal negative cache", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(generateBiomarkerStatus) + .mockResolvedValueOnce({ + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: true, + }) + .mockResolvedValueOnce({ + hasProvider: false, + text: "Assessment temporarily unavailable. Your recorded measurements remain available.", + cached: true, + updatedAt: null, + }); + + const preparing = await (await callGet(makeReq("bm-preparing"))).json(); + const negativeCache = await (await callGet(makeReq("bm-negative"))).json(); + + expect(preparing.data).toMatchObject({ + hasProvider: true, + text: null, + cached: false, + preparing: true, + }); + expect(negativeCache.data).toMatchObject({ + hasProvider: false, + cached: true, + text: expect.stringContaining("temporarily unavailable"), + }); + expect(negativeCache.data).not.toHaveProperty("preparing", true); + }); }); diff --git a/src/app/insights/__tests__/assessment-spine-order.test.ts b/src/app/insights/__tests__/assessment-spine-order.test.ts index baf9fc31b..66fb9f720 100644 --- a/src/app/insights/__tests__/assessment-spine-order.test.ts +++ b/src/app/insights/__tests__/assessment-spine-order.test.ts @@ -139,3 +139,52 @@ describe("metric subpages — retired range row + Letzte-Messung are gone", () = }); } }); + +describe("assessment presentation contract", () => { + it("uses the shared readable foreground and compact rhythm for populated prose", () => { + const cardSource = readFileSync( + join( + process.cwd(), + "src", + "components", + "insights", + "insight-status-card.tsx", + ), + "utf8", + ); + const moodSource = readFileSync( + join( + process.cwd(), + "src", + "components", + "insights", + "mood", + "mood-insights-sections.tsx", + ), + "utf8", + ); + const moodBodySource = readFileSync( + join( + process.cwd(), + "src", + "components", + "insights", + "mood", + "mood-better-days.tsx", + ), + "utf8", + ); + + expect(cardSource).toContain( + 'className="animate-insight-in gap-2 py-3 md:py-4"', + ); + expect(cardSource).toContain('className="text-foreground text-sm"'); + expect(moodSource).toContain( + 'className="animate-insight-in gap-2 py-3 md:py-4"', + ); + expect(moodSource).toContain('data-slot="insight-assessment"'); + expect(moodBodySource).toContain( + 'className="text-foreground mb-2 text-sm"', + ); + }); +}); diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index 6a8497e98..d40928eb2 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -82,20 +82,30 @@ describe("", () => { expect(html).toContain("Pulse stayed inside the band."); }); - it("renders the no-provider state without touching the strip path", () => { + it("renders the no-provider setup state when no terminal text exists", () => { const html = render( - , + , ); // v1.18.6 — the no-provider tile is a guided-setup explainer + a // Settings link, not a bare "unavailable" line. expect(html).toContain("Connect an AI provider"); expect(html).toContain("Open AI settings"); expect(html).toContain("/settings/ai"); - expect(html).not.toContain("ignored when hasProvider is false"); + }); + + it("renders a screened terminal fallback even when its envelope has no provider", () => { + const html = render( + , + ); + + expect(html).toContain("could not be completed safely"); + expect(html).toContain("recorded measurements remain available"); + expect(html).not.toContain("Connect an AI provider"); + expect(html).not.toContain("because your medication caused"); }); it("renders the empty-text state without crashing on null", () => { @@ -148,4 +158,36 @@ describe("", () => { // we do not double-paint the announcement. expect(html).not.toContain("animate-spin"); }); + + it("keeps loading, preparing, no-provider, empty, and populated states distinct with stable compact geometry", () => { + const loading = render( + , + ); + const preparing = render( + , + ); + const noProvider = render( + , + ); + const empty = render(); + const populated = render( + , + ); + + expect(loading).toContain('data-testid="insight-status-card-loading"'); + expect(preparing).toContain('data-testid="insight-status-card-preparing"'); + expect(noProvider).toContain('data-slot="insight-status-no-provider-cta"'); + expect(empty).toContain("No assessment yet."); + expect(populated).toContain('data-slot="insight-assessment"'); + + for (const html of [loading, preparing, noProvider, empty, populated]) { + expect(html).toContain("gap-2"); + expect(html).toContain("py-3"); + expect(html).toContain("md:py-4"); + } + expect(populated).toContain("text-foreground"); + }); }); diff --git a/src/components/records/__tests__/allergy-manager.test.tsx b/src/components/records/__tests__/allergy-manager.test.tsx new file mode 100644 index 000000000..dd2b60a9b --- /dev/null +++ b/src/components/records/__tests__/allergy-manager.test.tsx @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mocks = vi.hoisted(() => ({ + rows: [] as Array>, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: mocks.rows, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: () => null, +})); +vi.mock("@/components/data-list", () => ({ + DeleteButton: (props: { title: string }) => ( + + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AllergyManager } from "../allergy-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.rows = []; +}); + +describe(" action grammar", () => { + it("shows exactly one first-entry action and no duplicate top add action when empty", () => { + const html = render(); + + expect(html.match(/Add your first allergy/g)).toHaveLength(1); + expect(html).not.toMatch(/>Add allergy<\/button>/); + }); + + it("shows one normal top add action plus edit/delete controls when populated", () => { + mocks.rows = [ + { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: null, + reaction: "Hives", + note: null, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + + const html = render(); + + expect(html.match(/>Add allergy<\/button>/g)).toHaveLength(1); + expect(html).not.toContain("Add your first allergy"); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete this allergy?"'); + expect(html).toContain("Penicillin"); + }); +}); diff --git a/src/components/records/__tests__/family-history-manager.test.tsx b/src/components/records/__tests__/family-history-manager.test.tsx new file mode 100644 index 000000000..1064b5607 --- /dev/null +++ b/src/components/records/__tests__/family-history-manager.test.tsx @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mocks = vi.hoisted(() => ({ + rows: [] as Array>, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: mocks.rows, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: () => null, +})); +vi.mock("@/components/data-list", () => ({ + DeleteButton: (props: { title: string }) => ( + + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { FamilyHistoryManager } from "../family-history-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.rows = []; +}); + +describe(" action grammar", () => { + it("shows exactly one first-entry action and no duplicate top add action when empty", () => { + const html = render(); + + expect(html.match(/Add your first entry/g)).toHaveLength(1); + expect(html).not.toMatch(/>Add entry<\/button>/); + }); + + it("shows one normal top add action plus edit/delete controls when populated", () => { + mocks.rows = [ + { + id: "family-1", + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + note: null, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + + const html = render(); + + expect(html.match(/>Add entry<\/button>/g)).toHaveLength(1); + expect(html).not.toContain("Add your first entry"); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete this entry?"'); + expect(html).toContain("Type 2 diabetes"); + expect(html).toContain("Mother"); + }); +}); diff --git a/src/components/records/__tests__/health-profile-facts-manager.test.tsx b/src/components/records/__tests__/health-profile-facts-manager.test.tsx index 669ed0bf3..54641b031 100644 --- a/src/components/records/__tests__/health-profile-facts-manager.test.tsx +++ b/src/components/records/__tests__/health-profile-facts-manager.test.tsx @@ -107,6 +107,27 @@ beforeEach(() => { }); describe("health-profile fact removal control", () => { + it("uses one Save label for new and existing lifestyle facts", () => { + render(); + + const actionLabels = renderedButtons + .filter( + (props) => + props.type === "button" && + props.variant === "outline" && + typeof props.onClick === "function", + ) + .map((props) => { + const children = Array.isArray(props.children) + ? props.children + : [props.children]; + return children.filter((child) => typeof child === "string").join(""); + }); + + expect(actionLabels).toEqual(["Save", "Save", "Save"]); + expect(actionLabels).not.toContain("Correct"); + }); + it("renders a confirmed removal action only for each recorded current fact", () => { const { html } = render(); diff --git a/src/hooks/__tests__/use-insight-status.test.ts b/src/hooks/__tests__/use-insight-status.test.ts index b36ddd5e8..59580c0f1 100644 --- a/src/hooks/__tests__/use-insight-status.test.ts +++ b/src/hooks/__tests__/use-insight-status.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { nextStatusPollInterval, @@ -65,4 +67,18 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => { nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true), ).toBe(false); }); + + it("exposes poll exhaustion as a terminal state instead of leaving preparing true forever", () => { + const source = readFileSync( + join(process.cwd(), "src", "hooks", "use-insight-status.ts"), + "utf8", + ); + + // Stopping the timer is only half of the contract. Once the ceiling is + // reached, callers must be able to distinguish an exhausted attempt from + // an actively preparing response and must not keep painting the preparing + // skeleton forever. + expect(source).toContain("pollExhausted"); + expect(source).toMatch(/preparing:\s*false/); + }); });