From 95064b405af7f3edcb7438f41c7dd659db434fa9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 2 Jun 2026 22:00:36 +0200 Subject: [PATCH] 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. --- .../coach-route-gate-inventory.test.ts | 5 + .../__tests__/rhythm-events-card.test.tsx | 115 ++++++++++++++ .../measurement-type-enum-coverage.test.ts | 19 ++- .../insights/__tests__/chart-tokens.test.ts | 11 +- .../__tests__/apple-health-mapping.test.ts | 141 ++++++++++++++++-- 5 files changed, 280 insertions(+), 11 deletions(-) create mode 100644 src/components/insights/__tests__/rhythm-events-card.test.tsx diff --git a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts index a8c5a8145..37ea6fd23 100644 --- a/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts +++ b/src/app/api/insights/__tests__/coach-route-gate-inventory.test.ts @@ -53,6 +53,11 @@ const NON_COACH_GATED_ROUTES: ReadonlyArray = [ // `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", ]; diff --git a/src/components/insights/__tests__/rhythm-events-card.test.tsx b/src/components/insights/__tests__/rhythm-events-card.test.tsx new file mode 100644 index 000000000..7de61bc59 --- /dev/null +++ b/src/components/insights/__tests__/rhythm-events-card.test.tsx @@ -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). `` 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( + + + , + ); +} + +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("", () => { + it("renders nothing before the payload resolves", () => { + useQueryMock.mockReturnValue({ data: undefined, isLoading: true }); + const html = renderToStaticMarkup( + + + , + ); + 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"); + }); +}); diff --git a/src/lib/__tests__/measurement-type-enum-coverage.test.ts b/src/lib/__tests__/measurement-type-enum-coverage.test.ts index 5693adf6d..1985c040c 100644 --- a/src/lib/__tests__/measurement-type-enum-coverage.test.ts +++ b/src/lib/__tests__/measurement-type-enum-coverage.test.ts @@ -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", () => { diff --git a/src/lib/insights/__tests__/chart-tokens.test.ts b/src/lib/insights/__tests__/chart-tokens.test.ts index aeb86855a..f84525a4b 100644 --- a/src/lib/insights/__tests__/chart-tokens.test.ts +++ b/src/lib/insights/__tests__/chart-tokens.test.ts @@ -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:` 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:` 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) { diff --git a/src/lib/measurements/__tests__/apple-health-mapping.test.ts b/src/lib/measurements/__tests__/apple-health-mapping.test.ts index 84fddec66..2603f23af 100644 --- a/src/lib/measurements/__tests__/apple-health-mapping.test.ts +++ b/src/lib/measurements/__tests__/apple-health-mapping.test.ts @@ -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(); + }); +});