From b035d6277203208960d4bad2fb452338de93eb84 Mon Sep 17 00:00:00 2001 From: ak710 Date: Sun, 2 Aug 2026 14:32:03 -0400 Subject: [PATCH] Export the four ring metrics that stopped short of Apple Health MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Respiratory rate, VO2max, blood glucose and blood pressure were all decoded, stored and displayed in-app, but the export path mapped every one of them to nil. Three were a genuine follow-up; blood pressure needed a different shape entirely. Respiratory rate, VO2max and glucose join the quantity path with the units HealthKit expects (count/min, mL/(kg*min), mg/dL) and the same plausibility bounds RingEventBridge already applies on the way into the store. Blood pressure gets its own pass, because Health only recognises a reading when systolic and diastolic are saved together in an HKCorrelation — saved separately they are stored but never surface in the Health app, which is indistinguishable from a silent failure. The two halves are written from one packet at one instant, so that shared timestamp is the pairing key; a half without its partner is skipped rather than guessed at, and a pair with systolic at or below diastolic is rejected as a misframed packet. Its watermark reuses the bloodPressureSystolic slot, which the quantity path never touches, so backfill and reset keep working unchanged. Stress and fatigue stay unmapped, and the comment now says why properly: this isn't a follow-up, HealthKit has no type for a device-derived wellness score. HKStateOfMind is a self-reported mood log and would misrepresent both. The four new toggles only appear for rings that can produce the metric — or that already have — since respiratory rate and VO2max come only from the YCBT records and glucose and BP only from jring and some YCBT units. A jring owner never sees a VO2max switch that could not write anything. Co-Authored-By: Claude Opus 5 --- PulseLoop/Health/HealthKitTypeMappings.swift | 53 +++++-- PulseLoop/Health/HealthSyncService.swift | 96 ++++++++++++- PulseLoop/Services/Repositories.swift | 10 ++ .../Settings/AppleHealthPrefsStore.swift | 12 ++ .../Settings/AppleHealthSettingsView.swift | 33 ++++- PulseLoopTests/HealthSyncNewTypesTests.swift | 132 ++++++++++++++++++ PulseLoopTests/HealthSyncTests.swift | 8 +- 7 files changed, 327 insertions(+), 17 deletions(-) create mode 100644 PulseLoopTests/HealthSyncNewTypesTests.swift diff --git a/PulseLoop/Health/HealthKitTypeMappings.swift b/PulseLoop/Health/HealthKitTypeMappings.swift index f318f37..d389946 100644 --- a/PulseLoop/Health/HealthKitTypeMappings.swift +++ b/PulseLoop/Health/HealthKitTypeMappings.swift @@ -20,11 +20,14 @@ enum HealthKitTypeMappings { let isPlausible: (Double) -> Bool } - /// Ported from PR #16. Stress / fatigue / blood-pressure / blood-sugar map to `nil`: - /// - stress & fatigue have no native HealthKit equivalent. - /// - blood pressure needs `HKCorrelation` pairing (an unpaired systolic/diastolic sample never - /// surfaces as a reading in Health) plus new share-authorization types — a documented follow-up. - /// - blood sugar likewise needs its own share type. Follow-up. + /// Ported from PR #16. Two kinds still map to `nil`, for two different reasons: + /// + /// - **Stress and fatigue have no HealthKit type at all.** Not a follow-up — there is nothing to + /// map them onto. `HKStateOfMind` (iOS 17) is a self-reported mood log, not a device-derived + /// score, and writing a ring's 0–100 wellness number into it would misrepresent both. + /// - **Blood pressure is a correlation, not a quantity.** An unpaired systolic or diastolic + /// sample never surfaces as a reading in Health, so it needs `HKCorrelation` pairing and gets + /// its own export pass in `HealthSyncService` rather than a `QuantityMapping`. static func quantityMapping(for kind: MeasurementKind) -> QuantityMapping? { switch kind { case .heartRate: @@ -45,17 +48,43 @@ enum HealthKitTypeMappings { guard let type = HKQuantityType.quantityType(forIdentifier: .bodyTemperature) else { return nil } return QuantityMapping(type: type, unit: .degreeCelsius(), convert: { $0 }, isPlausible: { $0 > 25 && $0 < 45 }) + case .respiratoryRate: + guard let type = HKQuantityType.quantityType(forIdentifier: .respiratoryRate) else { return nil } + return QuantityMapping(type: type, unit: HKUnit.count().unitDivided(by: .minute()), + convert: { $0 }, isPlausible: { $0 >= 4 && $0 <= 60 }) + case .vo2max: + guard let type = HKQuantityType.quantityType(forIdentifier: .vo2Max) else { return nil } + // mL/(kg·min) — HealthKit spells the same unit as a compound. + let unit = HKUnit.literUnit(with: .milli) + .unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())) + return QuantityMapping(type: type, unit: unit, + convert: { $0 }, isPlausible: { $0 >= 10 && $0 <= 90 }) + case .bloodSugar: + guard let type = HKQuantityType.quantityType(forIdentifier: .bloodGlucose) else { return nil } + // Stored canonically in mg/dL; HealthKit's mass/volume unit spells that as mg/dL too. + let unit = HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) + return QuantityMapping(type: type, unit: unit, + convert: { $0 }, isPlausible: { $0 >= 40 && $0 <= 600 }) case .stress, .fatigue: - return nil // No native HealthKit equivalent. - case .bloodPressureSystolic, .bloodPressureDiastolic, .bloodSugar: - return nil // BP needs HKCorrelation pairing + new share types; blood sugar needs its own. Follow-up. - case .respiratoryRate, .vo2max: - // HealthKit has both (`respiratoryRate`, `vo2Max`), but exporting them needs new share - // types plus their own per-type toggles to keep the sync opt-in per metric. Follow-up. - return nil + return nil // No HealthKit type exists for either — see the note above. + case .bloodPressureSystolic, .bloodPressureDiastolic: + return nil // Exported as an HKCorrelation instead; see `bloodPressureSyncID`. } } + /// A paired blood-pressure reading, keyed by the instant both halves share so a re-export + /// upserts rather than duplicating. + static func bloodPressureSyncID(timestamp: Date) -> String { + "pl-bp-\(Int(timestamp.timeIntervalSince1970 * 1000))" + } + + /// Plausibility guards for the two halves of a blood-pressure reading, mirroring + /// `RingEventBridge`'s persistence gates so a value that reached the store can still be rejected + /// here if it is nonsense as a *pair* (systolic at or below diastolic). + static func isPlausibleBloodPressure(systolic: Double, diastolic: Double) -> Bool { + (60...250).contains(systolic) && (30...160).contains(diastolic) && systolic > diastolic + } + // MARK: - Workouts /// Maps a PulseLoop activity type onto the closest `HKWorkoutActivityType`. diff --git a/PulseLoop/Health/HealthSyncService.swift b/PulseLoop/Health/HealthSyncService.swift index 6ca00ab..ea72b3d 100644 --- a/PulseLoop/Health/HealthSyncService.swift +++ b/PulseLoop/Health/HealthSyncService.swift @@ -60,7 +60,12 @@ final class HealthSyncService { private var quantityWriteTypes: [HKQuantityType] { var identifiers: [HKQuantityTypeIdentifier] = [ .heartRate, .oxygenSaturation, .heartRateVariabilitySDNN, .bodyTemperature, - .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling + .stepCount, .activeEnergyBurned, .distanceWalkingRunning, .distanceCycling, + // Ring-dependent: only some families produce these, but the share set is fixed at + // authorization time and can't be re-prompted per device, so all four are requested up + // front. A ring that never reports one simply never writes it. + .respiratoryRate, .vo2Max, .bloodGlucose, + .bloodPressureSystolic, .bloodPressureDiastolic ] // Dietary types join the share set only once the nutrition feature is enabled, so users // who never opted in never see dietary rows on the Health authorization sheet. Enabling @@ -116,6 +121,8 @@ final class HealthSyncService { do { try await exportVitals(context: context, state: &state, counts: &counts, now: now, device: device) } catch { log.error("Vitals export failed: \(error.localizedDescription)") } + do { try await exportBloodPressure(context: context, state: &state, counts: &counts, now: now, device: device) } + catch { log.error("Blood-pressure export failed: \(error.localizedDescription)") } do { try await exportActivity(context: context, state: &state, counts: &counts, now: now, device: device) } catch { log.error("Activity export failed: \(error.localizedDescription)") } do { try await exportSleep(context: context, state: &state, counts: &counts, now: now, device: device) } @@ -172,6 +179,10 @@ final class HealthSyncService { if prefs.syncSpO2 { kinds.append(.spo2) } if prefs.syncHRV { kinds.append(.hrv) } if prefs.syncTemperature { kinds.append(.temperature) } + if prefs.syncRespiratoryRate { kinds.append(.respiratoryRate) } + if prefs.syncVO2Max { kinds.append(.vo2max) } + if prefs.syncBloodSugar { kinds.append(.bloodSugar) } + // Blood pressure is deliberately absent: it exports as a correlation, not a quantity. return kinds } @@ -235,6 +246,87 @@ final class HealthSyncService { ) } + // MARK: - Blood-pressure pass + + /// Blood pressure is the one vital that can't ride the quantity path: Health only recognises a + /// reading when systolic and diastolic are saved together inside an `HKCorrelation`. Saved + /// separately they are stored but never surface in the Health app, which looks exactly like a + /// silent failure. + /// + /// Rows for the two halves are written from one packet at one instant (`bloodPressureEvents`), so + /// the shared timestamp is the pairing key. A half without its partner is skipped rather than + /// guessed at. + /// + /// The watermark reuses the `bloodPressureSystolic` slot in `measurementWatermarks`: that kind is + /// never exported as a quantity, so the slot is free, and it inherits the reset/backfill handling + /// `resetWatermarks` already applies to every `MeasurementKind`. + private func exportBloodPressure(context: ModelContext, state: inout AppleHealthSyncState, + counts: inout SyncCounts, now: Date, device: HKDevice?) async throws { + guard prefsStore.prefs.syncBloodPressure, + let systolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureSystolic), + let diastolicType = HKQuantityType.quantityType(forIdentifier: .bloodPressureDiastolic), + let correlationType = HKCorrelationType.correlationType(forIdentifier: .bloodPressure), + canShare(systolicType), canShare(diastolicType) else { return } + + let watermarkKey = MeasurementKind.bloodPressureSystolic.rawValue + let systolicRaw = MeasurementKind.bloodPressureSystolic.rawValue + let diastolicRaw = MeasurementKind.bloodPressureDiastolic.rawValue + let mockRaw = MeasurementSource.mock.rawValue + let watermark = state.measurementWatermarks[watermarkKey] ?? .distantPast + + let systolicDescriptor = FetchDescriptor( + predicate: #Predicate { $0.kindRaw == systolicRaw && $0.sourceRaw != mockRaw && $0.createdAt > watermark }, + sortBy: [SortDescriptor(\.createdAt, order: .forward)] + ) + let systolicRows = (try? context.fetch(systolicDescriptor)) ?? [] + guard !systolicRows.isEmpty else { return } + + // Index the diastolic halves across the same instant span. Bounded by the batch's own range + // rather than the watermark, so a partner row that was persisted in a different pass — and + // therefore carries a different `createdAt` — is still found. + guard let spanStart = systolicRows.map(\.timestamp).min(), + let spanEnd = systolicRows.map(\.timestamp).max() else { return } + let diastolicDescriptor = FetchDescriptor( + predicate: #Predicate { + $0.kindRaw == diastolicRaw && $0.sourceRaw != mockRaw + && $0.timestamp >= spanStart && $0.timestamp <= spanEnd + } + ) + let diastolicByInstant = Dictionary( + ((try? context.fetch(diastolicDescriptor)) ?? []).map { ($0.timestamp, $0.value) }, + uniquingKeysWith: { first, _ in first } + ) + + for chunk in systolicRows.chunked(into: 1000) { + let correlations: [HKCorrelation] = chunk.compactMap { row in + guard row.timestamp <= now, let diastolic = diastolicByInstant[row.timestamp], + HealthKitTypeMappings.isPlausibleBloodPressure(systolic: row.value, diastolic: diastolic) + else { return nil } + + let unit = HKUnit.millimeterOfMercury() + let metadata = HealthKitTypeMappings.metadata( + syncID: HealthKitTypeMappings.bloodPressureSyncID(timestamp: row.timestamp), version: 1 + ) + let objects: Set = [ + HKQuantitySample(type: systolicType, quantity: HKQuantity(unit: unit, doubleValue: row.value), + start: row.timestamp, end: row.timestamp, device: device, metadata: nil), + HKQuantitySample(type: diastolicType, quantity: HKQuantity(unit: unit, doubleValue: diastolic), + start: row.timestamp, end: row.timestamp, device: device, metadata: nil), + ] + return HKCorrelation(type: correlationType, start: row.timestamp, end: row.timestamp, + objects: objects, device: device, metadata: metadata) + } + if !correlations.isEmpty { + try await save(correlations) + counts.bloodPressure += correlations.count + } + if let maxCreated = chunk.map(\.createdAt).max() { + state.measurementWatermarks[watermarkKey] = maxCreated + prefsStore.syncState = state + } + } + } + // MARK: - Daily activity pass private func exportActivity(context: ModelContext, state: inout AppleHealthSyncState, @@ -497,6 +589,7 @@ final class HealthSyncService { struct SyncCounts { var vitals = 0 + var bloodPressure = 0 var sleepSegments = 0 var dailyTotals = 0 var workouts = 0 @@ -505,6 +598,7 @@ final class HealthSyncService { var summary: String { var parts: [String] = [] if vitals > 0 { parts.append("\(vitals) vitals") } + if bloodPressure > 0 { parts.append("\(bloodPressure) BP reading\(bloodPressure == 1 ? "" : "s")") } if sleepSegments > 0 { parts.append("\(sleepSegments) sleep segment\(sleepSegments == 1 ? "" : "s")") } if dailyTotals > 0 { parts.append("\(dailyTotals) daily total\(dailyTotals == 1 ? "" : "s")") } if workouts > 0 { parts.append("\(workouts) workout\(workouts == 1 ? "" : "s")") } diff --git a/PulseLoop/Services/Repositories.swift b/PulseLoop/Services/Repositories.swift index 07d7b1b..70e09e5 100644 --- a/PulseLoop/Services/Repositories.swift +++ b/PulseLoop/Services/Repositories.swift @@ -104,6 +104,16 @@ enum MetricsRepository { return (try? context.fetch(descriptor)) ?? [] } + /// Whether the store holds any reading of a kind. `fetchLimit: 1` — an existence check, not a + /// count, so it stays cheap enough to call from a settings `body`. + @MainActor + static func hasAnyMeasurement(kind: MeasurementKind, context: ModelContext) -> Bool { + let raw = kind.rawValue + var descriptor = FetchDescriptor(predicate: #Predicate { $0.kindRaw == raw }) + descriptor.fetchLimit = 1 + return ((try? context.fetch(descriptor)) ?? []).isEmpty == false + } + /// Oldest measurement timestamp across all kinds (for the calibration "Day X of N" counter). /// `fetchLimit: 1` ascending — one row, not the whole table. @MainActor diff --git a/PulseLoop/Settings/AppleHealthPrefsStore.swift b/PulseLoop/Settings/AppleHealthPrefsStore.swift index 6c59c11..9d9caa9 100644 --- a/PulseLoop/Settings/AppleHealthPrefsStore.swift +++ b/PulseLoop/Settings/AppleHealthPrefsStore.swift @@ -28,6 +28,14 @@ struct AppleHealthPrefs: Codable, Equatable { var syncTemperature = true var syncSleep = true var syncActivity = true + /// The four ring metrics that reach Health but only exist on some hardware. Default **on** like + /// the rest — the settings screen hides the row entirely on a ring that can't produce the metric, + /// so an enabled-but-unreachable toggle never writes anything. + var syncRespiratoryRate = true + var syncVO2Max = true + var syncBloodSugar = true + /// Exported as an `HKCorrelation` pairing systolic + diastolic, not as two loose quantities. + var syncBloodPressure = true /// Whether finished workout sessions export as `HKWorkout`s (calories, distance, HR stats, GPS route). var exportWorkouts = true /// Whether logged meals export as dietary samples (energy + macros). Only effective when the @@ -52,6 +60,10 @@ struct AppleHealthPrefs: Codable, Equatable { syncTemperature = try c.decodeIfPresent(Bool.self, forKey: .syncTemperature) ?? d.syncTemperature syncSleep = try c.decodeIfPresent(Bool.self, forKey: .syncSleep) ?? d.syncSleep syncActivity = try c.decodeIfPresent(Bool.self, forKey: .syncActivity) ?? d.syncActivity + syncRespiratoryRate = try c.decodeIfPresent(Bool.self, forKey: .syncRespiratoryRate) ?? d.syncRespiratoryRate + syncVO2Max = try c.decodeIfPresent(Bool.self, forKey: .syncVO2Max) ?? d.syncVO2Max + syncBloodSugar = try c.decodeIfPresent(Bool.self, forKey: .syncBloodSugar) ?? d.syncBloodSugar + syncBloodPressure = try c.decodeIfPresent(Bool.self, forKey: .syncBloodPressure) ?? d.syncBloodPressure exportWorkouts = try c.decodeIfPresent(Bool.self, forKey: .exportWorkouts) ?? d.exportWorkouts syncNutrition = try c.decodeIfPresent(Bool.self, forKey: .syncNutrition) ?? d.syncNutrition backfillChoice = try c.decodeIfPresent(HealthBackfillChoice.self, forKey: .backfillChoice) ?? d.backfillChoice diff --git a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift index b8c6b44..7d1ae21 100644 --- a/PulseLoop/Views/Settings/AppleHealthSettingsView.swift +++ b/PulseLoop/Views/Settings/AppleHealthSettingsView.swift @@ -10,6 +10,7 @@ import UIKit /// can clean up after turning sync off). struct AppleHealthSettingsView: View { @Environment(\.modelContext) private var modelContext + @Environment(RingBLEClient.self) private var ble @State private var service = HealthSyncService.shared @State private var store = AppleHealthPrefsStore.shared /// First-enable backfill choice ("all history" vs "new only" vs cancel). @@ -21,6 +22,12 @@ struct AppleHealthSettingsView: View { private var masterOn: Bool { store.prefs.masterEnabled } + /// What the connected ring can actually produce. Rows for metrics it can't are hidden outright + /// rather than shown-and-inert: a VO₂max toggle on a jring is a promise the hardware can't keep. + private var capabilities: Set { + MetricsService.activeCapabilities(context: modelContext, ble: ble) + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 22) { @@ -96,17 +103,41 @@ struct AppleHealthSettingsView: View { @ViewBuilder private var dataTypesGroup: some View { SettingsGroup( header: "Data types", - footer: "Stress, fatigue, and blood pressure don't have an Apple Health equivalent yet, so they aren't synced." + footer: "Stress and fatigue have no Apple Health equivalent — Health has no type for a device " + + "wellness score — so they can't be synced. Rows appear only for metrics your ring can produce." ) { FormToggleRow(title: "Heart rate", isOn: prefBinding(\.syncHeartRate)) FormToggleRow(title: "Blood oxygen", isOn: prefBinding(\.syncSpO2)) FormToggleRow(title: "Heart rate variability", isOn: prefBinding(\.syncHRV)) FormToggleRow(title: "Temperature", isOn: prefBinding(\.syncTemperature)) + if shows(.respiratoryRate) { + FormToggleRow(title: "Respiratory rate", isOn: prefBinding(\.syncRespiratoryRate)) + } + if shows(.vo2max) { + FormToggleRow(title: "Cardio fitness (VO₂max)", isOn: prefBinding(\.syncVO2Max)) + } + if shows(.bloodSugar, capability: .bloodSugar) { + FormToggleRow(title: "Blood glucose", isOn: prefBinding(\.syncBloodSugar)) + } + if shows(.bloodPressureSystolic, capability: .bloodPressure) { + FormToggleRow(title: "Blood pressure", isOn: prefBinding(\.syncBloodPressure)) + } FormToggleRow(title: "Sleep", isOn: prefBinding(\.syncSleep)) FormToggleRow(title: "Steps & activity", isOn: prefBinding(\.syncActivity)) } } + /// Whether to offer a toggle for a ring-dependent metric. + /// + /// Shown when the connected ring declares the capability **or** the store already holds a reading + /// of that kind. The second arm matters for two cases the capability alone misses: respiratory + /// rate and VO₂max have no `WearableCapability` of their own (they ride the YCBT history records), + /// and history from a previously-paired ring should stay exportable after switching hardware. + private func shows(_ kind: MeasurementKind, capability: WearableCapability? = nil) -> Bool { + if let capability, capabilities.contains(capability) { return true } + return MetricsRepository.hasAnyMeasurement(kind: kind, context: modelContext) + } + @ViewBuilder private var workoutsGroup: some View { SettingsGroup( header: "Workouts", diff --git a/PulseLoopTests/HealthSyncNewTypesTests.swift b/PulseLoopTests/HealthSyncNewTypesTests.swift new file mode 100644 index 0000000..4b8efdc --- /dev/null +++ b/PulseLoopTests/HealthSyncNewTypesTests.swift @@ -0,0 +1,132 @@ +import XCTest +import HealthKit +import SwiftData +@testable import PulseLoop + +/// Respiratory rate, VO₂max, blood glucose and blood pressure were tracked and displayed in-app but +/// never reached Apple Health. These lock the four new mappings, the units they're written in, and +/// the two kinds that genuinely have nowhere to go. +@MainActor +final class HealthSyncNewTypesTests: XCTestCase { + + // MARK: - New quantity mappings + + func testRespiratoryRateMapsToCountPerMinute() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .respiratoryRate)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.respiratoryRate.rawValue) + XCTAssertEqual(mapping.unit, HKUnit.count().unitDivided(by: .minute())) + XCTAssertEqual(mapping.convert(16), 16, "brpm is already HealthKit's unit") + } + + func testVO2MaxMapsToMillilitresPerKilogramMinute() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .vo2max)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.vo2Max.rawValue) + let expected = HKUnit.literUnit(with: .milli) + .unitDivided(by: HKUnit.gramUnit(with: .kilo).unitMultiplied(by: .minute())) + XCTAssertEqual(mapping.unit, expected) + XCTAssertEqual(mapping.convert(42), 42) + } + + func testBloodGlucoseMapsToMgPerDecilitre() throws { + let mapping = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .bloodSugar)) + XCTAssertEqual(mapping.type.identifier, HKQuantityTypeIdentifier.bloodGlucose.rawValue) + XCTAssertEqual(mapping.unit, HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci))) + XCTAssertEqual(mapping.convert(95), 95, "stored canonically in mg/dL already") + } + + // MARK: - Plausibility, matching RingEventBridge's persistence gates + + func testNewMappingPlausibilityBounds() throws { + let resp = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .respiratoryRate)) + XCTAssertTrue(resp.isPlausible(4)) + XCTAssertTrue(resp.isPlausible(60)) + XCTAssertFalse(resp.isPlausible(3)) + XCTAssertFalse(resp.isPlausible(61)) + + let vo2 = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .vo2max)) + XCTAssertTrue(vo2.isPlausible(10)) + XCTAssertTrue(vo2.isPlausible(90)) + XCTAssertFalse(vo2.isPlausible(9)) + XCTAssertFalse(vo2.isPlausible(91)) + + let glucose = try XCTUnwrap(HealthKitTypeMappings.quantityMapping(for: .bloodSugar)) + XCTAssertTrue(glucose.isPlausible(40)) + XCTAssertTrue(glucose.isPlausible(600)) + XCTAssertFalse(glucose.isPlausible(39)) + XCTAssertFalse(glucose.isPlausible(601)) + } + + // MARK: - The two that stay unmapped + + /// Not a follow-up: HealthKit has no type for a device-derived wellness score. `HKStateOfMind` + /// is a self-reported mood log, so writing a ring's 0–100 number into it would misrepresent both. + func testStressAndFatigueHaveNoHealthKitType() { + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress)) + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue)) + } + + /// Blood pressure must not take the quantity path — a loose systolic or diastolic sample is + /// stored by Health but never surfaces as a reading, which looks exactly like a silent failure. + func testBloodPressureHalvesAreNotQuantityMapped() { + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureSystolic)) + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureDiastolic)) + } + + // MARK: - Blood-pressure pairing + + func testBloodPressurePlausibilityRejectsInvertedPairs() { + XCTAssertTrue(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 118, diastolic: 76)) + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 76, diastolic: 118), + "systolic below diastolic is a misframed packet, not a reading") + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 90, diastolic: 90), + "equal halves are not a valid reading either") + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 300, diastolic: 76)) + XCTAssertFalse(HealthKitTypeMappings.isPlausibleBloodPressure(systolic: 118, diastolic: 20)) + } + + /// Both halves share one instant, so the sync id is derived from that instant alone — a + /// re-export of the same reading upserts rather than duplicating. + func testBloodPressureSyncIDIsStablePerInstant() { + let instant = Date(timeIntervalSince1970: 1_760_000_000.25) + XCTAssertEqual(HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant), + HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant)) + XCTAssertNotEqual(HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant), + HealthKitTypeMappings.bloodPressureSyncID(timestamp: instant.addingTimeInterval(0.001))) + } + + // MARK: - Preferences + + func testNewPerTypeTogglesDefaultOn() { + let prefs = AppleHealthPrefs.default + XCTAssertTrue(prefs.syncRespiratoryRate) + XCTAssertTrue(prefs.syncVO2Max) + XCTAssertTrue(prefs.syncBloodSugar) + XCTAssertTrue(prefs.syncBloodPressure) + } + + /// A blob written by a build that predates these keys must keep its existing choices rather than + /// being discarded wholesale. + func testTolerantDecodeOfAnOlderPrefsBlob() throws { + let legacy = #"{"masterEnabled":true,"syncHeartRate":false,"backfillChoice":"newDataOnly"}"# + let prefs = try JSONDecoder().decode(AppleHealthPrefs.self, from: Data(legacy.utf8)) + + XCTAssertTrue(prefs.masterEnabled) + XCTAssertFalse(prefs.syncHeartRate, "the stored choice survives") + XCTAssertEqual(prefs.backfillChoice, .newDataOnly) + XCTAssertTrue(prefs.syncVO2Max, "a key the old build never wrote falls back to its default") + } + + // MARK: - Capability gating for the settings rows + + func testHasAnyMeasurementDrivesRowVisibility() throws { + let context = try TestSupport.makeContext() + XCTAssertFalse(MetricsRepository.hasAnyMeasurement(kind: .vo2max, context: context)) + + context.insert(Measurement(kind: .vo2max, value: 42, unit: "mL/kg/min", timestamp: Date())) + try? context.save() + + XCTAssertTrue(MetricsRepository.hasAnyMeasurement(kind: .vo2max, context: context)) + XCTAssertFalse(MetricsRepository.hasAnyMeasurement(kind: .respiratoryRate, context: context), + "existence is per-kind, not any-row") + } +} diff --git a/PulseLoopTests/HealthSyncTests.swift b/PulseLoopTests/HealthSyncTests.swift index 10daa74..14f3f93 100644 --- a/PulseLoopTests/HealthSyncTests.swift +++ b/PulseLoopTests/HealthSyncTests.swift @@ -66,12 +66,14 @@ final class HealthSyncTests: XCTestCase { XCTAssertFalse(mapping.isPlausible(15), "far below body temperature") } + /// The kinds with no quantity mapping, and why — see `HealthSyncNewTypesTests` for the four that + /// gained one. Blood sugar left this list when `.bloodGlucose` was wired up; blood pressure + /// stayed, because it exports as an `HKCorrelation` rather than a quantity. func testUnsupportedKindsMapToNil() { - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress), "no native HealthKit equivalent") - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue), "no native HealthKit equivalent") + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .stress), "HealthKit has no type for a wellness score") + XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .fatigue), "HealthKit has no type for a wellness score") XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureSystolic), "needs HKCorrelation pairing") XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodPressureDiastolic), "needs HKCorrelation pairing") - XCTAssertNil(HealthKitTypeMappings.quantityMapping(for: .bloodSugar), "needs its own share type") } // MARK: - HealthKitTypeMappings: sleep-stage map