diff --git a/BuildDetails.plist b/BuildDetails.plist index 6a06c7327bb..d01749fe721 100644 --- a/BuildDetails.plist +++ b/BuildDetails.plist @@ -3,6 +3,8 @@ TidepoolServiceClientId - diy-loop + nightscout-trio + TidepoolServiceRedirectURL + org.nightscout.trio.tidepoolkit.auth://redirect diff --git a/Trio.xcodeproj/project.pbxproj b/Trio.xcodeproj/project.pbxproj index 2c8124196d5..8fbfd89a8b2 100644 --- a/Trio.xcodeproj/project.pbxproj +++ b/Trio.xcodeproj/project.pbxproj @@ -414,6 +414,7 @@ BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; }; BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; }; BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */; }; + BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */; }; BDA25EE42D260CD500035F34 /* AppleWatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */; }; BDA25EE62D260D5E00035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EE52D260D5800035F34 /* WatchState.swift */; }; BDA25EFD2D261C0000035F34 /* WatchState.swift in Sources */ = {isa = PBXBuildFile; fileRef = BDA25EFC2D261BF200035F34 /* WatchState.swift */; }; @@ -1249,6 +1250,7 @@ BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = ""; }; BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = ""; }; BD8FC0652D661A0000B95AED /* GlucoseStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseStorageTests.swift; sourceTree = ""; }; + BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TidepoolTherapySettingsTests.swift; sourceTree = ""; }; BDA25EE32D260CCF00035F34 /* AppleWatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleWatchManager.swift; sourceTree = ""; }; BDA25EE52D260D5800035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = ""; }; BDA25EFC2D261BF200035F34 /* WatchState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchState.swift; sourceTree = ""; }; @@ -2640,6 +2642,7 @@ 3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */, CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */, BD8FC0532D66186000B95AED /* TestError.swift */, + BD8FC0702D661B0000B95AED /* TidepoolTherapySettingsTests.swift */, ); path = TrioTests; sourceTree = ""; @@ -4793,6 +4796,7 @@ 38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */, BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */, BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */, + BD8FC0712D661B0000B95AED /* TidepoolTherapySettingsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Trio/Sources/Localizations/Main/Localizable.xcstrings b/Trio/Sources/Localizations/Main/Localizable.xcstrings index 0c3167b55e2..02cebc2665f 100644 --- a/Trio/Sources/Localizations/Main/Localizable.xcstrings +++ b/Trio/Sources/Localizations/Main/Localizable.xcstrings @@ -264062,6 +264062,9 @@ } } } + }, + "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets." : { + }, "User Interface" : { "localizations" : { @@ -269847,6 +269850,7 @@ } }, "When connected, uploading of carbs, bolus, basal and glucose from Trio to your Tidepool account is enabled.\n\nUse your Tidepool credentials to login. If you dont already have a Tidepool account, you can sign up for one on the login page." : { + "extractionState" : "stale", "localizations" : { "bg" : { "stringUnit" : { @@ -277249,4 +277253,4 @@ } }, "version" : "1.1" -} \ No newline at end of file +} diff --git a/Trio/Sources/Models/CarbRatios.swift b/Trio/Sources/Models/CarbRatios.swift index 16fd0f6733f..ac8ed2947af 100644 --- a/Trio/Sources/Models/CarbRatios.swift +++ b/Trio/Sources/Models/CarbRatios.swift @@ -1,5 +1,9 @@ import Foundation +protocol CarbRatiosObserver { + func carbRatiosDidChange(_ carbRatios: CarbRatios) +} + struct CarbRatios: JSON { let units: CarbUnit let schedule: [CarbRatioEntry] diff --git a/Trio/Sources/Models/InsulinSensitivities.swift b/Trio/Sources/Models/InsulinSensitivities.swift index 285302523e7..9b7996e633c 100644 --- a/Trio/Sources/Models/InsulinSensitivities.swift +++ b/Trio/Sources/Models/InsulinSensitivities.swift @@ -1,5 +1,9 @@ import Foundation +protocol InsulinSensitivitiesObserver { + func insulinSensitivitiesDidChange(_ sensitivities: InsulinSensitivities) +} + struct InsulinSensitivities: JSON { var units: GlucoseUnits var userPreferredUnits: GlucoseUnits diff --git a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift index a6fa6589a71..3a47b0c6cdf 100644 --- a/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift +++ b/Trio/Sources/Modules/AlgorithmAdvancedSettings/AlgorithmAdvancedSettingsStateModel.swift @@ -7,6 +7,7 @@ extension AlgorithmAdvancedSettings { @Injected() var settings: SettingsManager! @Injected() var storage: FileStorage! @Injected() var nightscout: NightscoutManager! + @Injected() private var tidepoolManager: TidepoolManager! var units: GlucoseUnits = .mgdL @@ -78,6 +79,10 @@ extension AlgorithmAdvancedSettings { ) } } + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } } receiveValue: {} .store(in: &lifetime) } diff --git a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift index 20df175c7dd..8e805278bad 100644 --- a/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift +++ b/Trio/Sources/Modules/BasalProfileEditor/BasalProfileEditorStateModel.swift @@ -4,6 +4,7 @@ import SwiftUI extension BasalProfileEditor { @Observable final class StateModel: BaseStateModel { @ObservationIgnored @Injected() private var nightscout: NightscoutManager! + @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager! @ObservationIgnored @Injected() private var broadcaster: Broadcaster! var syncInProgress: Bool = false @@ -127,6 +128,10 @@ extension BasalProfileEditor { debug(.default, "Failed to upload basal rates to Nightscout: \(error)") } } + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } case .failure: // Handle the error, show error message self.showAlert = true diff --git a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift index 87179c99a2d..9b25656141f 100644 --- a/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift +++ b/Trio/Sources/Modules/CarbRatioEditor/CarbRatioEditorStateModel.swift @@ -3,6 +3,8 @@ import SwiftUI extension CarbRatioEditor { final class StateModel: BaseStateModel { @Injected() private var nightscout: NightscoutManager! + @Injected() private var tidepoolManager: TidepoolManager! + @Injected() private var broadcaster: Broadcaster! @Published var items: [Item] = [] @Published var initialItems: [Item] = [] @Published var therapyItems: [TherapySettingItem] = [] @@ -89,6 +91,13 @@ extension CarbRatioEditor { let profile = CarbRatios(units: .grams, schedule: schedule) provider.saveProfile(profile) initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } + + DispatchQueue.main.async { + self.broadcaster.notify(CarbRatiosObserver.self, on: .main) { + $0.carbRatiosDidChange(profile) + } + } + Task.detached(priority: .low) { do { debug(.nightscout, "Attempting to upload CRs to Nightscout") @@ -97,6 +106,10 @@ extension CarbRatioEditor { debug(.default, "Failed to upload CRs to Nightscout: \(error)") } } + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } } func validate() { diff --git a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift index c1e0de0e1ef..0f1b3435b61 100644 --- a/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift +++ b/Trio/Sources/Modules/GeneralSettings/UnitsLimitsSettingsStateModel.swift @@ -5,6 +5,7 @@ extension UnitsLimitsSettings { final class StateModel: BaseStateModel { @Injected() var settings: SettingsManager! @Injected() var storage: FileStorage! + @Injected() private var tidepoolManager: TidepoolManager! @Published var units: GlucoseUnits = .mgdL @Published var unitsIndex = 0 // index 0 is mg/dl @@ -56,6 +57,10 @@ extension UnitsLimitsSettings { let settings = self.provider.settings() self.maxBasal = settings.maxBasal self.maxBolus = settings.maxBolus + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } } receiveValue: {} .store(in: &lifetime) } diff --git a/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift b/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift index de71a95cc7e..5abe188d62f 100644 --- a/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift +++ b/Trio/Sources/Modules/ISFEditor/ISFEditorStateModel.swift @@ -16,6 +16,8 @@ extension ISFEditor { @Observable final class StateModel: BaseStateModel { @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage! @ObservationIgnored @Injected() private var nightscout: NightscoutManager! + @ObservationIgnored @Injected() private var tidepoolManager: TidepoolManager! + @ObservationIgnored @Injected() private var broadcaster: Broadcaster! var items: [Item] = [] var initialItems: [Item] = [] @@ -118,6 +120,12 @@ extension ISFEditor { provider.saveProfile(profile) initialItems = items.map { Item(rateIndex: $0.rateIndex, timeIndex: $0.timeIndex) } + DispatchQueue.main.async { + self.broadcaster.notify(InsulinSensitivitiesObserver.self, on: .main) { + $0.insulinSensitivitiesDidChange(profile) + } + } + Task.detached(priority: .low) { do { debug(.nightscout, "Attempting to upload ISF to Nightscout") @@ -129,6 +137,10 @@ extension ISFEditor { ) } } + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } } func validate() { diff --git a/Trio/Sources/Modules/Settings/SettingsStateModel.swift b/Trio/Sources/Modules/Settings/SettingsStateModel.swift index 8c3089730b4..8fb6c2df965 100644 --- a/Trio/Sources/Modules/Settings/SettingsStateModel.swift +++ b/Trio/Sources/Modules/Settings/SettingsStateModel.swift @@ -31,7 +31,6 @@ extension Settings { subscribeSetting(\.debugOptions, on: $debugOptions) { debugOptions = $0 } subscribeSetting(\.closedLoop, on: $closedLoop) { closedLoop = $0 } - broadcaster.register(SettingsObserver.self, observer: self) buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Unknown" diff --git a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift index e41992a209b..a46ae3c1e64 100644 --- a/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift +++ b/Trio/Sources/Modules/Settings/View/TidepoolStartView.swift @@ -99,7 +99,7 @@ struct TidepoolStartView: BaseView { shouldDisplayHint: $shouldDisplayHint, hintLabel: "Connect to Tidepool", hintText: Text( - "When connected, uploading of carbs, bolus, basal and glucose from Trio to your Tidepool account is enabled.\n\nUse your Tidepool credentials to login. If you dont already have a Tidepool account, you can sign up for one on the login page." + "Use your Tidepool credentials to log in. If you don't have a Tidepool account, you can sign up on the login page.\n\nWhen connected, Trio uploads your glucose, carb entries, insulin (bolus and basal), pump settings, and therapy settings to Tidepool.\n\nTherapy settings include basal schedules, carb ratios, insulin sensitivities, and glucose targets." ), sheetTitle: String(localized: "Help", comment: "Help sheet title") ) diff --git a/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift b/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift index e5e5f8c7667..8e7106725a8 100644 --- a/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift +++ b/Trio/Sources/Modules/TargetsEditor/TargetsEditorStateModel.swift @@ -3,6 +3,7 @@ import SwiftUI extension TargetsEditor { final class StateModel: BaseStateModel { @Injected() private var nightscout: NightscoutManager! + @Injected() private var tidepoolManager: TidepoolManager! @Injected() private var broadcaster: Broadcaster! @Published var items: [Item] = [] @@ -113,6 +114,10 @@ extension TargetsEditor { ) } } + + Task.detached(priority: .low) { + await self.tidepoolManager.uploadSettings() + } } func validate() { diff --git a/Trio/Sources/Services/Network/TidepoolManager.swift b/Trio/Sources/Services/Network/TidepoolManager.swift index 7c2bb60d61f..d30ce9882d8 100644 --- a/Trio/Sources/Services/Network/TidepoolManager.swift +++ b/Trio/Sources/Services/Network/TidepoolManager.swift @@ -1,11 +1,13 @@ import Combine import CoreData +import CryptoKit import Foundation import HealthKit import LoopKit import LoopKitUI import Swinject import TidepoolServiceKit +import UIKit protocol TidepoolManager { func addTidepoolService(service: Service) @@ -16,6 +18,7 @@ protocol TidepoolManager { func uploadInsulin() async func deleteInsulin(withSyncId id: String, amount: Decimal, at: Date) func uploadGlucose() async + func uploadSettings() async func forceTidepoolDataUpload() } @@ -27,8 +30,25 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { @Injected() private var storage: FileStorage! @Injected() private var pumpHistoryStorage: PumpHistoryStorage! @Injected() private var apsManager: APSManager! + @Injected() private var settingsManager: SettingsManager! + + // Lazy access to avoid circular dependency (TidepoolManager ↔ FetchGlucoseManager) + private var resolver: Resolver? private let processQueue = DispatchQueue(label: "BaseNetworkManager.processQueue") + + /// Pending debounce work item for settings upload; cancelled and rescheduled + /// each time an observer fires, so rapid changes coalesce into one upload. + /// - Important: Only access from `processQueue` to ensure thread safety. + private var pendingSettingsUpload: DispatchWorkItem? + + /// Delay before a debounced settings upload fires. + private static let settingsUploadDebounceDelay: TimeInterval = 1.5 + + /// Last-seen therapy-relevant TrioSettings values. + /// Used to filter `settingsDidChange` so UI-only changes don't trigger uploads. + private var lastClosedLoop: Bool? + private var lastUnits: GlucoseUnits? private var tidepoolService: RemoteDataService? { didSet { if let tidepoolService = tidepoolService { @@ -49,6 +69,7 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue? init(resolver: Resolver) { + self.resolver = resolver injectServices(resolver) loadTidepoolManager() @@ -133,6 +154,10 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { await self.uploadGlucose() } }.store(in: &subscriptions) + + // Register for settings that aren't saved from a single editor screen + broadcaster.register(SettingsObserver.self, observer: self) + broadcaster.register(PreferencesObserver.self, observer: self) } func sourceInfo() -> [String: Any]? { @@ -145,14 +170,14 @@ final class BaseTidepoolManager: TidepoolManager, Injectable { await uploadInsulin() await uploadCarbs() await uploadGlucose() + await uploadSettings() } } } extension BaseTidepoolManager: ServiceDelegate { var hostIdentifier: String { - // TODO: shouldn't this rather be `org.nightscout.Trio` ? - "com.loopkit.Loop" // To check + "org.nightscout.Trio" } var hostVersion: String { @@ -647,6 +672,79 @@ extension BaseTidepoolManager { } } +/// Settings Upload Functionality +extension BaseTidepoolManager { + /// Debounces settings upload requests. + /// Cancels any pending upload and schedules a new one after the debounce delay. + /// This prevents redundant uploads when multiple settings observers fire in rapid succession. + /// All access to `pendingSettingsUpload` is serialized on `processQueue`. + private func scheduleSettingsUpload() { + processQueue.async { [weak self] in + guard let self = self else { return } + self.pendingSettingsUpload?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + Task { + await self.uploadSettings() + } + } + self.pendingSettingsUpload = workItem + self.processQueue.asyncAfter( + deadline: .now() + Self.settingsUploadDebounceDelay, + execute: workItem + ) + } + } + + func uploadSettings() async { + guard let tidepoolService = self.tidepoolService as? TidepoolService else { + return + } + + // Get CGM device info (lazily resolved to avoid circular dependency) + let fetchGlucoseManager = resolver?.resolve(FetchGlucoseManager.self) + let cgmDevice = fetchGlucoseManager?.cgmManager?.cgmManagerStatus.device + + guard let settings = createStoredSettings(cgmDevice: cgmDevice) else { + return + } + + processQueue.async { + tidepoolService.uploadSettingsData([settings]) { result in + switch result { + case .success: + debug(.service, "Settings uploaded to Tidepool (syncId: \(settings.syncIdentifier))") + case let .failure(error): + debug(.service, "Failed to upload settings to Tidepool: \(error)") + } + } + } + } +} + +// MARK: - Settings Change Observers + +extension BaseTidepoolManager: SettingsObserver { + func settingsDidChange(_ settings: TrioSettings) { + // Only trigger upload when therapy-relevant properties change. + // TrioSettings has ~56 properties, most are UI-only (badges, colors, etc.). + let closedLoopChanged = lastClosedLoop != settings.closedLoop + let unitsChanged = lastUnits != settings.units + + lastClosedLoop = settings.closedLoop + lastUnits = settings.units + + guard closedLoopChanged || unitsChanged else { return } + scheduleSettingsUpload() + } +} + +extension BaseTidepoolManager: PreferencesObserver { + func preferencesDidChange(_: Preferences) { + scheduleSettingsUpload() + } +} + extension BaseTidepoolManager: StatefulPluggableDelegate { func pluginDidUpdateState(_: LoopKit.StatefulPluggable) {} @@ -655,6 +753,211 @@ extension BaseTidepoolManager: StatefulPluggableDelegate { } } +// MARK: - Settings Conversion + +extension BaseTidepoolManager { + /// Creates a StoredSettings object from current Trio settings + /// - Parameter cgmDevice: Optional CGM device info (pass from FetchGlucoseManager to avoid circular dependency) + func createStoredSettings(cgmDevice: HKDevice? = nil) -> StoredSettings? { + guard let basalProfile: [BasalProfileEntry] = storage + .retrieve(OpenAPS.Settings.basalProfile, as: [BasalProfileEntry].self), + let carbRatios: CarbRatios = storage.retrieve(OpenAPS.Settings.carbRatios, as: CarbRatios.self), + let insulinSensitivities: InsulinSensitivities = storage.retrieve( + OpenAPS.Settings.insulinSensitivities, + as: InsulinSensitivities.self + ), + let bgTargets: BGTargets = storage.retrieve(OpenAPS.Settings.bgTargets, as: BGTargets.self) + else { + debug(.service, "Failed to load Trio therapy settings for Tidepool upload") + return nil + } + + let pumpSettings = settingsManager.pumpSettings + let preferences: Preferences? = storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self) + + let basalRateSchedule = convertBasalProfile(basalProfile) + let carbRatioSchedule = convertCarbRatios(carbRatios) + let insulinSensitivitySchedule = convertInsulinSensitivities(insulinSensitivities) + let glucoseTargetRangeSchedule = convertBGTargets(bgTargets) + + let pumpDevice = apsManager.pumpManager?.status.device + let bgUnit: HKUnit = settingsManager.settings.units == .mmolL ? .millimolesPerLiter : .milligramsPerDeciliter + + // threshold_setting is always stored in mg/dL; TidepoolServiceKit calls + // convertTo(unit:) internally, so we pass it through in its native unit + let suspendThreshold: GlucoseThreshold? = preferences.map { prefs in + let thresholdValue = Double(prefs.threshold_setting) + return GlucoseThreshold(unit: .milligramsPerDeciliter, value: thresholdValue) + } + + return StoredSettings( + date: Date(), + controllerTimeZone: TimeZone.current, + dosingEnabled: settingsManager.settings.closedLoop, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + preMealTargetRange: nil, + workoutTargetRange: nil, + overridePresets: nil, + scheduleOverride: nil, + preMealOverride: nil, + maximumBasalRatePerHour: Double(pumpSettings.maxBasal), + maximumBolus: Double(pumpSettings.maxBolus), + suspendThreshold: suspendThreshold, + insulinType: apsManager.pumpManager?.status.insulinType, + defaultRapidActingModel: convertInsulinModel(preferences: preferences, pumpSettings: pumpSettings), + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule, + carbRatioSchedule: carbRatioSchedule, + notificationSettings: nil, + controllerDevice: createControllerDevice(), + cgmDevice: cgmDevice, + pumpDevice: pumpDevice, + bloodGlucoseUnit: bgUnit, + syncIdentifier: contentBasedSyncIdentifier( + basalProfile: basalProfile, + carbRatios: carbRatios, + insulinSensitivities: insulinSensitivities, + bgTargets: bgTargets, + pumpSettings: pumpSettings, + preferences: preferences, + dosingEnabled: settingsManager.settings.closedLoop + ) + ) + } + + private func convertBasalProfile(_ entries: [BasalProfileEntry]) -> BasalRateSchedule? { + let items = entries.map { entry in + let startTime = TimeInterval(entry.minutes * 60) + return RepeatingScheduleValue(startTime: startTime, value: Double(entry.rate)) + } + return BasalRateSchedule(dailyItems: items, timeZone: TimeZone.current) + } + + private func convertCarbRatios(_ carbRatios: CarbRatios) -> CarbRatioSchedule? { + let items = carbRatios.schedule.map { entry in + let startTime = TimeInterval(entry.offset * 60) + return RepeatingScheduleValue(startTime: startTime, value: Double(entry.ratio)) + } + return CarbRatioSchedule(unit: .gram(), dailyItems: items, timeZone: TimeZone.current) + } + + private func convertInsulinSensitivities(_ sensitivities: InsulinSensitivities) -> InsulinSensitivitySchedule? { + // sensitivities.units comes from the data model itself, not the user's display preference + let unit: HKUnit = sensitivities.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter + let items = sensitivities.sensitivities.map { entry in + let startTime = TimeInterval(entry.offset * 60) + return RepeatingScheduleValue(startTime: startTime, value: Double(entry.sensitivity)) + } + return InsulinSensitivitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current) + } + + private func convertBGTargets(_ bgTargets: BGTargets) -> GlucoseRangeSchedule? { + // bgTargets.units comes from the data model itself, not the user's display preference + let unit: HKUnit = bgTargets.units == .mgdL ? .milligramsPerDeciliter : .millimolesPerLiter + let items = bgTargets.targets.map { entry in + let startTime = TimeInterval(entry.offset * 60) + let minValue = Double(entry.low) + let maxValue = Double(entry.high) + return RepeatingScheduleValue(startTime: startTime, value: DoubleRange(minValue: minValue, maxValue: maxValue)) + } + let schedule = DailyQuantitySchedule(unit: unit, dailyItems: items, timeZone: TimeZone.current) + return schedule.map { GlucoseRangeSchedule(rangeSchedule: $0) } + } + + private func convertInsulinModel(preferences: Preferences?, pumpSettings: PumpSettings) -> StoredInsulinModel? { + guard let curve = preferences?.curve else { return nil } + + let modelType: StoredInsulinModel.ModelType + let preset: ExponentialInsulinModelPreset + switch curve { + case .bilinear, + .rapidActing: + modelType = .rapidAdult + preset = .rapidActingAdult + case .ultraRapid: + // Distinguish Fiasp vs Lyumjev using the pump's configured insulin type + let isLyumjev = apsManager.pumpManager?.status.insulinType == .lyumjev + modelType = isLyumjev ? .lyumjev : .fiasp + preset = isLyumjev ? .lyumjev : .fiasp + } + + let dia = Double(pumpSettings.insulinActionCurve) + + // Use custom peak time if enabled, otherwise fall back to LoopKit preset default + let peakActivity: TimeInterval + if let prefs = preferences, prefs.useCustomPeakTime { + peakActivity = .minutes(Double(prefs.insulinPeakTime)) + } else { + peakActivity = preset.peakActivity + } + + return StoredInsulinModel( + modelType: modelType, + delay: preset.delay, + actionDuration: .hours(dia), + peakActivity: peakActivity + ) + } + + /// Generates a deterministic UUID based on the content of the therapy settings. + /// If settings haven't changed, the same UUID is produced, enabling Tidepool + /// server-side deduplication via the origin ID. + private func contentBasedSyncIdentifier( + basalProfile: [BasalProfileEntry], + carbRatios: CarbRatios, + insulinSensitivities: InsulinSensitivities, + bgTargets: BGTargets, + pumpSettings: PumpSettings, + preferences: Preferences?, + dosingEnabled: Bool + ) -> UUID { + var hasher = SHA256() + + for entry in basalProfile { + hasher.update(data: Data("\(entry.minutes):\(entry.rate)".utf8)) + } + for entry in carbRatios.schedule { + hasher.update(data: Data("\(entry.offset):\(entry.ratio)".utf8)) + } + for entry in insulinSensitivities.sensitivities { + hasher.update(data: Data("\(entry.offset):\(entry.sensitivity)".utf8)) + } + for entry in bgTargets.targets { + hasher.update(data: Data("\(entry.offset):\(entry.low):\(entry.high)".utf8)) + } + + hasher.update(data: Data("maxBasal:\(pumpSettings.maxBasal)".utf8)) + hasher.update(data: Data("maxBolus:\(pumpSettings.maxBolus)".utf8)) + + if let prefs = preferences { + hasher.update(data: Data("threshold:\(prefs.threshold_setting)".utf8)) + } + + hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8)) + + let digest = hasher.finalize() + let bytes = Array(digest.prefix(16)) + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } + + private func createControllerDevice() -> StoredSettings.ControllerDevice { + let device = UIDevice.current + return StoredSettings.ControllerDevice( + name: "Trio", + systemName: device.systemName, + systemVersion: device.systemVersion, + model: device.model, + modelIdentifier: device.getDeviceId + ) + } +} + + // Service extension for rawValue extension Service { typealias RawValue = [String: Any] diff --git a/TrioTests/TidepoolTherapySettingsTests.swift b/TrioTests/TidepoolTherapySettingsTests.swift new file mode 100644 index 00000000000..c07a9357641 --- /dev/null +++ b/TrioTests/TidepoolTherapySettingsTests.swift @@ -0,0 +1,583 @@ +import CryptoKit +import HealthKit +import LoopKit +import Testing +import TidepoolKit + +@testable import TidepoolServiceKit +@testable import Trio + +// Both Trio and TidepoolServiceKit define mgPerDL, +// causing ambiguity. Use HealthKit's native API to avoid the conflict. +private let mgPerDL = HKUnit.gramUnit(with: .milli).unitDivided(by: HKUnit.literUnit(with: .deci)) +private let mmolPerL = HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) + +// MARK: - StoredSettings → Tidepool Datum Tests + +/// Tests that verify Trio's StoredSettings correctly converts to Tidepool's pumpSettings datum. +/// These test the REAL TidepoolServiceKit conversion code path. +@Suite("StoredSettings Tidepool Format Tests") struct StoredSettingsTidepoolFormatTests { + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder.tidepool + encoder.outputFormatting.insert(.prettyPrinted) + encoder.outputFormatting.insert(.sortedKeys) + return encoder + }() + + // MARK: - JSON Format + + @Test("Pump settings JSON contains required fields") func pumpSettingsJSONFormat() { + let datum = StoredSettings.test.datumPumpSettings(for: "trio-user-123", hostIdentifier: "Trio", hostVersion: "0.6.0") + let data = try! Self.encoder.encode(datum) + let json = String(data: data, encoding: .utf8)! + + let requiredFields = [ + "\"type\" : \"pumpSettings\"", + "\"activeSchedule\" : \"Default\"", + "\"basalSchedules\"", + "\"bgTargets\"", + "\"carbRatios\"", + "\"insulinSensitivities\"", + "\"automatedDelivery\"", + "\"name\" : \"Trio\"", + "\"version\" : \"0.6.0\"" + ] + + for field in requiredFields { + #expect(json.contains(field), "Missing required field: \(field)") + } + } + + @Test("Pump settings with minimal data") func pumpSettingsWithMinimalData() { + let datum = StoredSettings.minimal.datumPumpSettings(for: "test-user", hostIdentifier: "Trio", hostVersion: "0.6.0") + #expect(datum.activeScheduleName == "Default") + #expect(datum.origin?.name == "Trio") + #expect(datum.origin?.version == "0.6.0") + } + + // MARK: - Schedule Naming + + @Test("All schedules use 'Default' name") func scheduleNaming() { + let datum = StoredSettings.test.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.activeScheduleName == "Default") + #expect(datum.basalRateSchedules?.keys.count == 1) + #expect(datum.basalRateSchedules?["Default"] != nil) + #expect(datum.bloodGlucoseTargetSchedules?["Default"] != nil) + #expect(datum.carbohydrateRatioSchedules?["Default"] != nil) + #expect(datum.insulinSensitivitySchedules?["Default"] != nil) + } + + // MARK: - Device Metadata + + @Test("Pump device metadata is included") func pumpDeviceMetadata() { + let pumpDevice = HKDevice( + name: "Omnipod", manufacturer: "Insulet", model: "Dash", + hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil, + localIdentifier: "pod-123", udiDeviceIdentifier: nil + ) + + let settings = makeSettings(pumpDevice: pumpDevice) + let data = try! Self.encoder.encode( + settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + ) + let json = String(data: data, encoding: .utf8)! + + #expect(json.contains("Omnipod"), "Missing pump device name") + #expect(json.contains("Insulet"), "Missing pump manufacturer") + } + + @Test("CGM device metadata is included") func cgmDeviceMetadata() { + let cgmDevice = HKDevice( + name: "Dexcom G7", manufacturer: "Dexcom", model: "G7", + hardwareVersion: nil, firmwareVersion: "1.2.3", softwareVersion: "4.5.6", + localIdentifier: "CGM123", udiDeviceIdentifier: nil + ) + + let settings = makeSettings(cgmDevice: cgmDevice) + let datum = settings.datumCGMSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + let data = try! Self.encoder.encode(datum) + let json = String(data: data, encoding: .utf8)! + + #expect(json.contains("Dexcom G7"), "Missing CGM device name") + #expect(json.contains("Dexcom"), "Missing CGM manufacturer") + } + + // MARK: - Suspend Threshold + + @Test("Suspend threshold value is preserved") func suspendThreshold() { + let settings = makeSettings( + suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0) + ) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.bloodGlucoseSafetyLimit == 70, "Suspend threshold value should match") + } + + @Test("Suspend threshold in mg/dL passes through for mmol/L user") func suspendThresholdMmolLUser() { + // threshold_setting is always stored in mg/dL even when user displays mmol/L. + // The adapter creates GlucoseThreshold in mg/dL; TidepoolServiceKit converts internally. + let settings = makeSettings( + suspendThreshold: GlucoseThreshold(unit: mgPerDL, value: 70.0), + bgUnit: mmolPerL + ) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect( + datum.bloodGlucoseSafetyLimit == 70, + "Threshold in mg/dL should pass through correctly regardless of display unit" + ) + } + + // MARK: - Max Basal / Max Bolus + + @Test("Maximum basal and bolus values are preserved") func maximumValues() { + let settings = makeSettings(maxBasal: 30.0, maxBolus: 25.0) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.basal?.rateMaximum?.value == 30.0, "Max basal should handle high values") + #expect(datum.bolus?.amountMaximum?.value == 25.0, "Max bolus should handle high values") + } + + @Test("Minimum basal and bolus values are preserved") func minimumValues() { + let settings = makeSettings(maxBasal: 0.5, maxBolus: 1.0) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.basal?.rateMaximum?.value == 0.5, "Should preserve low max basal") + #expect(datum.bolus?.amountMaximum?.value == 1.0, "Should preserve low max bolus") + } + + // MARK: - Automated Delivery Flag + + @Test("Automated delivery flag reflects dosing state") func automatedDeliveryFlag() { + let enabled = makeSettings(dosingEnabled: true) + let disabled = makeSettings(dosingEnabled: false) + + let enabledDatum = enabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + let disabledDatum = disabled.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(enabledDatum.automatedDelivery == true) + #expect(disabledDatum.automatedDelivery == false) + } + + // MARK: - Unit Conversion + + @Test("mmol/L values are converted to mg/dL by Tidepool") func mmolLUnitConversion() { + let targetSchedule = GlucoseRangeSchedule( + rangeSchedule: DailyQuantitySchedule( + unit: mmolPerL, + dailyItems: [RepeatingScheduleValue( + startTime: 0, + value: DoubleRange(minValue: 5.0, maxValue: 6.0) + )], + timeZone: .current + )!, + override: nil + ) + let isfSchedule = InsulinSensitivitySchedule( + unit: mmolPerL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 3.0)], + timeZone: .current + ) + + let settings = makeSettings( + glucoseTargetRangeSchedule: targetSchedule, + insulinSensitivitySchedule: isfSchedule, + bgUnit: mmolPerL + ) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + // Tidepool converts to mg/dL (5.0 mmol/L ≈ 90 mg/dL) + let target = datum.bloodGlucoseTargetSchedules?["Default"]?.first + #expect(abs((target?.low ?? 0) - 90) <= 1) + #expect(abs((target?.high ?? 0) - 108) <= 1) + + let isf = datum.insulinSensitivitySchedules?["Default"]?.first + #expect(abs((isf?.amount ?? 0) - 54) <= 1) + } + + // MARK: - Insulin Model + + @Test("Insulin model preserves DIA and peak time") func insulinModel() { + let model = StoredInsulinModel( + modelType: .rapidAdult, + delay: .minutes(10), + actionDuration: .hours(8), + peakActivity: .minutes(65) + ) + let settings = makeSettings(insulinModel: model) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.insulinModel != nil, "Insulin model should be present") + #expect(datum.insulinModel?.actionDuration == .hours(8), "DIA should match user setting") + #expect(datum.insulinModel?.actionPeakOffset == .minutes(65), "Peak time should match user setting") + } + + @Test("Fiasp insulin model maps correctly") func fiaspInsulinModel() { + let model = StoredInsulinModel( + modelType: .fiasp, + delay: .minutes(10), + actionDuration: .hours(6), + peakActivity: .minutes(55) + ) + let settings = makeSettings(insulinModel: model) + let datum = settings.datumPumpSettings(for: "test", hostIdentifier: "Trio", hostVersion: "0.6.0") + + #expect(datum.insulinModel?.modelType == .fiasp, "Ultra-rapid should map to fiasp") + #expect(datum.insulinModel?.actionDuration == .hours(6)) + #expect(datum.insulinModel?.actionPeakOffset == .minutes(55)) + } + + // MARK: - Helpers + + private func makeSettings( + dosingEnabled: Bool = true, + glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil, + insulinSensitivitySchedule: InsulinSensitivitySchedule? = nil, + maxBasal: Double? = 5.0, + maxBolus: Double? = 10.0, + suspendThreshold: GlucoseThreshold? = nil, + insulinModel: StoredInsulinModel? = nil, + cgmDevice: HKDevice? = nil, + pumpDevice: HKDevice? = nil, + bgUnit: HKUnit = mgPerDL + ) -> StoredSettings { + let tz = TimeZone(secondsFromGMT: 0)! + + let defaultTarget = GlucoseRangeSchedule( + rangeSchedule: DailyQuantitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue( + startTime: 0, + value: DoubleRange(minValue: 100.0, maxValue: 110.0) + )], + timeZone: tz + )!, + override: nil + ) + + let defaultBasal = BasalRateSchedule( + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)], + timeZone: tz + )! + + let defaultISF = InsulinSensitivitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], + timeZone: tz + )! + + let defaultCarb = CarbRatioSchedule( + unit: .gram(), + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], + timeZone: tz + )! + + return StoredSettings( + date: Date(), + controllerTimeZone: .current, + dosingEnabled: dosingEnabled, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule ?? defaultTarget, + preMealTargetRange: nil, + workoutTargetRange: nil, + overridePresets: nil, + scheduleOverride: nil, + preMealOverride: nil, + maximumBasalRatePerHour: maxBasal, + maximumBolus: maxBolus, + suspendThreshold: suspendThreshold, + insulinType: nil, + defaultRapidActingModel: insulinModel, + basalRateSchedule: defaultBasal, + insulinSensitivitySchedule: insulinSensitivitySchedule ?? defaultISF, + carbRatioSchedule: defaultCarb, + notificationSettings: nil, + controllerDevice: nil, + cgmDevice: cgmDevice, + pumpDevice: pumpDevice, + bloodGlucoseUnit: bgUnit, + syncIdentifier: UUID() + ) + } +} + +// MARK: - Conversion Logic Tests + +/// Tests for the conversion math used in BaseTidepoolManager. +/// These verify the patterns used in the real adapter code. +@Suite("BaseTidepoolManager Conversion Tests") struct BaseTidepoolManagerTests { + // MARK: - Basal Profile Conversion + + @Test("Basal profile minutes convert to seconds") func basalProfileMinutesToSeconds() { + let entries: [(minutes: Int, expectedSeconds: TimeInterval)] = [ + (0, 0), + (210, 12600), + (360, 21600), + (720, 43200), + (1125, 67500), + (1439, 86340) + ] + + for (minutes, expected) in entries { + let startTime = TimeInterval(minutes * 60) + #expect(startTime == expected, "\(minutes) minutes should be \(expected) seconds") + } + } + + @Test("Basal profile uses minutes field for start time") func basalProfileUsesMinutesField() { + let entries = [ + BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0), + BasalProfileEntry(start: "06:00:00", minutes: 360, rate: 1.5), + BasalProfileEntry(start: "12:00:00", minutes: 720, rate: 1.25) + ] + + let items = entries.map { entry in + RepeatingScheduleValue( + startTime: TimeInterval(entry.minutes * 60), + value: Double(entry.rate) + ) + } + let schedule = BasalRateSchedule(dailyItems: items, timeZone: .current) + + #expect(schedule != nil) + #expect(schedule?.items[0].startTime == 0) + #expect(schedule?.items[1].startTime == 21600) + #expect(schedule?.items[2].startTime == 43200) + } + + // MARK: - Carb Ratio Conversion + + @Test("Carb ratio offset converts to seconds") func carbRatioOffsetToSeconds() { + let entries = [ + CarbRatioEntry(start: "00:00", offset: 0, ratio: 15.0), + CarbRatioEntry(start: "06:00", offset: 360, ratio: 12.0), + CarbRatioEntry(start: "12:00", offset: 720, ratio: 10.0) + ] + + let items = entries.map { entry in + RepeatingScheduleValue( + startTime: TimeInterval(entry.offset * 60), + value: Double(entry.ratio) + ) + } + + #expect(items[0].startTime == 0) + #expect(items[1].startTime == 21600) + #expect(items[2].startTime == 43200) + } + + // MARK: - ISF Conversion + + @Test("ISF offset converts to seconds") func insulinSensitivityOffsetToSeconds() { + let entries = [ + InsulinSensitivityEntry(sensitivity: 50.0, offset: 0, start: "00:00"), + InsulinSensitivityEntry(sensitivity: 45.0, offset: 480, start: "08:00") + ] + + let items = entries.map { entry in + RepeatingScheduleValue( + startTime: TimeInterval(entry.offset * 60), + value: Double(entry.sensitivity) + ) + } + + #expect(items[0].startTime == 0) + #expect(items[1].startTime == 28800, "480 min = 28800 sec") + } + + // MARK: - BG Target Conversion + + @Test("BG target offset converts to seconds") func bgTargetOffsetToSeconds() { + let entries = [ + BGTargetEntry(low: 100, high: 110, start: "00:00", offset: 0), + BGTargetEntry(low: 110, high: 120, start: "22:00", offset: 1320) + ] + + #expect(TimeInterval(entries[0].offset * 60) == 0) + #expect(TimeInterval(entries[1].offset * 60) == 79200, "1320 min = 79200 sec") + } + + @Test("BG target low and high values are preserved") func bgTargetLowHighValues() { + let entry = BGTargetEntry(low: 90, high: 120, start: "00:00", offset: 0) + #expect(Double(entry.low) == 90) + #expect(Double(entry.high) == 120) + } + + // MARK: - Insulin Model Conversion + + @Test("Preset peak times match expected values when custom peak disabled") func presetPeakTimes() { + // When useCustomPeakTime is false, should use LoopKit preset defaults + let rapidAdultPeak = ExponentialInsulinModelPreset.rapidActingAdult.peakActivity + let fiaspPeak = ExponentialInsulinModelPreset.fiasp.peakActivity + + #expect(rapidAdultPeak == .minutes(75), "rapidActingAdult preset peak should be 75 min") + #expect(fiaspPeak == .minutes(55), "fiasp preset peak should be 55 min") + } + + @Test("Custom peak time range boundaries") func customPeakTimeRange() { + // insulinPeakTime picker: min 35, max 120, step 1 (minutes) + let minPeak: TimeInterval = .minutes(35) + let maxPeak: TimeInterval = .minutes(120) + + #expect(minPeak == 2100, "35 minutes = 2100 seconds") + #expect(maxPeak == 7200, "120 minutes = 7200 seconds") + } + + @Test("DIA range boundaries") func diaRange() { + // insulinActionCurve picker: min 5, max 10, step 0.5 (hours) + let minDIA: TimeInterval = .hours(5) + let maxDIA: TimeInterval = .hours(10) + + #expect(minDIA == 18000, "5 hours = 18000 seconds") + #expect(maxDIA == 36000, "10 hours = 36000 seconds") + } + + // MARK: - Content-Based Sync Identifier + + @Test("Same settings produce the same sync identifier") func syncIdentifierDeterminism() { + let id1 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) + let id2 = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) + #expect(id1 == id2, "Same settings should produce the same sync identifier") + } + + @Test("Different settings produce different sync identifiers") func syncIdentifierChanges() { + let baseline = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: true) + let changedBasal = computeTestSyncId(maxBasal: "6.0", maxBolus: "10.0", dosingEnabled: true) + let changedDosing = computeTestSyncId(maxBasal: "5.0", maxBolus: "10.0", dosingEnabled: false) + + #expect(baseline != changedBasal, "Different maxBasal should produce different ID") + #expect(baseline != changedDosing, "Different dosingEnabled should produce different ID") + #expect(changedBasal != changedDosing, "All three should be unique") + } + + // MARK: - Helpers + + /// Replicates the SHA-256 hash algorithm from BaseTidepoolManager.contentBasedSyncIdentifier + private func computeTestSyncId(maxBasal: String, maxBolus: String, dosingEnabled: Bool) -> UUID { + var hasher = SHA256() + hasher.update(data: Data("0:1.0".utf8)) // basal entry + hasher.update(data: Data("0:15".utf8)) // carb ratio + hasher.update(data: Data("0:50".utf8)) // ISF + hasher.update(data: Data("0:100:110".utf8)) // BG target + hasher.update(data: Data("maxBasal:\(maxBasal)".utf8)) + hasher.update(data: Data("maxBolus:\(maxBolus)".utf8)) + hasher.update(data: Data("threshold:100".utf8)) + hasher.update(data: Data("dosingEnabled:\(dosingEnabled)".utf8)) + let digest = hasher.finalize() + let bytes = Array(digest.prefix(16)) + return UUID(uuid: ( + bytes[0], bytes[1], bytes[2], bytes[3], + bytes[4], bytes[5], bytes[6], bytes[7], + bytes[8], bytes[9], bytes[10], bytes[11], + bytes[12], bytes[13], bytes[14], bytes[15] + )) + } +} + +// MARK: - Test Fixtures + +private extension StoredSettings { + static var test: StoredSettings { + let tz = TimeZone(secondsFromGMT: 0)! + + let pumpDevice = HKDevice( + name: "Omnipod", manufacturer: "Insulet", model: "Dash", + hardwareVersion: "1.0", firmwareVersion: "2.9.0", softwareVersion: nil, + localIdentifier: "pod-serial-123", udiDeviceIdentifier: nil + ) + + return StoredSettings( + date: Date(), + controllerTimeZone: TimeZone(identifier: "America/Los_Angeles")!, + dosingEnabled: true, + glucoseTargetRangeSchedule: GlucoseRangeSchedule( + rangeSchedule: DailyQuantitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))], + timeZone: tz + )!, + override: nil + ), + preMealTargetRange: nil, + workoutTargetRange: nil, + overridePresets: nil, + scheduleOverride: nil, + preMealOverride: nil, + maximumBasalRatePerHour: 5.0, + maximumBolus: 10.0, + suspendThreshold: nil, + insulinType: .humalog, + defaultRapidActingModel: nil, + basalRateSchedule: BasalRateSchedule(dailyItems: [ + RepeatingScheduleValue(startTime: 0, value: 1.0), + RepeatingScheduleValue(startTime: 21600, value: 1.5), + RepeatingScheduleValue(startTime: 43200, value: 1.25), + RepeatingScheduleValue(startTime: 64800, value: 1.0) + ], timeZone: tz)!, + insulinSensitivitySchedule: InsulinSensitivitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], + timeZone: tz + )!, + carbRatioSchedule: CarbRatioSchedule( + unit: .gram(), + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], + timeZone: tz + )!, + notificationSettings: nil, + controllerDevice: nil, + cgmDevice: nil, + pumpDevice: pumpDevice, + bloodGlucoseUnit: mgPerDL, + syncIdentifier: UUID() + ) + } + + static var minimal: StoredSettings { + let tz = TimeZone(secondsFromGMT: 0)! + + return StoredSettings( + date: Date(), + controllerTimeZone: .current, + dosingEnabled: true, + glucoseTargetRangeSchedule: GlucoseRangeSchedule( + rangeSchedule: DailyQuantitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100.0, maxValue: 110.0))], + timeZone: tz + )!, + override: nil + ), + preMealTargetRange: nil, + workoutTargetRange: nil, + overridePresets: nil, + scheduleOverride: nil, + preMealOverride: nil, + maximumBasalRatePerHour: nil, + maximumBolus: nil, + suspendThreshold: nil, + insulinType: nil, + defaultRapidActingModel: nil, + basalRateSchedule: BasalRateSchedule( + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 1.0)], + timeZone: tz + )!, + insulinSensitivitySchedule: InsulinSensitivitySchedule( + unit: mgPerDL, + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 45.0)], + timeZone: tz + )!, + carbRatioSchedule: CarbRatioSchedule( + unit: .gram(), + dailyItems: [RepeatingScheduleValue(startTime: 0, value: 15.0)], + timeZone: tz + )!, + notificationSettings: nil, + controllerDevice: nil, + cgmDevice: nil, + pumpDevice: nil, + bloodGlucoseUnit: mgPerDL, + syncIdentifier: UUID() + ) + } +}