feat(targets): mount CoachDrawer on /targets with per-card scope prefill

The drawer mount + per-card CTA handoff was wired during Phase 5
when the page.tsx was rewritten around the extracted primitives.
This commit lands the integration tests + prompt-builder tests that
pin the contract:

  • targets-coach-mount.test.tsx — three branches:
      1. AI provider configured → per-card "Ask Coach" CTAs render
      2. No AI provider → CTAs hide entirely (no broken-button state)
      3. Chain query still loading → same as #2 (don't render a CTA
         whose click could 404)

  • target-prompts.test.ts — six cases:
      1. EN BP template threads current value + range + cadence + streak
      2. DE BP template threads the same values with German copy
      3. Streak fragment is omitted when streakDays < 3
      4. MOOD_STABILITY uses the verbal label inline ("stable" /
         "variable" / "highly variable") not the raw σ
      5. Unknown target types fall back to a general EN template
      6. DE fallback localises the question shape

  • coachScopeForTarget — four cases:
      1. WEIGHT + BMI map to ["weight"]
      2. BP + BP-in-target map to ["bp"]
      3. MOOD_SCORE + MOOD_STABILITY map to ["mood"]
      4. Unmapped targets return [] so the drawer falls back to defaults

PROMPT_VERSION stays at 4.23.0 per Marc's prompt-version rules:
the assistant-facing system prompt does not change; only the user-
side prefill does. Confirm in the W3e reconcile if a future change
makes a prefill load-bearing on the model's behaviour.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:37:46 +02:00
1 parent f223b4feae
commit ec4fb9837a
2 files changed
+262

No files matched your search

@@ -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(
<I18nProvider initialLocale="en">
<TargetsPage />
</I18nProvider>,
);
}
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"');
});
});
@@ -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([]);
});
});