mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
The coincident-deviation read grouped the latest day with a UTC date slice while the sibling readiness read already used the tz-aware day key. For a user east or west of UTC, a late-evening or early-morning reading landed on the wrong calendar day, so a fired flag could compare a vital from the wrong day against its band and narrate '2 or more vitals out of band today' on the wrong day. Thread the user's timezone into the latest-day read and mint the day key with userDayKey, matching readiness. The Coach derived-snapshot and the correlations reader pass the real account timezone through. Adds a regression covering a UTC+2 user whose late-night reading rolls to the next local day.
229 lines
8.1 KiB
TypeScript
229 lines
8.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: { measurement: { findMany: vi.fn() } },
|
|
}));
|
|
vi.mock("@/lib/rollups/measurement-coverage", () => ({
|
|
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
|
|
}));
|
|
vi.mock("@/lib/rollups/measurement-read-wmy", () => ({
|
|
readBestGranularityRollups: vi.fn().mockResolvedValue(null),
|
|
}));
|
|
// v1.18.1 P4 — Rest Mode. Default inactive; the reframe test flips it active.
|
|
const restModeMock = vi.hoisted(() => ({
|
|
resolveRestMode: vi.fn(async () => ({
|
|
active: false as boolean,
|
|
since: null as string | null,
|
|
episodeCount: 0,
|
|
episodes: [] as unknown[],
|
|
})),
|
|
}));
|
|
vi.mock("@/lib/illness/rest-mode", () => restModeMock);
|
|
|
|
import { prisma } from "@/lib/db";
|
|
import {
|
|
computeCoincidentDeviation,
|
|
classifyDeviation,
|
|
COINCIDENT_FIRE_THRESHOLD,
|
|
} from "../coincident-deviation";
|
|
|
|
const PROFILE = { ageYears: 40, sex: "MALE" as const };
|
|
const NOW = new Date("2026-06-02T08:00:00Z");
|
|
const findMany = prisma.measurement.findMany as ReturnType<typeof vi.fn>;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
findMany.mockResolvedValue([]);
|
|
restModeMock.resolveRestMode.mockResolvedValue({
|
|
active: false,
|
|
since: null,
|
|
episodeCount: 0,
|
|
episodes: [],
|
|
});
|
|
});
|
|
|
|
/** RHR + HRV each flat, then a final day that pushes both outside the band. */
|
|
function firingRows(args: { where: { type: string } }) {
|
|
const baseline = (base: number, outlier: number) => [
|
|
...Array.from({ length: 9 }, (_, i) => ({
|
|
value: base,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
})),
|
|
{ value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") },
|
|
];
|
|
if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
|
|
if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
|
|
return [];
|
|
}
|
|
|
|
/** RHR + HRV each flat (banded but inside) — the no-fire-but-banded case. */
|
|
function quietBandedRows(args: { where: { type: string } }) {
|
|
const flat = (base: number) =>
|
|
Array.from({ length: 10 }, (_, i) => ({
|
|
value: base,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
}));
|
|
if (args.where.type === "RESTING_HEART_RATE") return flat(58);
|
|
if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60);
|
|
return [];
|
|
}
|
|
|
|
describe("classifyDeviation", () => {
|
|
it("flags above / below / in-band", () => {
|
|
expect(classifyDeviation("PULSE", 90, 60, 80, 70).direction).toBe("above");
|
|
expect(classifyDeviation("PULSE", 50, 60, 80, 70).direction).toBe("below");
|
|
expect(classifyDeviation("PULSE", 70, 60, 80, 70).direction).toBe("in");
|
|
expect(classifyDeviation("PULSE", 70, 60, 80, 70).outside).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("computeCoincidentDeviation", () => {
|
|
it("returns insufficient with fewer than two banded vitals", async () => {
|
|
// Only one vital has a usable band.
|
|
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
|
if (args.where.type === "RESTING_HEART_RATE") {
|
|
return Array.from({ length: 10 }, (_, i) => ({
|
|
value: 58,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
}));
|
|
}
|
|
return [];
|
|
});
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW,
|
|
});
|
|
expect(result.status).toBe("insufficient");
|
|
if (result.status === "insufficient") {
|
|
expect(result.reason).toBe("too_few_banded_vitals");
|
|
}
|
|
});
|
|
|
|
it("fires when ≥ 2 vitals sit outside their band on the latest day", async () => {
|
|
// RHR + HRV each have a flat band, then a final outlier day that
|
|
// pushes both outside the band.
|
|
const baseline = (
|
|
base: number,
|
|
outlier: number,
|
|
): Array<{ value: number; measuredAt: Date }> => [
|
|
...Array.from({ length: 9 }, (_, i) => ({
|
|
value: base,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
})),
|
|
{ value: outlier, measuredAt: new Date("2026-06-02T07:00:00Z") },
|
|
];
|
|
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
|
if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
|
|
if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
|
|
return [];
|
|
});
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW,
|
|
});
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.value.contributing.length).toBeGreaterThanOrEqual(
|
|
COINCIDENT_FIRE_THRESHOLD,
|
|
);
|
|
expect(result.value.fired).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("does not fire when vitals stay inside their bands", async () => {
|
|
const flat = (base: number): Array<{ value: number; measuredAt: Date }> =>
|
|
Array.from({ length: 10 }, (_, i) => ({
|
|
value: base,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
}));
|
|
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
|
if (args.where.type === "RESTING_HEART_RATE") return flat(58);
|
|
if (args.where.type === "HEART_RATE_VARIABILITY") return flat(60);
|
|
return [];
|
|
});
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW,
|
|
});
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.value.fired).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("reframes a fired flag as illness-explained during Rest Mode (vitals unchanged)", async () => {
|
|
restModeMock.resolveRestMode.mockResolvedValue({
|
|
active: true,
|
|
since: "2026-05-30T00:00:00.000Z",
|
|
episodeCount: 1,
|
|
episodes: [],
|
|
});
|
|
findMany.mockImplementation(firingRows);
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW,
|
|
});
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.value.fired).toBe(true);
|
|
// Reframed — but the measured deviations are untouched.
|
|
expect(result.value.illnessExplained).toBe(true);
|
|
expect(result.value.contributing.length).toBeGreaterThanOrEqual(
|
|
COINCIDENT_FIRE_THRESHOLD,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("keys 'today' in the user's timezone, not UTC (D2-8)", async () => {
|
|
// A non-UTC user (UTC+2) logs both vitals at 23:30 UTC on Jun 2 — which is
|
|
// 01:30 LOCAL on Jun 3. Under the old UTC slice the day key would read
|
|
// "2026-06-02"; tz-aware it must read "2026-06-03", the user's real "today".
|
|
const TZ = "Europe/Berlin"; // UTC+2 in summer
|
|
const NOW_LATE = new Date("2026-06-02T23:45:00Z"); // 01:45 local Jun 3
|
|
const baseline = (
|
|
base: number,
|
|
outlier: number,
|
|
): Array<{ value: number; measuredAt: Date }> => [
|
|
...Array.from({ length: 9 }, (_, i) => ({
|
|
value: base,
|
|
measuredAt: new Date(
|
|
`2026-05-${String(15 + i).padStart(2, "0")}T07:00:00Z`,
|
|
),
|
|
})),
|
|
// 23:30 UTC Jun 2 == 01:30 local Jun 3.
|
|
{ value: outlier, measuredAt: new Date("2026-06-02T23:30:00Z") },
|
|
];
|
|
findMany.mockImplementation(async (args: { where: { type: string } }) => {
|
|
if (args.where.type === "RESTING_HEART_RATE") return baseline(58, 95);
|
|
if (args.where.type === "HEART_RATE_VARIABILITY") return baseline(60, 10);
|
|
return [];
|
|
});
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW_LATE,
|
|
tz: TZ,
|
|
});
|
|
expect(result.status).toBe("ok");
|
|
if (result.status === "ok") {
|
|
expect(result.value.day).toBe("2026-06-03");
|
|
expect(result.value.fired).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("does not resolve Rest Mode (no reframe) when the flag did not fire", async () => {
|
|
// Banded but inside-band: the reframe must not even query Rest Mode.
|
|
findMany.mockImplementation(quietBandedRows);
|
|
const result = await computeCoincidentDeviation("u1", PROFILE, {
|
|
now: NOW,
|
|
});
|
|
expect(result.status === "ok" && result.value.fired).toBe(false);
|
|
expect(result.status === "ok" && result.value.illnessExplained).toBe(false);
|
|
expect(restModeMock.resolveRestMode).not.toHaveBeenCalled();
|
|
});
|
|
});
|