mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
test(healthkit): cover device-event ingest + the awareness surface
Add mapping tests for the five event classes (verdict resolution, severity grading, the value-pinned-to-1 contract, unknown-codepoint fallback, and that a continuous measurement never gets a classification), the awareness-card gating + device-verdict framing + disclaimer presence, and the events route narrowing + empty-state contract. Extend the enum, chart-token and Coach-gate drift guards for the new event types.
This commit is contained in:
1 parent
135141a750
commit
95064b405a
5 files changed
+280
-11
No files matched your search
@@ -53,6 +53,11 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray<string> = [
|
||||
// `coach`: a user with assessments enabled but Coach disabled can warm.
|
||||
"src/app/api/insights/pregenerate/route.ts",
|
||||
"src/app/api/insights/pulse-status/route.ts",
|
||||
// v1.10.0 — device-flagged event awareness timeline (categorical
|
||||
// events, WX-B). Pure DB read of the device's own verdicts; gates on
|
||||
// the same `insightStatus` sub-flag as the assessment routes (no Coach
|
||||
// prose).
|
||||
"src/app/api/insights/rhythm-events/route.ts",
|
||||
"src/app/api/insights/weight-status/route.ts",
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.10.0 — categorical events (WX-B). `<RhythmEventsCard>` unit tests.
|
||||
*
|
||||
* The card is the device-flagged event awareness surface. The load-bearing
|
||||
* behaviour under test:
|
||||
* - data-availability gating: the card un-mounts entirely (renders
|
||||
* nothing) when the user has no events or while the payload is in
|
||||
* flight — never an empty / alarming card.
|
||||
* - the device-verdict framing ("Your device flagged …") and the
|
||||
* permanent regulatory disclaimer that states HealthLog does not make a
|
||||
* medical assessment.
|
||||
*
|
||||
* The component depends on `useAuth` (gate) + TanStack Query (`useQuery`)
|
||||
* for the payload, so both are mocked and the assertions run through SSR.
|
||||
*/
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: vi.fn(() => ({ isAuthenticated: true, user: null })),
|
||||
}));
|
||||
|
||||
const useQueryMock = vi.fn();
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (opts: unknown) => useQueryMock(opts),
|
||||
}));
|
||||
|
||||
const { RhythmEventsCard } = await import("../rhythm-events-card");
|
||||
|
||||
interface RhythmEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
classification: string | null;
|
||||
occurredAt: string;
|
||||
source: string;
|
||||
deviceType: string | null;
|
||||
}
|
||||
|
||||
function renderWith(
|
||||
data: { events: RhythmEvent[]; hasEvents: boolean } | undefined,
|
||||
) {
|
||||
useQueryMock.mockReturnValue({ data, isLoading: false });
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RhythmEventsCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const IRREGULAR_EVENT: RhythmEvent = {
|
||||
id: "evt_1",
|
||||
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
classification: "IRREGULAR",
|
||||
occurredAt: "2026-06-01T09:15:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "watch",
|
||||
};
|
||||
|
||||
const STEADINESS_EVENT: RhythmEvent = {
|
||||
id: "evt_2",
|
||||
type: "WALKING_STEADINESS_EVENT",
|
||||
classification: "VERY_LOW",
|
||||
occurredAt: "2026-05-20T17:00:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
deviceType: "phone",
|
||||
};
|
||||
|
||||
describe("<RhythmEventsCard>", () => {
|
||||
it("renders nothing before the payload resolves", () => {
|
||||
useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<RhythmEventsCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when the user has no events (data-availability gate)", () => {
|
||||
const html = renderWith({ events: [], hasEvents: false });
|
||||
expect(html).toBe("");
|
||||
});
|
||||
|
||||
it("renders the timeline + each event row when events exist", () => {
|
||||
const html = renderWith({
|
||||
events: [IRREGULAR_EVENT, STEADINESS_EVENT],
|
||||
hasEvents: true,
|
||||
});
|
||||
expect(html).toContain('data-slot="rhythm-events-card"');
|
||||
expect(html).toContain('data-slot="rhythm-events-timeline"');
|
||||
const rows = (html.match(/data-slot="rhythm-event-row"/g) ?? []).length;
|
||||
expect(rows).toBe(2);
|
||||
expect(html).toContain('data-event-type="IRREGULAR_RHYTHM_NOTIFICATION"');
|
||||
expect(html).toContain('data-event-type="WALKING_STEADINESS_EVENT"');
|
||||
});
|
||||
|
||||
it("frames the verdict as the DEVICE's decision, never HealthLog's", () => {
|
||||
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
|
||||
expect(html).toContain('data-slot="rhythm-event-verdict"');
|
||||
// Verbatim device-decision framing — load-bearing regulatory copy.
|
||||
expect(html).toContain("Your device flagged a possible irregular rhythm.");
|
||||
});
|
||||
|
||||
it("renders the permanent regulatory disclaimer", () => {
|
||||
const html = renderWith({ events: [IRREGULAR_EVENT], hasEvents: true });
|
||||
expect(html).toContain('data-slot="rhythm-events-disclaimer"');
|
||||
// The disclaimer must state HealthLog does not make a medical
|
||||
// assessment and does not diagnose.
|
||||
expect(html).toContain("not a medical assessment by HealthLog");
|
||||
expect(html).toContain("does not diagnose");
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,16 @@ const EXPECTED_TYPES = [
|
||||
"STAIR_ASCENT_SPEED",
|
||||
"STAIR_DESCENT_SPEED",
|
||||
"BREATHING_DISTURBANCES",
|
||||
// ── v1.10.0 — categorical events (WX-B) ──
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
"HIGH_HEART_RATE_EVENT",
|
||||
"LOW_HEART_RATE_EVENT",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
] as const;
|
||||
|
||||
describe("measurementTypeEnum coverage", () => {
|
||||
it("exposes the 45 canonical measurement types", () => {
|
||||
it("exposes the 50 canonical measurement types", () => {
|
||||
expect([...measurementTypeEnum.options].sort()).toEqual(
|
||||
[...EXPECTED_TYPES].sort(),
|
||||
);
|
||||
@@ -151,6 +157,17 @@ describe("measurementTypeEnum coverage", () => {
|
||||
"STAIR_ASCENT_SPEED",
|
||||
"STAIR_DESCENT_SPEED",
|
||||
"BREATHING_DISTURBANCES",
|
||||
// v1.10.0 — categorical events (WX-B). Device-flagged EVENT rows are
|
||||
// discrete on-device notifications, not vital-sign readings. They are
|
||||
// surfaced on the Insights awareness timeline with their own regulatory
|
||||
// disclaimer, never in the clinical vitals PDF (their value is always 1
|
||||
// and they carry the device's verdict, not a measured quantity). See
|
||||
// doctor-report-pdf-core.ts for the matching exclusion rationale.
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
"HIGH_HEART_RATE_EVENT",
|
||||
"LOW_HEART_RATE_EVENT",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
]);
|
||||
|
||||
it("doctor-report PDF vital types cover the canonical enum minus documented exclusions", () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MeasurementType } from "@/generated/prisma/client";
|
||||
import { EVENT_MEASUREMENT_TYPES } from "@/lib/validations/measurement";
|
||||
import {
|
||||
ALLOWED_CHART_TOKENS,
|
||||
parseChartTokens,
|
||||
@@ -214,12 +215,20 @@ describe("ALLOWED_CHART_TOKENS — drift guard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("covers every MeasurementType enum value", () => {
|
||||
it("covers every chartable MeasurementType enum value", () => {
|
||||
// If a future schema migration adds a new MeasurementType, this test
|
||||
// fails until the new `metric:<TYPE>` is also added to the allowlist.
|
||||
// (`NOTE` is excluded if it ever appears — currently the schema has none.)
|
||||
//
|
||||
// v1.10.0 — the categorical EVENT classes (irregular-rhythm /
|
||||
// high-low-HR / walking-steadiness / breathing-disturbance) are
|
||||
// deliberately NOT chartable: they are discrete device-flagged
|
||||
// occurrences with no continuous series, surfaced on the awareness
|
||||
// timeline rather than the inline chart renderer. They carry no
|
||||
// `metric:<TYPE>` token by design.
|
||||
const enumTokens = (Object.values(MeasurementType) as string[])
|
||||
.filter((value) => value !== "NOTE")
|
||||
.filter((value) => !EVENT_MEASUREMENT_TYPES.has(value))
|
||||
.map((value) => `metric:${value}`);
|
||||
|
||||
for (const token of enumTokens) {
|
||||
|
||||
@@ -66,6 +66,15 @@ describe("APPLE_HEALTH_TYPE_MAP", () => {
|
||||
// HRV / VO2 max / sleep analysis.
|
||||
"HKQuantityTypeIdentifierAppleSleepingWristTemperature",
|
||||
"HKQuantityTypeIdentifierAppleSleepingBreathingDisturbances",
|
||||
// v1.10.0 — the categorical EVENT classes carry health-screening
|
||||
// verdicts (rhythm / heart-rate / mobility / breathing) the user
|
||||
// explicitly enabled on-device; flagged privacy-sensitive so the
|
||||
// server audit trail can distinguish them.
|
||||
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
|
||||
"HKCategoryTypeIdentifierHighHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierLowHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
"HKCategoryTypeIdentifierSleepApneaEvent",
|
||||
].sort(),
|
||||
);
|
||||
// Audio exposure + time-in-daylight are environment / lifestyle
|
||||
@@ -138,11 +147,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
|
||||
// Cardiovascular / clinical
|
||||
"HKQuantityTypeIdentifierAtrialFibrillationBurden",
|
||||
"HKQuantityTypeIdentifierPeripheralPerfusionIndex",
|
||||
// Mobility — `AppleWalkingSteadiness` moved into the mapped
|
||||
// set in v1.4.30; `NumberOfTimesFallen` moved into the mapped set
|
||||
// in v1.10.0 (FALL_COUNT). The walking-steadiness EVENT stays
|
||||
// deferred (a category-type event, not a quantity).
|
||||
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
// Mobility — `AppleWalkingSteadiness` moved into the mapped set in
|
||||
// v1.4.30. In v1.10.0 both remaining identifiers moved in too:
|
||||
// `NumberOfTimesFallen` → FALL_COUNT and `AppleWalkingSteadinessEvent`
|
||||
// → WALKING_STEADINESS_EVENT, so neither stays deferred.
|
||||
// Respiratory / pulmonary
|
||||
"HKQuantityTypeIdentifierForcedExpiratoryVolume1",
|
||||
"HKQuantityTypeIdentifierForcedVitalCapacity",
|
||||
@@ -154,10 +162,10 @@ describe("HK_QUANTITY_TYPE_DEFERRED", () => {
|
||||
"HKQuantityTypeIdentifierElectrodermalActivity",
|
||||
"HKQuantityTypeIdentifierBloodAlcoholContent",
|
||||
"HKQuantityTypeIdentifierNikeFuel",
|
||||
// Heart-rhythm event flags
|
||||
"HKCategoryTypeIdentifierLowHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierHighHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
|
||||
// Heart-rhythm event flags — `LowHeartRateEvent`,
|
||||
// `HighHeartRateEvent` + `IrregularHeartRhythmEvent` moved into the
|
||||
// mapped set in v1.10.0 as categorical events. `LowCardioFitnessEvent`
|
||||
// stays deferred (no awareness surface yet).
|
||||
"HKCategoryTypeIdentifierLowCardioFitnessEvent",
|
||||
// Audio-exposure events — environmental + headphone moved into
|
||||
// the mapped set in v1.4.30 (AUDIO_EXPOSURE_EVENT). The general
|
||||
@@ -757,3 +765,118 @@ describe("v1.10.0 additive HealthKit signals (WX-A)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("v1.10.0 categorical events (WX-B)", () => {
|
||||
const ts = "2026-06-02T07:30:00.000Z";
|
||||
|
||||
it("maps every event identifier to its EVENT MeasurementType", () => {
|
||||
const expected: Array<[string, string]> = [
|
||||
[
|
||||
"HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
|
||||
"IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
],
|
||||
["HKCategoryTypeIdentifierHighHeartRateEvent", "HIGH_HEART_RATE_EVENT"],
|
||||
["HKCategoryTypeIdentifierLowHeartRateEvent", "LOW_HEART_RATE_EVENT"],
|
||||
[
|
||||
"HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
"WALKING_STEADINESS_EVENT",
|
||||
],
|
||||
[
|
||||
"HKCategoryTypeIdentifierSleepApneaEvent",
|
||||
"BREATHING_DISTURBANCE_EVENT",
|
||||
],
|
||||
];
|
||||
for (const [hkId, type] of expected) {
|
||||
const mapping = APPLE_HEALTH_TYPE_MAP[hkId];
|
||||
expect(mapping, `${hkId} should be mapped`).toBeDefined();
|
||||
expect(mapping.measurementType).toBe(type);
|
||||
// EVENT rows are always a single fired occurrence regardless of the
|
||||
// inbound number — the device fired it once.
|
||||
expect(mapping.convertToDbUnit(999)).toBe(1);
|
||||
expect(mapping.dbUnit).toBe("event");
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves the irregular-rhythm event to the device's IRREGULAR verdict", () => {
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKCategoryTypeIdentifierIrregularHeartRhythmEvent",
|
||||
value: 0,
|
||||
unit: "event",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
categoryValue: 0,
|
||||
});
|
||||
expect(out).toEqual({
|
||||
type: "IRREGULAR_RHYTHM_NOTIFICATION",
|
||||
value: 1,
|
||||
unit: "event",
|
||||
takenAt: new Date(ts),
|
||||
rhythmClassification: "IRREGULAR",
|
||||
});
|
||||
});
|
||||
|
||||
it("grades the walking-steadiness event severity from the codepoint", () => {
|
||||
const low = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
value: 0,
|
||||
unit: "event",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
categoryValue: 1, // initialLow
|
||||
});
|
||||
expect(low!.rhythmClassification).toBe("LOW");
|
||||
|
||||
const veryLow = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
value: 0,
|
||||
unit: "event",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
categoryValue: 3, // initialVeryLow
|
||||
});
|
||||
expect(veryLow!.rhythmClassification).toBe("VERY_LOW");
|
||||
});
|
||||
|
||||
it("falls back to FIRED for the neutral high/low-HR + breathing events", () => {
|
||||
for (const hkId of [
|
||||
"HKCategoryTypeIdentifierHighHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierLowHeartRateEvent",
|
||||
"HKCategoryTypeIdentifierSleepApneaEvent",
|
||||
]) {
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: hkId,
|
||||
value: 0,
|
||||
unit: "event",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
});
|
||||
expect(out!.value).toBe(1);
|
||||
expect(out!.rhythmClassification).toBe("FIRED");
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades an unknown steadiness codepoint to the fallback rather than dropping it", () => {
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKCategoryTypeIdentifierAppleWalkingSteadinessEvent",
|
||||
value: 0,
|
||||
unit: "event",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
categoryValue: 99, // Apple introduced a codepoint we don't enumerate
|
||||
});
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.rhythmClassification).toBe("LOW");
|
||||
});
|
||||
|
||||
it("never sets rhythmClassification on a continuous measurement", () => {
|
||||
const out = mapAppleHealthEntry({
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
startDate: ts,
|
||||
endDate: ts,
|
||||
categoryValue: 1,
|
||||
});
|
||||
expect(out!.rhythmClassification).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user