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