diff --git a/src/app/__tests__/targets-coach-mount.test.tsx b/src/app/__tests__/targets-coach-mount.test.tsx new file mode 100644 index 000000000..68fd321ef --- /dev/null +++ b/src/app/__tests__/targets-coach-mount.test.tsx @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +/** + * v1.4.25 W3e — Coach drawer mount + per-card CTA gate. + * + * Two scenarios: + * 1. AI provider configured → per-card "Ask Coach" CTAs render + * alongside each card; the drawer is mounted at the page level. + * 2. AI provider NOT configured → CTAs are completely absent (no + * broken-button state) but the drawer mount stays present (it's + * controlled by the parent regardless; no harm in mounting it + * collapsed, and it costs ~0 since closed Sheets render only the + * portal root). + */ + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/targets", +})); + +const sampleData = { + targets: [ + { + type: "WEIGHT", + label: "Weight", + current: 78, + average30: 78, + trend: "stable", + unit: "kg", + range: { min: 60, max: 80 }, + classification: { category: "Normal", color: "#50fa7b" }, + source: "WHO BMI", + daysInRange7d: 5, + daysLogged7d: 7, + lastMetGoalAt: null, + streakDays: 0, + insufficientData: false, + consistency7d: ["in", "in", "in", "in", "in", "in", "in"], + }, + ], + pageSummary: { + targetsMetThisWeek: 1, + totalTargets: 1, + streakHighlight: null, + }, + bpDiastolic: { current: null, average30: null, range: null }, + profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null }, +}; + +let providerChainResponse: { activeProvider: string | null } | null = null; + +vi.mock("@tanstack/react-query", () => { + const noopClient = { + invalidateQueries: vi.fn(), + setQueryData: vi.fn(), + getQueryData: vi.fn(), + refetchQueries: vi.fn(), + }; + return { + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") { + return { + data: providerChainResponse, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + } + return { + data: sampleData, + isLoading: false, + isError: false, + refetch: vi.fn(), + }; + }, + useQueryClient: () => noopClient, + useMutation: () => ({ + mutate: vi.fn(), + mutateAsync: vi.fn(), + isPending: false, + reset: vi.fn(), + }), + }; +}); + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ + user: { + id: "u1", + username: "marc", + email: "marc@example.com", + role: "USER", + }, + isAuthenticated: true, + isLoading: false, + refetch: vi.fn(), + }), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import TargetsPage from "../targets/page"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +describe("/targets page — Coach drawer mount + CTA gate", () => { + it("renders the per-card Coach CTA when an AI provider is configured", () => { + providerChainResponse = { activeProvider: "openai" }; + const html = render(); + expect(html).toContain('data-slot="target-coach-cta"'); + expect(html).toContain("Ask Coach"); + }); + + it("hides every per-card Coach CTA when no AI provider is configured", () => { + providerChainResponse = { activeProvider: null }; + const html = render(); + expect(html).not.toContain('data-slot="target-coach-cta"'); + }); + + it("renders nothing different when chainStatus query is still loading", () => { + providerChainResponse = null; + const html = render(); + // Same as the "no provider" branch — until we know the provider is + // configured we don't render a CTA whose click would 404. + expect(html).not.toContain('data-slot="target-coach-cta"'); + }); +}); diff --git a/src/lib/ai/coach/__tests__/target-prompts.test.ts b/src/lib/ai/coach/__tests__/target-prompts.test.ts new file mode 100644 index 000000000..ecf898de3 --- /dev/null +++ b/src/lib/ai/coach/__tests__/target-prompts.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { buildTargetPrompt } from "../target-prompts"; +import { coachScopeForTarget } from "../target-scope"; + +/** + * v1.4.25 W3e — per-card Coach prefill builder. + * + * Pin the per-metric template shape + the locale switch so a + * refactor cannot silently flatten the prompts to a single fallback. + */ + +describe("buildTargetPrompt", () => { + const base = { + current: 124, + range: { min: 110, max: 130 }, + unit: "mmHg", + status: "On target", + streakDays: 5, + daysInRange7d: 6, + }; + + it("returns the EN BP template with live values", () => { + const out = buildTargetPrompt({ + type: "BLOOD_PRESSURE", + locale: "en", + ...base, + }); + expect(out).toContain("blood pressure"); + expect(out).toContain("124 mmHg"); + expect(out).toContain("110–130 mmHg"); + expect(out).toContain("6 days"); + expect(out).toContain("5 days"); // streak + }); + + it("returns the DE BP template with live values", () => { + const out = buildTargetPrompt({ + type: "BLOOD_PRESSURE", + locale: "de", + ...base, + }); + expect(out).toContain("Blutdruck-Wert"); + expect(out).toContain("124 mmHg"); + expect(out).toContain("110–130 mmHg"); + expect(out).toContain("6 Tagen"); + }); + + it("omits the streak fragment when streakDays < 3", () => { + const out = buildTargetPrompt({ + type: "BLOOD_PRESSURE", + locale: "en", + ...base, + streakDays: 2, + }); + expect(out).not.toContain("current streak"); + }); + + it("renders the MOOD_STABILITY verbal label inline", () => { + const stableOut = buildTargetPrompt({ + type: "MOOD_STABILITY", + locale: "en", + ...base, + current: 0.5, + unit: "σ", + range: { min: 0, max: 0.5 }, + }); + expect(stableOut).toContain("stable"); + const variableOut = buildTargetPrompt({ + type: "MOOD_STABILITY", + locale: "en", + ...base, + current: 1.5, + unit: "σ", + range: { min: 0, max: 0.5 }, + }); + expect(variableOut).toContain("variable"); + const highOut = buildTargetPrompt({ + type: "MOOD_STABILITY", + locale: "en", + ...base, + current: 2.5, + unit: "σ", + range: { min: 0, max: 0.5 }, + }); + expect(highOut).toContain("highly variable"); + }); + + it("falls back to the general template for unknown target types", () => { + const out = buildTargetPrompt({ + type: "FUTURE_METRIC_X", + locale: "en", + ...base, + }); + expect(out).toContain("how I'm doing on"); + expect(out).toContain("future metric x"); + }); + + it("DE fallback localises the question shape", () => { + const out = buildTargetPrompt({ + type: "FUTURE_METRIC_X", + locale: "de", + ...base, + }); + expect(out).toContain("Wie steht es um meine"); + }); +}); + +describe("coachScopeForTarget", () => { + it("maps WEIGHT + BMI to the weight source", () => { + expect(coachScopeForTarget("WEIGHT")).toEqual(["weight"]); + expect(coachScopeForTarget("BMI")).toEqual(["weight"]); + }); + + it("maps BLOOD_PRESSURE and BLOOD_PRESSURE_IN_TARGET to bp", () => { + expect(coachScopeForTarget("BLOOD_PRESSURE")).toEqual(["bp"]); + expect(coachScopeForTarget("BLOOD_PRESSURE_IN_TARGET")).toEqual(["bp"]); + }); + + it("maps mood + stability to the mood source", () => { + expect(coachScopeForTarget("MOOD_SCORE")).toEqual(["mood"]); + expect(coachScopeForTarget("MOOD_STABILITY")).toEqual(["mood"]); + }); + + it("returns an empty array for unmapped targets (use defaults)", () => { + expect(coachScopeForTarget("BLOOD_GLUCOSE_FASTING")).toEqual([]); + expect(coachScopeForTarget("BODY_FAT")).toEqual([]); + expect(coachScopeForTarget("FUTURE_X")).toEqual([]); + }); +});