mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
Add a 'What the Coach remembers' settings section: lists the user's durable facts grouped by category with a learned-when stamp, forgets a single fact or clears all (behind a confirm), and notes the rolling conversation summary. Consumes the v1.11.1 CoachFact routes via the query-key factory; gated on the Coach feature; localised in all six locales. Closes the API-only gap from v1.11.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.0 KiB
TypeScript
154 lines
5.0 KiB
TypeScript
/**
|
|
* v1.11.2 — Settings → AI "What the Coach remembers" panel contract.
|
|
*
|
|
* Project convention is SSR-only tests (no `@testing-library/react`;
|
|
* the Vitest environment is `node`). We pre-seed the `coachFacts`
|
|
* query in a real `QueryClient` so `useQuery` resolves synchronously
|
|
* during the `renderToStaticMarkup` pass, then assert the rendered
|
|
* shape: facts grouped + labelled by category, the empty state, and
|
|
* the per-fact + bulk "forget" controls that drive the DELETE
|
|
* mutations.
|
|
*
|
|
* The mutation wiring itself (DELETE `/facts/{id}` and DELETE `/facts`
|
|
* → `queryKeys.coachFacts()` invalidation + toast) is pinned by the
|
|
* route-shape contract in the component plus the typecheck gate; this
|
|
* suite guards the render contract the way the sibling
|
|
* `disable-coach-card.test.tsx` does for the toggle.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
import { queryKeys } from "@/lib/query-keys";
|
|
import { CoachMemorySection } from "../coach-memory-section";
|
|
|
|
interface SeedFact {
|
|
id: string;
|
|
category: "preference" | "condition" | "goal" | "constraint" | "context";
|
|
text: string;
|
|
confidence: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
const NOW = "2026-06-04T12:00:00.000Z";
|
|
|
|
function buildFacts(): SeedFact[] {
|
|
return [
|
|
{
|
|
id: "fact-goal-1",
|
|
category: "goal",
|
|
text: "Wants to reach 80 kg by autumn",
|
|
confidence: 0.9,
|
|
createdAt: NOW,
|
|
},
|
|
{
|
|
id: "fact-pref-1",
|
|
category: "preference",
|
|
text: "Prefers morning workouts",
|
|
confidence: 0.8,
|
|
createdAt: NOW,
|
|
},
|
|
{
|
|
id: "fact-cond-1",
|
|
category: "condition",
|
|
text: "Takes ramipril for blood pressure",
|
|
confidence: 0.95,
|
|
createdAt: NOW,
|
|
},
|
|
];
|
|
}
|
|
|
|
beforeEach(() => {
|
|
// The component never fetches when the query is pre-seeded fresh, but
|
|
// stub fetch so any background refetch can't escape to the network.
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(JSON.stringify({ data: { facts: [] } }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
}),
|
|
),
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
function render(facts: SeedFact[] | "skip"): string {
|
|
const client = new QueryClient({
|
|
defaultOptions: {
|
|
queries: { retry: 0, staleTime: Infinity, gcTime: Infinity },
|
|
},
|
|
});
|
|
if (facts !== "skip") {
|
|
client.setQueryData(queryKeys.coachFacts(), facts);
|
|
}
|
|
return renderToStaticMarkup(
|
|
<QueryClientProvider client={client}>
|
|
<I18nProvider initialLocale="en">
|
|
<CoachMemorySection isAuthenticated />
|
|
</I18nProvider>
|
|
</QueryClientProvider>,
|
|
);
|
|
}
|
|
|
|
describe("Settings — CoachMemorySection", () => {
|
|
it("renders the facts grouped by category", () => {
|
|
const html = render(buildFacts());
|
|
// Each populated category surfaces its group heading...
|
|
expect(html).toContain(
|
|
'data-testid="settings-coach-memory-group-preference"',
|
|
);
|
|
expect(html).toContain('data-testid="settings-coach-memory-group-goal"');
|
|
expect(html).toContain(
|
|
'data-testid="settings-coach-memory-group-condition"',
|
|
);
|
|
// ...and the localized category labels render.
|
|
expect(html).toContain("Preferences");
|
|
expect(html).toContain("Goals");
|
|
expect(html).toContain("Conditions");
|
|
// The fact text appears verbatim.
|
|
expect(html).toContain("Prefers morning workouts");
|
|
expect(html).toContain("Wants to reach 80 kg by autumn");
|
|
// No group rows for categories that have no facts.
|
|
expect(html).not.toContain(
|
|
'data-testid="settings-coach-memory-group-constraint"',
|
|
);
|
|
});
|
|
|
|
it("renders a per-fact forget control and the bulk forget-all action", () => {
|
|
const html = render(buildFacts());
|
|
expect(html).toContain('data-testid="settings-coach-memory-forget"');
|
|
expect(html).toContain('data-testid="settings-coach-memory-forget-all"');
|
|
// One forget button per fact (3 seeded).
|
|
const forgetButtons = html.match(
|
|
/data-testid="settings-coach-memory-forget"/g,
|
|
);
|
|
expect(forgetButtons).toHaveLength(3);
|
|
});
|
|
|
|
it("renders the empty state with the explainer when there are no facts", () => {
|
|
const html = render([]);
|
|
expect(html).toContain('data-testid="settings-coach-memory-empty"');
|
|
// The bulk action hides when there is nothing to forget.
|
|
expect(html).not.toContain(
|
|
'data-testid="settings-coach-memory-forget-all"',
|
|
);
|
|
// No fact rows.
|
|
expect(html).not.toContain('data-testid="settings-coach-memory-fact"');
|
|
});
|
|
|
|
it("always shows the rolling-summary note", () => {
|
|
const withFacts = render(buildFacts());
|
|
const empty = render([]);
|
|
// The summary explainer is present in both states (it has no
|
|
// per-row control — deleting a conversation removes its summary).
|
|
expect(withFacts).toContain("rolling summary");
|
|
expect(empty).toContain("rolling summary");
|
|
});
|
|
});
|