mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Recharts AreaChart over a 21-day window, anchored at now. Wires the W19c-Backend pure PK module: drug id resolved from the medication brand via the catalog, doses derived from non-skipped intake events matched against the dose-change history. Y-axis is unit-less per research §2.3 — no tick labels, the 'Estimated level (relative)' caption sits above the frame. The chart gates on enabled + acknowledgedVersion === currentDisclaimerVersion (defence-in-depth alongside the server 400) and renders distinct placeholders for each failure mode. Mounted on /medications/[id]/history when treatmentClass === GLP1.
340 lines
11 KiB
TypeScript
340 lines
11 KiB
TypeScript
/**
|
|
* v1.4.25 W19c-Frontend — Drug-level chart tests.
|
|
*
|
|
* Pins the gating decision tree (research §2.3 + §12.4 + W19c-Backend
|
|
* phase report). The chart MUST hide until:
|
|
* - the medication's brand resolves to a Glp1DrugId, AND
|
|
* - `enabled === true` AND
|
|
* - `acknowledgedVersion === currentDisclaimerVersion`.
|
|
*
|
|
* Each gate failure renders a distinct placeholder so the SSR markup
|
|
* carries an unambiguous data-slot for assertion. Recharts in
|
|
* `renderToStaticMarkup` only paints the static frame (the
|
|
* `<ResponsiveContainer>` mounts but doesn't measure), so we assert on
|
|
* `data-slot="drug-level-chart-area"` presence + the gradient
|
|
* definition + the y-axis having no `<text>` tick labels.
|
|
*
|
|
* Pure helpers (`parseDoseMg`, `resolveDoseMg`) get their own unit
|
|
* coverage — they're tested directly so the data-shape transformation
|
|
* stays correct without a Recharts mount.
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
|
|
import { I18nProvider } from "@/lib/i18n/context";
|
|
|
|
// Capture queryKey routing so different tests can stub different
|
|
// query results. The component reads three queries:
|
|
// - ["research-mode"]
|
|
// - ["medications", id, "glp1-details"]
|
|
// - ["medications", id, "intake", "drug-level-chart"]
|
|
const queryResults: Record<string, unknown> = {};
|
|
|
|
function setQueryResult(keyJoined: string, data: unknown) {
|
|
queryResults[keyJoined] = data;
|
|
}
|
|
|
|
vi.mock("@tanstack/react-query", () => ({
|
|
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
|
const key = (queryKey as Array<string | number>).join("/");
|
|
return {
|
|
data: queryResults[key],
|
|
isLoading: false,
|
|
};
|
|
},
|
|
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
|
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
|
}));
|
|
|
|
// Pin the PK compute helper so we can assert "the chart asked for the
|
|
// right doses" without re-running the math.
|
|
const pkCalls: Array<{
|
|
drug: string;
|
|
doses: Array<{ takenAt: Date; doseMg: number }>;
|
|
asOf: Date;
|
|
}> = [];
|
|
|
|
vi.mock("@/lib/medications/glp1-pk", async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import("@/lib/medications/glp1-pk")>(
|
|
"@/lib/medications/glp1-pk",
|
|
);
|
|
return {
|
|
...actual,
|
|
computeOneCompartment: (
|
|
drug: string,
|
|
doses: Array<{ takenAt: Date; doseMg: number }>,
|
|
asOf: Date,
|
|
) => {
|
|
pkCalls.push({ drug, doses, asOf });
|
|
// Return a small fixed series so the chart renders a non-empty
|
|
// SVG path. Two samples is enough to verify the Area renders.
|
|
return [
|
|
{ tHours: -24 * 21, concentration: 0 },
|
|
{ tHours: -24 * 14, concentration: 0.6 },
|
|
{ tHours: -24 * 7, concentration: 1.0 },
|
|
{ tHours: 0, concentration: 0.7 },
|
|
];
|
|
},
|
|
};
|
|
});
|
|
|
|
import {
|
|
DrugLevelChart,
|
|
parseDoseMg,
|
|
resolveDoseMg,
|
|
} from "../DrugLevelChart";
|
|
|
|
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
|
|
return renderToStaticMarkup(
|
|
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
|
|
);
|
|
}
|
|
|
|
const mounjaro = {
|
|
id: "med-1",
|
|
name: "Mounjaro",
|
|
dose: "7.5 mg",
|
|
};
|
|
|
|
const ramipril = {
|
|
id: "med-2",
|
|
name: "Ramipril",
|
|
dose: "5 mg",
|
|
};
|
|
|
|
beforeEach(() => {
|
|
for (const k of Object.keys(queryResults)) delete queryResults[k];
|
|
pkCalls.length = 0;
|
|
});
|
|
|
|
describe("<DrugLevelChart> — gating decision tree", () => {
|
|
it("renders the unknown-drug placeholder for a non-GLP-1 brand", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: true,
|
|
acknowledgedAt: "2026-05-14T00:00:00Z",
|
|
acknowledgedVersion: "2026-05-14.1",
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
|
|
const html = render(<DrugLevelChart medication={ramipril} />);
|
|
|
|
expect(html).toContain('data-slot="drug-level-chart-unknown-drug"');
|
|
expect(html).toContain("Ramipril");
|
|
// Chart body must not paint when the drug is unknown.
|
|
expect(html).not.toContain('data-slot="drug-level-chart-area"');
|
|
// The PK helper is never asked to compute for an unknown drug.
|
|
expect(pkCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("renders the gated placeholder when Research Mode is OFF", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: false,
|
|
acknowledgedAt: null,
|
|
acknowledgedVersion: null,
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
|
|
const html = render(<DrugLevelChart medication={mounjaro} />);
|
|
|
|
expect(html).toContain('data-slot="drug-level-chart-gated"');
|
|
expect(html).toContain('data-stale="false"');
|
|
expect(html).toContain("Research Mode is off");
|
|
expect(html).toContain("Open Research Mode");
|
|
expect(html).not.toContain('data-slot="drug-level-chart-area"');
|
|
expect(pkCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("renders the stale-disclaimer placeholder when versions differ", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: true,
|
|
acknowledgedAt: "2026-04-01T00:00:00Z",
|
|
acknowledgedVersion: "2026-04-01.1",
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
|
|
const html = render(<DrugLevelChart medication={mounjaro} />);
|
|
|
|
expect(html).toContain('data-slot="drug-level-chart-gated"');
|
|
expect(html).toContain('data-stale="true"');
|
|
expect(html).toContain("re-acknowledgment");
|
|
expect(html).not.toContain('data-slot="drug-level-chart-area"');
|
|
expect(pkCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("renders the empty state when gate is open but no intake events exist", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: true,
|
|
acknowledgedAt: "2026-05-14T00:00:00Z",
|
|
acknowledgedVersion: "2026-05-14.1",
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
|
|
setQueryResult("medications/med-1/intake/drug-level-chart", {
|
|
events: [],
|
|
});
|
|
|
|
const html = render(<DrugLevelChart medication={mounjaro} />);
|
|
|
|
expect(html).toContain('data-slot="drug-level-chart-empty"');
|
|
expect(html).toContain("No intake events yet");
|
|
expect(html).not.toContain('data-slot="drug-level-chart-area"');
|
|
expect(pkCalls).toHaveLength(0);
|
|
});
|
|
|
|
it("renders the chart when gate is open AND intake events exist", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: true,
|
|
acknowledgedAt: "2026-05-14T00:00:00Z",
|
|
acknowledgedVersion: "2026-05-14.1",
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
setQueryResult("medications/med-1/glp1-details", {
|
|
doseChanges: [
|
|
{
|
|
id: "dc-1",
|
|
effectiveFrom: "2026-04-01T00:00:00Z",
|
|
doseValue: 7.5,
|
|
doseUnit: "mg",
|
|
},
|
|
],
|
|
});
|
|
setQueryResult("medications/med-1/intake/drug-level-chart", {
|
|
events: [
|
|
{
|
|
id: "ev-1",
|
|
takenAt: "2026-05-13T08:00:00Z",
|
|
skipped: false,
|
|
scheduledFor: "2026-05-13T08:00:00Z",
|
|
},
|
|
{
|
|
id: "ev-2",
|
|
takenAt: "2026-05-06T08:00:00Z",
|
|
skipped: false,
|
|
scheduledFor: "2026-05-06T08:00:00Z",
|
|
},
|
|
// A skipped event must NOT contribute to the curve.
|
|
{
|
|
id: "ev-3",
|
|
takenAt: null,
|
|
skipped: true,
|
|
scheduledFor: "2026-04-29T08:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
const fixedNow = new Date("2026-05-14T12:00:00Z");
|
|
const html = render(
|
|
<DrugLevelChart medication={mounjaro} asOf={fixedNow} />,
|
|
);
|
|
|
|
expect(html).toContain('data-slot="drug-level-chart-area"');
|
|
expect(html).toContain("Estimated drug level");
|
|
// The estimate disclaimer must accompany the chart body.
|
|
expect(html).toContain('data-slot="drug-level-chart-disclaimer"');
|
|
|
|
// The PK helper was asked for the right drug, with only the two
|
|
// non-skipped doses, and with the supplied `asOf`.
|
|
expect(pkCalls).toHaveLength(1);
|
|
const [call] = pkCalls;
|
|
expect(call.drug).toBe("tirzepatide");
|
|
expect(call.doses).toHaveLength(2);
|
|
expect(call.doses.every((d) => d.doseMg === 7.5)).toBe(true);
|
|
expect(call.asOf).toEqual(fixedNow);
|
|
});
|
|
|
|
it("hides the y-axis tick labels (research §2.3, unit-less)", () => {
|
|
setQueryResult("research-mode", {
|
|
enabled: true,
|
|
acknowledgedAt: "2026-05-14T00:00:00Z",
|
|
acknowledgedVersion: "2026-05-14.1",
|
|
currentDisclaimerVersion: "2026-05-14.1",
|
|
});
|
|
setQueryResult("medications/med-1/glp1-details", { doseChanges: [] });
|
|
setQueryResult("medications/med-1/intake/drug-level-chart", {
|
|
events: [
|
|
{
|
|
id: "ev-1",
|
|
takenAt: "2026-05-13T08:00:00Z",
|
|
skipped: false,
|
|
scheduledFor: "2026-05-13T08:00:00Z",
|
|
},
|
|
],
|
|
});
|
|
|
|
const html = render(
|
|
<DrugLevelChart
|
|
medication={mounjaro}
|
|
asOf={new Date("2026-05-14T12:00:00Z")}
|
|
/>,
|
|
);
|
|
|
|
// Recharts paints `<text class="recharts-cartesian-axis-tick-value">`
|
|
// for each numeric tick. The drug-level chart suppresses those for
|
|
// the Y axis by passing `tick={false}` — none should appear inside
|
|
// the y-axis container. Same content scan that the dashboard chart
|
|
// tests already use.
|
|
const yAxisTickValues = html.match(
|
|
/class="recharts-yAxis.*?recharts-cartesian-axis-tick-value/g,
|
|
);
|
|
expect(yAxisTickValues).toBeNull();
|
|
|
|
// The unit-less axis label still renders (it carries the
|
|
// educational framing).
|
|
expect(html).toContain("Estimated level (relative)");
|
|
});
|
|
});
|
|
|
|
describe("parseDoseMg / resolveDoseMg helpers", () => {
|
|
it("parses standard '7.5 mg' / '12,5 mg' / '0.25 mg' shapes", () => {
|
|
expect(parseDoseMg("7.5 mg")).toBe(7.5);
|
|
expect(parseDoseMg("12,5 mg")).toBe(12.5);
|
|
expect(parseDoseMg("0.25 mg")).toBe(0.25);
|
|
expect(parseDoseMg("15 mg")).toBe(15);
|
|
});
|
|
|
|
it("returns NaN for non-numeric strings", () => {
|
|
expect(Number.isNaN(parseDoseMg(""))).toBe(true);
|
|
expect(Number.isNaN(parseDoseMg("as needed"))).toBe(true);
|
|
});
|
|
|
|
it("resolves the dose to the latest effectiveFrom <= takenAt", () => {
|
|
const history = [
|
|
{
|
|
id: "a",
|
|
effectiveFrom: "2026-01-01T00:00:00Z",
|
|
doseValue: 2.5,
|
|
doseUnit: "mg",
|
|
},
|
|
{
|
|
id: "b",
|
|
effectiveFrom: "2026-03-01T00:00:00Z",
|
|
doseValue: 5,
|
|
doseUnit: "mg",
|
|
},
|
|
{
|
|
id: "c",
|
|
effectiveFrom: "2026-05-01T00:00:00Z",
|
|
doseValue: 7.5,
|
|
doseUnit: "mg",
|
|
},
|
|
];
|
|
|
|
// Before any history → fallback.
|
|
expect(resolveDoseMg("2025-12-15T00:00:00Z", history, 1)).toBe(1);
|
|
// Inside the 2.5-mg window.
|
|
expect(resolveDoseMg("2026-02-15T00:00:00Z", history, 1)).toBe(2.5);
|
|
// On the 5-mg boundary day.
|
|
expect(resolveDoseMg("2026-03-01T00:00:00Z", history, 1)).toBe(5);
|
|
// Inside the 5-mg window.
|
|
expect(resolveDoseMg("2026-04-15T00:00:00Z", history, 1)).toBe(5);
|
|
// Inside the latest window.
|
|
expect(resolveDoseMg("2026-05-10T00:00:00Z", history, 1)).toBe(7.5);
|
|
});
|
|
|
|
it("returns the fallback when takenAt is unparseable", () => {
|
|
expect(resolveDoseMg("not-an-iso", [], 42)).toBe(42);
|
|
});
|
|
});
|