mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
test(01-10): add red assessment and records contracts
- pin honest assessment fallback, exhaustion, foreground, and state envelopes - require consistent save and empty-state add action grammar
This commit is contained in:
1 parent
d8e52b831d
commit
18d0facdfe
7 files changed
+362
-7
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -82,20 +82,30 @@ describe("<InsightStatusCard>", () => {
|
||||
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(
|
||||
<InsightStatusCard
|
||||
{...baseProps}
|
||||
hasProvider={false}
|
||||
text="ignored when hasProvider is false"
|
||||
/>,
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
// 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(
|
||||
<InsightStatusCard
|
||||
{...baseProps}
|
||||
hasProvider={false}
|
||||
text="The assessment could not be completed safely. Your recorded measurements remain available."
|
||||
/>,
|
||||
);
|
||||
|
||||
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("<InsightStatusCard>", () => {
|
||||
// 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(
|
||||
<InsightStatusCard {...baseProps} text={null} loading />,
|
||||
);
|
||||
const preparing = render(
|
||||
<InsightStatusCard {...baseProps} text={null} preparing />,
|
||||
);
|
||||
const noProvider = render(
|
||||
<InsightStatusCard {...baseProps} hasProvider={false} text={null} />,
|
||||
);
|
||||
const empty = render(<InsightStatusCard {...baseProps} text={null} />);
|
||||
const populated = render(
|
||||
<InsightStatusCard
|
||||
{...baseProps}
|
||||
text="Pulse stayed inside the expected range."
|
||||
/>,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<button aria-label={props.title}>Delete</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AllergyManager } from "../allergy-manager";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<AllergyManager />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.rows = [];
|
||||
});
|
||||
|
||||
describe("<AllergyManager> 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");
|
||||
});
|
||||
});
|
||||
@@ -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<Record<string, unknown>>,
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<button aria-label={props.title}>Delete</button>
|
||||
),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { FamilyHistoryManager } from "../family-history-manager";
|
||||
|
||||
function render() {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<FamilyHistoryManager />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.rows = [];
|
||||
});
|
||||
|
||||
describe("<FamilyHistoryManager> 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user