mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
A snooze lives on the medication row, and the reminder tick applied it to every slot of that medication. A Telegram "skip" of the morning dose snoozes to the end of the day, so the evening dose of the same medication was neither reminded nor marked missed by the reminder path. A snooze now holds only slots whose reminder window had opened when it was set, dated by the row's updatedAt that the snooze write stamps (the same reading the skip and pin suppression already uses). A later edit to the row can only widen the hold back to the old behaviour. The doctor report kept adherence in a map keyed by medication name, so two medications with the same name (an ended course and a new one, two strengths) collapsed into one row and the second overwrote the first. The map is keyed by medication id now and carries the name as the label; the report's medication list carries the id. The PDF, the FHIR adherence observations, the MCP visit summary, the clinician view and the GLP-1 section read it that way.
553 lines
18 KiB
TypeScript
553 lines
18 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import {
|
|
adherenceRatePercent,
|
|
buildLedgerCompliance,
|
|
collapseMeasurementsToCanonical,
|
|
normaliseDays,
|
|
normaliseDateRange,
|
|
resolveMaxMedicationAdministrations,
|
|
sanitisePracticeName,
|
|
type CanonicalCollapseRow,
|
|
type DoctorReportComplianceMedication,
|
|
type DoctorReportComplianceIntake,
|
|
} from "../doctor-report-data";
|
|
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
|
import { userDayKey } from "@/lib/tz/resolver";
|
|
import {
|
|
buildComplianceMedicationContext,
|
|
tallyComplianceFromLedger,
|
|
} from "@/lib/analytics/compliance";
|
|
import {
|
|
reconstructSleepNights,
|
|
summarizeSleepNights,
|
|
type SleepStageRow,
|
|
} from "@/lib/analytics/sleep-night";
|
|
|
|
describe("resolveMaxMedicationAdministrations", () => {
|
|
it("defaults to 5000 when unset or non-numeric", () => {
|
|
expect(resolveMaxMedicationAdministrations(undefined)).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("abc")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("1000.5")).toBe(5000);
|
|
});
|
|
|
|
it("accepts a valid integer within [1, 50000]", () => {
|
|
expect(resolveMaxMedicationAdministrations("1")).toBe(1);
|
|
expect(resolveMaxMedicationAdministrations("1000")).toBe(1000);
|
|
expect(resolveMaxMedicationAdministrations("50000")).toBe(50000);
|
|
});
|
|
|
|
it("falls back to 5000 for out-of-range values", () => {
|
|
expect(resolveMaxMedicationAdministrations("0")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("-1")).toBe(5000);
|
|
expect(resolveMaxMedicationAdministrations("50001")).toBe(5000);
|
|
});
|
|
});
|
|
|
|
describe("adherenceRatePercent", () => {
|
|
it("rounds to an integer percent capped at 100, matching the app card", () => {
|
|
// 87.3 → 87 (the divergence the export previously printed as 87.3).
|
|
expect(adherenceRatePercent(157, 180)).toBe(87);
|
|
// Exact half rounds up like the app's Math.round.
|
|
expect(adherenceRatePercent(1, 2)).toBe(50);
|
|
expect(adherenceRatePercent(5, 6)).toBe(83);
|
|
// Full adherence is a clean 100.
|
|
expect(adherenceRatePercent(10, 10)).toBe(100);
|
|
});
|
|
|
|
it("never exceeds 100 even when taken outruns the denominator", () => {
|
|
expect(adherenceRatePercent(12, 10)).toBe(100);
|
|
});
|
|
|
|
it("returns null when there is no expected dose", () => {
|
|
expect(adherenceRatePercent(0, 0)).toBeNull();
|
|
expect(adherenceRatePercent(3, 0)).toBeNull();
|
|
expect(adherenceRatePercent(1, -1)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("normaliseDays", () => {
|
|
it("returns the value when it's a valid integer in [1, 365]", () => {
|
|
expect(normaliseDays(1)).toBe(1);
|
|
expect(normaliseDays(90)).toBe(90);
|
|
expect(normaliseDays(365)).toBe(365);
|
|
});
|
|
|
|
it("falls back to 90 for invalid input", () => {
|
|
expect(normaliseDays(undefined)).toBe(90);
|
|
expect(normaliseDays(0)).toBe(90);
|
|
expect(normaliseDays(-1)).toBe(90);
|
|
expect(normaliseDays(366)).toBe(90);
|
|
expect(normaliseDays(7.5)).toBe(90);
|
|
expect(normaliseDays("90")).toBe(90);
|
|
expect(normaliseDays(null)).toBe(90);
|
|
});
|
|
|
|
it("respects a custom fallback", () => {
|
|
expect(normaliseDays(undefined, 30)).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe("normaliseDateRange", () => {
|
|
const NOW = new Date("2026-05-09T12:00:00.000Z");
|
|
|
|
it("uses the explicit range when both dates are valid and span <= 730 days", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-02-01T00:00:00.000Z",
|
|
endDate: "2026-05-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.start.toISOString()).toBe("2026-02-01T00:00:00.000Z");
|
|
expect(range.end.toISOString()).toBe("2026-05-01T00:00:00.000Z");
|
|
// 89-day span rounded up to the inclusive day-count.
|
|
expect(range.days).toBe(89);
|
|
});
|
|
|
|
it("falls back to last-90-days when range is missing", () => {
|
|
const range = normaliseDateRange({}, NOW);
|
|
expect(range.days).toBe(90);
|
|
expect(range.end.toISOString()).toBe(NOW.toISOString());
|
|
const expectedStart = new Date(NOW);
|
|
expectedStart.setDate(expectedStart.getDate() - 90);
|
|
expect(range.start.toISOString()).toBe(expectedStart.toISOString());
|
|
});
|
|
|
|
it("falls back when endDate is before startDate", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-05-01T00:00:00.000Z",
|
|
endDate: "2026-02-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("falls back when range exceeds 730 days", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2023-01-01T00:00:00.000Z",
|
|
endDate: "2026-01-01T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("accepts the maximum 730-day window", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2024-05-09T00:00:00.000Z",
|
|
endDate: "2026-05-09T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBeGreaterThan(700);
|
|
expect(range.days).toBeLessThanOrEqual(730);
|
|
});
|
|
|
|
it("falls back when startDate is unparseable", () => {
|
|
const range = normaliseDateRange(
|
|
{ startDate: "not-a-date", endDate: "2026-05-01T00:00:00.000Z" },
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
|
|
it("uses the legacy days fallback when only days is provided", () => {
|
|
const range = normaliseDateRange({ days: 30 }, NOW);
|
|
expect(range.days).toBe(30);
|
|
expect(range.end.toISOString()).toBe(NOW.toISOString());
|
|
});
|
|
|
|
it("equal start+end produces a 1-day window (not zero)", () => {
|
|
const range = normaliseDateRange(
|
|
{
|
|
startDate: "2026-05-09T00:00:00.000Z",
|
|
endDate: "2026-05-09T00:00:00.000Z",
|
|
},
|
|
NOW,
|
|
);
|
|
expect(range.days).toBe(1);
|
|
});
|
|
|
|
it("ignores non-object input", () => {
|
|
const range = normaliseDateRange("not-an-object", NOW);
|
|
expect(range.days).toBe(90);
|
|
});
|
|
});
|
|
|
|
describe("sanitisePracticeName", () => {
|
|
it("returns null for non-string input", () => {
|
|
expect(sanitisePracticeName(undefined)).toBe(null);
|
|
expect(sanitisePracticeName(null)).toBe(null);
|
|
expect(sanitisePracticeName(42)).toBe(null);
|
|
expect(sanitisePracticeName({})).toBe(null);
|
|
});
|
|
|
|
it("returns null for whitespace-only or empty strings", () => {
|
|
expect(sanitisePracticeName("")).toBe(null);
|
|
expect(sanitisePracticeName(" ")).toBe(null);
|
|
expect(sanitisePracticeName("\t\n ")).toBe(null);
|
|
});
|
|
|
|
it("trims and collapses whitespace", () => {
|
|
expect(sanitisePracticeName(" Dr. Smith ")).toBe("Dr. Smith");
|
|
expect(sanitisePracticeName("Praxis\tDr.\nMüller")).toBe(
|
|
"Praxis Dr. Müller",
|
|
);
|
|
});
|
|
|
|
it("strips ASCII control characters", () => {
|
|
expect(sanitisePracticeName("Dr.Smith")).toBe("Dr. Smith");
|
|
expect(sanitisePracticeName(" |