Files
HealthLog/src/lib/insights/__tests__/features.test.ts
T
Marc-André Bombeck d82862b384 feat(insights): weave ECG into the briefing narrative and daily rail
Fold the ECG silo into the insights narrative without crossing the
non-diagnostic line — the app reports the recording device's own verdict
and the existence/count of recordings, never an interpretation of the trace.

- Feature snapshot: a bounded, device-verdict-only ECG descriptor (recording
  count, device-verdict distribution, latest recorded-days-ago + average HR)
  flows into the nightly briefing prompt. The waveform never enters the
  payload; the grounding gate admits the pre-computed counts so a
  device-attributed sentence stays grounded. A prompt rule (EN + DE)
  constrains any mention to the device's attribution and forbids reading
  morphology, measuring intervals, or emitting a verdict of the app's own.
- Pulse cross-link: a device-attributed pointer from the resting-HR / pulse
  page into the ECG viewer, un-mounting when no recordings exist.
- Daily rail: an ecg_new_recording item surfaces once when a recording landed
  within the last day — calm, info-level, deep-linking the viewer. Gated on
  the insights module; the DTO carries no waveform.
- i18n across all six locales; non-diagnostic disclaimer copy per language.
2026-07-17 08:19:17 +02:00

498 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn(), groupBy: vi.fn() },
measurementRollup: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
// v1.22 — cross-signal integration blocks.
labResult: { findMany: vi.fn() },
measurementReminder: { findMany: vi.fn() },
workout: { findMany: vi.fn() },
ecgRecording: { findMany: vi.fn() },
$queryRawUnsafe: vi.fn(),
},
}));
// Stub the mood-rollup warm-up so test runs don't fire the real
// recompute aggregate. The warm-up is fire-and-forget on the route.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/rollups/mood-rollups")
>("@/lib/rollups/mood-rollups");
return {
...actual,
ensureUserMoodRollupsFresh: vi
.fn()
.mockResolvedValue({ recomputed: false }),
};
});
vi.mock("@/lib/medication-category", () => ({
getMedicationCategories: vi.fn(async () => ({})),
}));
import { prisma } from "@/lib/db";
import {
extractFeatures,
FeaturesPayloadTooLargeError,
FEATURES_MAX_BYTES,
type RawFeatures,
} from "../features";
const prismaMock = prisma as unknown as {
user: { findUnique: ReturnType<typeof vi.fn> };
measurement: {
findMany: ReturnType<typeof vi.fn>;
groupBy: ReturnType<typeof vi.fn>;
};
measurementRollup: { findMany: ReturnType<typeof vi.fn> };
moodEntry: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
moodEntryRollup: {
findMany: ReturnType<typeof vi.fn>;
findFirst: ReturnType<typeof vi.fn>;
};
medication: { findMany: ReturnType<typeof vi.fn> };
medicationIntakeEvent: { findMany: ReturnType<typeof vi.fn> };
labResult: { findMany: ReturnType<typeof vi.fn> };
measurementReminder: { findMany: ReturnType<typeof vi.fn> };
workout: { findMany: ReturnType<typeof vi.fn> };
ecgRecording: { findMany: ReturnType<typeof vi.fn> };
};
const dayMs = 24 * 60 * 60 * 1000;
function rollupRow(daysAgo: number, mean: number, count: number) {
return {
bucketStart: new Date(Date.now() - daysAgo * dayMs),
count,
mean,
minValue: mean,
maxValue: mean,
sd: 0,
slope: 0,
r2: 0,
computedAt: new Date(),
};
}
beforeEach(() => {
vi.resetAllMocks();
prismaMock.user.findUnique.mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1980-01-01"),
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([]);
prismaMock.measurement.groupBy.mockResolvedValue([]);
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findMany.mockResolvedValue([]);
prismaMock.moodEntry.findFirst.mockResolvedValue(null);
prismaMock.moodEntryRollup.findMany.mockResolvedValue([]);
prismaMock.moodEntryRollup.findFirst.mockResolvedValue(null);
prismaMock.medication.findMany.mockResolvedValue([]);
prismaMock.medicationIntakeEvent.findMany.mockResolvedValue([]);
prismaMock.labResult.findMany.mockResolvedValue([]);
prismaMock.measurementReminder.findMany.mockResolvedValue([]);
prismaMock.workout.findMany.mockResolvedValue([]);
prismaMock.ecgRecording.findMany.mockResolvedValue([]);
});
describe("extractFeatures — v1.4.36 W3 bucketed payload", () => {
it("does not attach bucketedMeasurements when includeRaw=false", async () => {
const features = await extractFeatures("user-1", false);
const asRecord = features as unknown as Record<string, unknown>;
expect(asRecord.bucketedMeasurements).toBeUndefined();
expect(asRecord.rawMeasurements).toBeUndefined();
});
it("attaches DAY / WEEK / MONTH buckets from measurement_rollups when includeRaw=true", async () => {
// Two WEIGHT DAY buckets in the 0-90d window and one MONTH bucket
// in the 365-1825d window. The reader is called once per
// (type, granularity) combination — return data only for the two
// we care about, empty for the rest.
prismaMock.measurementRollup.findMany.mockImplementation(
async (args: { where: { type: string; granularity: string } }) => {
if (args.where.type === "WEIGHT" && args.where.granularity === "DAY") {
return [rollupRow(10, 82.5, 2), rollupRow(20, 82.7, 1)];
}
if (
args.where.type === "WEIGHT" &&
args.where.granularity === "MONTH"
) {
return [rollupRow(400, 85.1, 28)];
}
return [];
},
);
const features = (await extractFeatures("user-1", true)) as RawFeatures;
expect(features.bucketedMeasurements).toBeDefined();
const weightDay = features.bucketedMeasurements.find(
(s) => s.type === "WEIGHT" && s.granularity === "DAY",
);
expect(weightDay).toBeDefined();
expect(weightDay?.buckets).toHaveLength(2);
expect(weightDay?.buckets[0]).toMatchObject({
mean: 82.5,
count: 2,
});
expect(weightDay?.buckets[0].bucketStart).toMatch(
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/,
);
const weightMonth = features.bucketedMeasurements.find(
(s) => s.type === "WEIGHT" && s.granularity === "MONTH",
);
expect(weightMonth?.buckets).toHaveLength(1);
expect(weightMonth?.buckets[0].count).toBe(28);
});
it("drops empty (type, granularity) series so the payload never carries labelled-but-empty arrays", async () => {
prismaMock.measurementRollup.findMany.mockResolvedValue([]);
const features = (await extractFeatures("user-1", true)) as RawFeatures;
expect(features.bucketedMeasurements).toEqual([]);
});
it("keeps anthropometrics (heightCm / ageYears / gender) on context", async () => {
const features = await extractFeatures("user-1", false);
expect(features.context.heightCm).toBe(180);
expect(features.context.gender).toBe("MALE");
expect(features.context.ageYears).toBeGreaterThan(40);
});
// Constructs a ~5.6 MB rollup payload and runs the whole serialiser
// pipeline; the slower GitHub-Actions runners cross the default 5 s
// budget. Raise this single test only — the rest of the suite stays
// on the default timeout. Vitest 4 takes the timeout as a numeric
// second argument (the old `it(name, fn, options)` signature was
// removed in v4).
it(
"throws FeaturesPayloadTooLargeError when the serialised payload exceeds the 5 MB cap",
{ timeout: 30_000 },
async () => {
// Fabricate an absurdly long bucket list so the JSON dump crosses
// the ceiling. ~200 KB per series × 28 series ≈ ~5.6 MB.
const giant = new Array(200_000).fill(null).map((_, i) => ({
bucketStart: new Date(Date.now() - i * dayMs),
count: i,
mean: i,
minValue: i,
maxValue: i,
sd: 0,
slope: 0,
r2: 0,
computedAt: new Date(),
}));
prismaMock.measurementRollup.findMany.mockResolvedValue(giant);
await expect(extractFeatures("user-1", true)).rejects.toThrow(
FeaturesPayloadTooLargeError,
);
},
);
it("exposes the size cap as a public constant", () => {
expect(FEATURES_MAX_BYTES).toBe(5 * 1024 * 1024);
});
});
// ─── v1.22 — cross-signal integration blocks ──────────────────────────────
const DAY_MS = 24 * 60 * 60 * 1000;
function measurementRow(type: string, value: number, daysAgo: number) {
return {
type,
value,
measuredAt: new Date(Date.now() - daysAgo * DAY_MS),
sleepStage: null,
source: "MANUAL",
deviceType: null,
};
}
function labRow(opts: {
analyte: string;
value: number | null;
referenceLow: number | null;
referenceHigh: number | null;
daysAgo: number;
hidden?: boolean;
valueText?: string | null;
}) {
return {
analyte: opts.analyte,
panel: null,
unit: "mg/dL",
value: opts.value,
valueText: opts.valueText ?? null,
referenceLow: opts.referenceLow,
referenceHigh: opts.referenceHigh,
takenAt: new Date(Date.now() - opts.daysAgo * DAY_MS),
biomarkerId: opts.hidden === undefined ? null : "bm-" + opts.analyte,
biomarker:
opts.hidden === undefined
? null
: {
id: "bm-" + opts.analyte,
name: opts.analyte,
unit: "mg/dL",
lowerBound: opts.referenceLow,
upperBound: opts.referenceHigh,
panel: null,
hidden: opts.hidden,
},
};
}
describe("extractFeatures — v1.22 glucose aggregate block", () => {
it("emits a glucose block from BLOOD_GLUCOSE readings", async () => {
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("BLOOD_GLUCOSE", 95, 1),
measurementRow("BLOOD_GLUCOSE", 110, 10),
measurementRow("BLOOD_GLUCOSE", 105, 40),
]);
const f = await extractFeatures("user-1", false);
expect(f.glucose).toBeDefined();
expect(f.glucose?.latest).not.toBeNull();
expect(f.glucose?.coverage.count).toBe(3);
});
it("omits the glucose block when there are no readings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.glucose).toBeUndefined();
});
});
describe("extractFeatures — v1.25.1 clinical-depth aggregate blocks", () => {
it("emits grip, waist (with WHtR) and pain blocks from their readings", async () => {
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("GRIP_STRENGTH", 42, 1),
measurementRow("GRIP_STRENGTH", 40, 20),
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
measurementRow("WAIST_CIRCUMFERENCE", 92, 25),
measurementRow("PAIN_NRS", 4, 1),
measurementRow("PAIN_NRS", 2, 10),
]);
const f = await extractFeatures("user-1", false);
expect(f.gripStrength).toBeDefined();
expect(f.gripStrength?.latest).toBe(42);
expect(f.waist).toBeDefined();
expect(f.waist?.latest).toBe(90);
// heightCm is 180 in the default user mock → WHtR = 90 / 180 = 0.5.
expect(f.waist?.whtrLatest).toBe(0.5);
expect(f.pain).toBeDefined();
expect(f.pain?.latest).toBe(4);
});
it("omits the clinical-depth blocks when there are no readings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.gripStrength).toBeUndefined();
expect(f.waist).toBeUndefined();
expect(f.pain).toBeUndefined();
});
it("leaves WHtR null when height is unknown", async () => {
prismaMock.user.findUnique.mockResolvedValue({
heightCm: null,
dateOfBirth: new Date("1980-01-01"),
gender: "MALE",
});
prismaMock.measurement.findMany.mockResolvedValue([
measurementRow("WAIST_CIRCUMFERENCE", 90, 2),
]);
const f = await extractFeatures("user-1", false);
expect(f.waist?.whtrLatest).toBeNull();
});
});
describe("extractFeatures — v1.22 labs briefing block", () => {
it("surfaces an abnormal (out-of-range) biomarker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "LDL",
value: 200,
referenceLow: 0,
referenceHigh: 130,
daysAgo: 5,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs).toBeDefined();
expect(f.labs?.flagged[0]?.analyte).toBe("LDL");
expect(f.labs?.flagged[0]?.rangeStatus).toBe("above");
});
it("excludes a HIDDEN biomarker even when it is abnormal", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "Ferritin",
value: 500,
referenceLow: 30,
referenceHigh: 300,
daysAgo: 3,
hidden: true,
}),
]);
const f = await extractFeatures("user-1", false);
// The only marker is hidden + abnormal → block omitted entirely.
expect(f.labs).toBeUndefined();
});
it("does not surface an in-range, non-trending marker", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
labRow({
analyte: "TSH",
value: 2,
referenceLow: 0.4,
referenceHigh: 4,
daysAgo: 4,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs).toBeUndefined();
});
it("surfaces an in-range but trending marker (rising vs prior reading)", async () => {
prismaMock.labResult.findMany.mockResolvedValue([
// Newest first (route orders desc); both in range, but rising.
labRow({
analyte: "Glucose",
value: 99,
referenceLow: 70,
referenceHigh: 100,
daysAgo: 2,
}),
labRow({
analyte: "Glucose",
value: 80,
referenceLow: 70,
referenceHigh: 100,
daysAgo: 60,
}),
]);
const f = await extractFeatures("user-1", false);
expect(f.labs?.flagged[0]?.trend).toBe("rising");
});
});
describe("extractFeatures — v1.22 preventive-care block", () => {
it("buckets reminders into overdue and due", async () => {
prismaMock.measurementReminder.findMany.mockResolvedValue([
{ label: "Blutbild", nextDueAt: new Date(Date.now() - 5 * DAY_MS) },
{ label: "Augenarzt", nextDueAt: new Date(Date.now() + 7 * DAY_MS) },
]);
const f = await extractFeatures("user-1", false);
expect(f.preventiveCare?.overdue[0]?.label).toBe("Blutbild");
expect(f.preventiveCare?.overdue[0]?.daysOverdue).toBeGreaterThanOrEqual(4);
expect(f.preventiveCare?.due[0]?.label).toBe("Augenarzt");
});
it("omits the block when nothing is due or overdue", async () => {
const f = await extractFeatures("user-1", false);
expect(f.preventiveCare).toBeUndefined();
});
});
describe("extractFeatures — v1.22 workouts block", () => {
it("aggregates counts + distance over the windows", async () => {
prismaMock.workout.findMany.mockResolvedValue([
{
sportType: "running",
startedAt: new Date(Date.now() - 1 * DAY_MS),
durationSec: 1800,
totalDistanceM: 5000,
},
{
sportType: "cycling",
startedAt: new Date(Date.now() - 20 * DAY_MS),
durationSec: 3600,
totalDistanceM: 20000,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.workouts?.last7.count).toBe(1);
expect(f.workouts?.last30.count).toBe(2);
expect(f.workouts?.last7.totalDistanceKm).toBe(5);
expect(f.workouts?.latest?.sportType).toBe("running");
});
});
describe("extractFeatures — v1.22 integration blocks are briefing-scoped", () => {
it("skips the extra reads on the tight Coach-snapshot window (sinceDays: 90)", async () => {
await extractFeatures("user-1", false, { sinceDays: 90 });
expect(prismaMock.labResult.findMany).not.toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).not.toHaveBeenCalled();
expect(prismaMock.workout.findMany).not.toHaveBeenCalled();
// S10 — the ECG descriptor rides the same briefing-scoped gate.
expect(prismaMock.ecgRecording.findMany).not.toHaveBeenCalled();
});
it("runs the extra reads on the wide briefing window (sinceDays: 400)", async () => {
await extractFeatures("user-1", false, { sinceDays: 400 });
expect(prismaMock.labResult.findMany).toHaveBeenCalled();
expect(prismaMock.measurementReminder.findMany).toHaveBeenCalled();
expect(prismaMock.workout.findMany).toHaveBeenCalled();
expect(prismaMock.ecgRecording.findMany).toHaveBeenCalled();
});
});
describe("extractFeatures — S10 ECG device-verdict descriptor", () => {
const DAY = 86_400_000;
it("emits a device-verdict descriptor and NEVER a waveform", async () => {
const now = Date.now();
prismaMock.ecgRecording.findMany.mockResolvedValue([
{
recordedAt: new Date(now - 2 * DAY),
rhythmClassification: "IRREGULAR",
averageHeartRate: 72,
},
{
recordedAt: new Date(now - 10 * DAY),
rhythmClassification: "NOT_DETECTED",
averageHeartRate: 61,
},
]);
const f = await extractFeatures("user-1", false);
expect(f.ecg?.recordingCount).toBe(2);
expect(f.ecg?.deviceVerdicts).toEqual({
irregular: 1,
notDetected: 1,
inconclusive: 0,
});
// The LATEST recording's device verdict + HR (newest-first ordering).
expect(f.ecg?.latestDeviceVerdict).toBe("IRREGULAR");
expect(f.ecg?.latestAverageHeartRate).toBe(72);
// The waveform never reaches the payload — only descriptors.
expect(JSON.stringify(f.ecg)).not.toMatch(
/waveform|sample|signal|voltage|microvolt/i,
);
});
it("omits the ECG block entirely when the user has no recordings", async () => {
const f = await extractFeatures("user-1", false);
expect(f.ecg).toBeUndefined();
});
it("only ever reads descriptor columns, never the encrypted waveform", async () => {
await extractFeatures("user-1", false);
const call = prismaMock.ecgRecording.findMany.mock.calls[0]?.[0];
expect(call?.select?.waveformEncrypted).toBeUndefined();
expect(call?.select?.recordedAt).toBe(true);
expect(call?.select?.rhythmClassification).toBe(true);
});
});