mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
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([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user