diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index e02e07538d..c94e13368a 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + FC8D0BDACEFE2E545E2605E1 /* DeviceDataManagerTherapyProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB490099D0931F6B695C39E /* DeviceDataManagerTherapyProfileTests.swift */; }; + 2E3F5FD7B369167E41E59667 /* DeviceDataManagerScheduledProfileSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AFF676119621CDFDD638367 /* DeviceDataManagerScheduledProfileSwitchTests.swift */; }; 101AA7370B0E499FBD88BB9F /* G7SensorKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 921B7FAE002E40EFAB3F50C6 /* G7SensorKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */; }; 52CB9A826E3B3DFC4E483084 /* AlgorithmBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36421A4B9676EF68CDDC4ECD /* AlgorithmBridge.swift */; }; @@ -892,6 +894,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + ACB490099D0931F6B695C39E /* DeviceDataManagerTherapyProfileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerTherapyProfileTests.swift; sourceTree = ""; }; + 0AFF676119621CDFDD638367 /* DeviceDataManagerScheduledProfileSwitchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceDataManagerScheduledProfileSwitchTests.swift; sourceTree = ""; }; F8760AF31E3C43F6AFBA1BE7 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 00A28F30399B460CABF4EDD2 /* SettingsProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsProvider.swift; sourceTree = ""; }; 0FC34B523FE5A50C920506D5 /* Loop/Resources/Sounds/alarm.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Loop/Resources/Sounds/alarm.caf; sourceTree = SOURCE_ROOT; }; @@ -1824,6 +1828,8 @@ C16575722538AFF6004AE16E /* CGMStalenessMonitorTests.swift */, A91E4C2224F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift */, C188599A2AF15E1B0010F21F /* DeviceDataManagerTests.swift */, + ACB490099D0931F6B695C39E /* DeviceDataManagerTherapyProfileTests.swift */, + 0AFF676119621CDFDD638367 /* DeviceDataManagerScheduledProfileSwitchTests.swift */, C16B983F26B4898800256B05 /* DoseEnactorTests.swift */, E9C58A7124DB489100487A17 /* LoopDataManagerTests.swift */, A1B2C3D40000000000000001 /* GlucoseAlertManagerTests.swift */, @@ -4192,6 +4198,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FC8D0BDACEFE2E545E2605E1 /* DeviceDataManagerTherapyProfileTests.swift in Sources */, + 2E3F5FD7B369167E41E59667 /* DeviceDataManagerScheduledProfileSwitchTests.swift in Sources */, A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */, 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */, C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, diff --git a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift index 8fff07dbaf..ac8d487e20 100644 --- a/Loop/Extensions/SettingsStore+SimulatedCoreData.swift +++ b/Loop/Extensions/SettingsStore+SimulatedCoreData.swift @@ -25,7 +25,9 @@ extension SettingsStore { while startDate < endDate { for index in 0..= simulatedLimit { @@ -45,7 +47,7 @@ extension SettingsStore { } fileprivate extension StoredSettings { - static func simulated(date: Date) -> StoredSettings { + static func simulated(date: Date, activeProfileName: String?) -> StoredSettings { let controllerTimeZone = TimeZone(identifier: "America/Los_Angeles")! let scheduleTimeZone = TimeZone(secondsFromGMT: TimeZone(identifier: "America/Phoenix")!.secondsFromGMT())! let glucoseTargetRangeSchedule = GlucoseRangeSchedule(rangeSchedule: DailyQuantitySchedule(unit: .milligramsPerDeciliter, @@ -90,6 +92,24 @@ fileprivate extension StoredSettings { RepeatingScheduleValue(startTime: .hours(18), value: 8.0), RepeatingScheduleValue(startTime: .hours(21), value: 10.0)], timeZone: scheduleTimeZone) + let defaultProfile = TherapyProfile(name: TherapyProfile.defaultName, + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + carbRatioSchedule: carbRatioSchedule!, + basalRateSchedule: basalRateSchedule!, + insulinSensitivitySchedule: insulinSensitivitySchedule!) + let exerciseProfile = TherapyProfile(name: "Exercise", + glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: DoubleRange(minValue: 120.0, maxValue: 130.0))], + timeZone: scheduleTimeZone)!, + carbRatioSchedule: CarbRatioSchedule(unit: .gram, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 14.0)], + timeZone: scheduleTimeZone)!, + basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 0.75)], + timeZone: scheduleTimeZone)!, + insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: .hours(0), value: 60.0)], + timeZone: scheduleTimeZone)!) + let activeProfile = activeProfileName == exerciseProfile.name ? exerciseProfile : defaultProfile let notificationSettings = NotificationSettings(authorizationStatus: .authorized, soundSetting: .enabled, badgeSetting: .enabled, @@ -129,7 +149,7 @@ fileprivate extension StoredSettings { return StoredSettings(date: date, controllerTimeZone: controllerTimeZone, dosingEnabled: true, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + glucoseTargetRangeSchedule: activeProfile.glucoseTargetRangeSchedule, preMealTargetRange: DoubleRange(minValue: 80.0, maxValue: 90.0).quantityRange(for: .milligramsPerDeciliter), overridePresets: [], maximumBasalRatePerHour: 3.5, @@ -138,13 +158,15 @@ fileprivate extension StoredSettings { deviceToken: UUID().uuidString, insulinType: .humalog, defaultRapidActingModel: StoredInsulinModel(ExponentialInsulinModelPreset.rapidActingAdult), - basalRateSchedule: basalRateSchedule, - insulinSensitivitySchedule: insulinSensitivitySchedule, - carbRatioSchedule: carbRatioSchedule, + basalRateSchedule: activeProfile.basalRateSchedule, + insulinSensitivitySchedule: activeProfile.insulinSensitivitySchedule, + carbRatioSchedule: activeProfile.carbRatioSchedule, notificationSettings: notificationSettings, controllerDevice: controllerDevice, cgmDevice: cgmDevice, pumpDevice: pumpDevice, - bloodGlucoseUnit: .milligramsPerDeciliter) + bloodGlucoseUnit: .milligramsPerDeciliter, + therapyProfiles: [defaultProfile, exerciseProfile], + activeProfileName: activeProfileName) } } diff --git a/Loop/Localizable.xcstrings b/Loop/Localizable.xcstrings index 92601ae0e8..6778bdf852 100644 --- a/Loop/Localizable.xcstrings +++ b/Loop/Localizable.xcstrings @@ -12,6 +12,54 @@ }, " -" : { + }, + "%1$@ (paused)" : { + "comment" : "A paused scheduled switch in a notification (1: scheduled switch)" + }, + "%1$d scheduled switches removed" : { + "comment" : "Remote profile delete notification: scheduled switches to the deleted profile were removed (1: number of scheduled switches)" + }, + "%1$d starts paused" : { + "comment" : "Status screen: scheduled profile starts are paused (1: number of paused starts)" + }, + "1 start paused" : { + "comment" : "Status screen: one scheduled profile start is paused" + }, + "All scheduled switches were removed remotely" : { + "comment" : "Remote scheduled switches notification when the list was cleared" + }, + "all starts paused" : { + "comment" : "Status screen: every scheduled profile start is paused" + }, + "It is more than %1$d hours late." : { + "comment" : "Reason a scheduled profile switch did not run: too late (1: hours)" + }, + "Loop has not been able to switch to %1$@ as scheduled at %2$@. Open Loop." : { + "comment" : "Alert body when a scheduled profile switch has not run (1: profile name, 2: time)" + }, + "Loop switched to %1$@ as scheduled (%2$@)." : { + "comment" : "Notification body of a scheduled profile switch (1: profile name, 2: scheduled time)" + }, + "Loop switched to %1$@, scheduled for %2$@, at %3$@." : { + "comment" : "Notification body of a late scheduled profile switch (1: profile name, 2: scheduled time, 3: time of the switch)" + }, + "Profile Not Switched" : { + "comment" : "Title of the alerts about a scheduled profile switch that did not run" + }, + "Profile Switched" : { + "comment" : "The notification title for a scheduled profile switch" + }, + "Scheduled switches changed remotely: %1$@" : { + "comment" : "Remote scheduled switches notification (1: the scheduled switches)" + }, + "Scheduled switches not saved: %1$@" : { + "comment" : "Remote scheduled switches failure notification (1: reason)" + }, + "The scheduled switch to %1$@ (%2$@) did not happen: %3$@" : { + "comment" : "Alert body when a scheduled profile switch did not run (1: profile name, 2: weekday and time, 3: reason)" + }, + "until %1$@ starts %2$@ %3$@" : { + "comment" : "Status screen: the next scheduled profile start (1: profile name, 2: short weekday, 3: time)" }, " - " : { @@ -2239,6 +2287,9 @@ } } }, + "%1$@ of profile %2$@" : { + "comment" : "Setup change item for a therapy setting of a therapy profile (1: setting name, 2: profile name)" + }, "%1$@ Still Active" : { "comment" : "The format title for the preset still active alert. (1: preset name)" }, @@ -5315,6 +5366,9 @@ } } }, + "Active: %1$@" : { + "comment" : "Descriptive text for Profiles (1: active profile name)" + }, "Add a new favorite food" : { "comment" : "Button label to open new favorite food view", "localizations" : { @@ -30747,6 +30801,24 @@ }, "Presets for Illness" : { + }, + "Profile '%1$@' activated" : { + "comment" : "Remote profile switch notification (1: profile name)" + }, + "Profile '%1$@' deleted" : { + "comment" : "Remote profile delete notification (1: profile name)" + }, + "Profile '%1$@' not activated: %2$@" : { + "comment" : "Remote profile switch failure notification (1: profile name, 2: reason)" + }, + "Profile '%1$@' not deleted: %2$@" : { + "comment" : "Remote profile delete failure notification (1: profile name, 2: reason)" + }, + "Profile '%1$@' not saved: %2$@" : { + "comment" : "Remote profile save failure notification (1: profile name, 2: reason)" + }, + "Profile '%1$@' saved" : { + "comment" : "Remote profile save notification (1: profile name)" }, "Profile Expiration" : { "comment" : "Settings App Profile expiration view", @@ -31013,6 +31085,15 @@ "comment" : "The title of the text field where the user can set the name of a glucose alert profile.", "isCommentAutoGenerated" : true }, + "Profile: %1$@" : { + "comment" : "Status screen label naming the active therapy profile (1: profile name)" + }, + "Profiles" : { + "comment" : "Title text for button to Profiles" + }, + "Profiles are not available" : { + "comment" : "Therapy profile action error description: no profile handler." + }, "Pump" : { "comment" : "The title of the pump section in settings", "extractionState" : "manual", @@ -32822,6 +32903,9 @@ } } }, + "Remote Profile Command" : { + "comment" : "The notification title for a remote therapy profile command" + }, "Repeat every" : { "comment" : "A label describing the option to change how often a glucose alert will repeat.", "isCommentAutoGenerated" : true @@ -35413,6 +35497,9 @@ } } }, + "Switched to profile %1$@ (from %2$@)" : { + "comment" : "Setup change item for a switch of therapy profile (1: profile switched to, 2: profile switched from)" + }, "Tap “Apps”." : { "comment" : "Focus modes step 3" }, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 19db48210d..03314db1fb 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -168,6 +168,12 @@ final class DeviceDataManager { var doseEnactor = DoseEnactor() + + /// Set while a profile switch or save runs, so profile changes run one at a time. + private(set) var therapyProfileChangeInProgress = false + + /// Issues the alerts about scheduled profile switches. + weak var scheduledProfileSwitchAlertIssuer: AlertIssuer? // MARK: Stores let carbStore: CarbStore @@ -268,6 +274,7 @@ final class DeviceDataManager { self.pluginManager = pluginManager self.deviceLog = deviceLog self.alertManager = alertManager + self.scheduledProfileSwitchAlertIssuer = alertManager self.glucoseAlertManager = GlucoseAlertManager(alertIssuer: alertManager, displayGlucosePreference: displayGlucosePreference) alertManager.addAlertSoundVendor(managerIdentifier: GlucoseAlertManager.managerIdentifier, @@ -865,6 +872,9 @@ extension DeviceDataManager { func didBecomeActive() { updatePumpManagerBLEHeartbeatPreference() + Task { + await runDueScheduledProfileSwitch() + } } func updatePumpManagerBLEHeartbeatPreference() { @@ -1102,6 +1112,11 @@ extension DeviceDataManager: PumpManagerDelegate { self.deliveryUncertaintyAlertManager?.clearAlert() } } + + // A scheduled switch waits for a bolus to finish and for delivery to resume. + if !status.holdsScheduledProfileSwitch, oldStatus.holdsScheduledProfileSwitch { + await runDueScheduledProfileSwitch() + } } } @@ -1378,8 +1393,31 @@ extension DeviceDataManager : RemoteDataServicesManagerDelegate { extension DeviceDataManager: TherapySettingsViewModelDelegate { + /// Profile changes are refused while the basal schedule is written, so the pump and the settings end up with + /// the same schedule. A suspended pump would resume delivery when it takes a basal schedule, so the schedule is + /// not sent. func syncBasalRateSchedule(items: [RepeatingScheduleValue], completion: @escaping (Swift.Result) -> Void) { - pumpManager?.syncBasalRateSchedule(items: items, completion: completion) + guard let pumpManager else { + return + } + guard !therapyProfileChangeInProgress else { + completion(.failure(TherapyProfileError.operationInProgress)) + return + } + switch pumpManager.status.basalDeliveryState { + case .suspended, .suspending: + completion(.failure(TherapyProfileError.pumpSuspended)) + return + default: + break + } + therapyProfileChangeInProgress = true + pumpManager.syncBasalRateSchedule(items: items) { result in + Task { @MainActor in + self.therapyProfileChangeInProgress = false + completion(result) + } + } } func syncDeliveryLimits(deliveryLimits: DeliveryLimits) async throws -> DeliveryLimits @@ -1396,7 +1434,41 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { return try await pumpManager?.syncDeliveryLimits(limits: deliveryLimits) ?? deliveryLimits } + /// Edits must start from the active profile's current schedules, and no profile change may be running. + func validateTherapySettingsEdit(from therapySettings: TherapySettings) throws { + guard !therapyProfileChangeInProgress else { + throw TherapyProfileError.operationInProgress + } + let activeName = activeTherapyProfileName + guard TherapyProfiles.activeName(therapySettings.activeProfileName) == activeName else { + throw TherapyProfileError.activeProfileChangedWhileEditing(activeName) + } + let edited = TherapyProfile(name: activeName, + glucoseTargetRangeSchedule: therapySettings.glucoseTargetRangeSchedule, + carbRatioSchedule: therapySettings.carbRatioSchedule, + basalRateSchedule: therapySettings.basalRateSchedule, + insulinSensitivitySchedule: therapySettings.insulinSensitivitySchedule) + let live = settingsManager.loopSettings.liveTherapyProfile + if let edited, let live { + guard edited.hasSameValues(as: live) else { + throw TherapyProfileError.settingsChangedWhileEditing + } + } else if (edited == nil) != (live == nil) { + throw TherapyProfileError.settingsChangedWhileEditing + } + } + + /// Settings for another profile than the active one, or saved while a profile change runs, are not saved, so + /// stale schedules never reach the active profile. func saveCompletion(therapySettings: TherapySettings) { + guard !therapyProfileChangeInProgress else { + log.error("Therapy settings not saved: a profile change is in progress") + return + } + guard TherapyProfiles.activeName(therapySettings.activeProfileName) == activeTherapyProfileName else { + log.error("Therapy settings for profile %{public}@ not saved: %{public}@ is active", TherapyProfiles.activeName(therapySettings.activeProfileName), activeTherapyProfileName) + return + } settingsManager.mutateLoopSettings { settings in settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule settings.preMealTargetRange = therapySettings.correctionRangeOverrides?.preMeal @@ -1420,6 +1492,417 @@ extension DeviceDataManager: TherapySettingsViewModelDelegate { } } +extension DeviceDataManager: TherapyProfileDelegate { + + var therapyProfiles: [TherapyProfile] { + settingsManager.loopSettings.resolvedTherapyProfiles + } + + var activeTherapyProfileName: String { + TherapyProfiles.activeName(settingsManager.loopSettings.activeProfileName) + } + + var therapyProfileValidator: TherapyProfileValidator? { + guard let pumpManager else { + return nil + } + let settings = settingsManager.loopSettings + return TherapyProfileValidator(supportedBasalRates: pumpManager.supportedBasalRates, + maximumBasalRatePerHour: settings.maximumBasalRatePerHour, + maximumBasalScheduleEntryCount: pumpManager.maximumBasalScheduleEntryCount, + suspendThreshold: settings.suspendThreshold) + } + + func activateTherapyProfile(named name: String, expectedFingerprint: String?, trigger: TherapyProfileSwitchTrigger) async throws { + let now = Date() + try await loggingFailure("Activating therapy profile \(name) (\(trigger.rawValue))") { + try await self.activateTherapyProfile(named: name, expectedFingerprint: expectedFingerprint, trigger: trigger, at: now) + } + await armScheduledProfileSwitchAlert(now: now) + } + + /// Validates, writes the basal schedule to the pump, then sets the four schedules, the active name and the trigger + /// in one change. Scheduled starts up to `date`, the time of the switch, are settled in the same change. + private func activateTherapyProfile(named name: String, expectedFingerprint: String?, trigger: TherapyProfileSwitchTrigger, at date: Date) async throws { + try beginTherapyProfileChange() + defer { therapyProfileChangeInProgress = false } + + guard let profile = therapyProfile(named: name) else { + throw TherapyProfileError.notFound(name) + } + try checkFingerprint(of: profile, expected: expectedFingerprint) + guard let validator = therapyProfileValidator else { + throw TherapyProfileError.noPump + } + let validated = try validator.validate(profile) + + if name == activeTherapyProfileName, let live = settingsManager.loopSettings.liveTherapyProfile, live.hasSameValues(as: validated) { + return + } + + let syncedBasal = try await syncBasalRateSchedule(items: validated.basalRateSchedule.items) + let live = settingsManager.loopSettings.liveTherapyProfile + settingsManager.mutateLoopSettings { settings in + settings.therapyProfiles = TherapyProfiles.resolved(settings.therapyProfiles, live: settings.activeProfileName == nil ? live : nil) + settings.applyTherapyProfileSchedules(validated) + settings.basalRateSchedule = syncedBasal + settings.activeProfileName = name == TherapyProfile.defaultName ? nil : name + settings.activeProfileSwitchTrigger = trigger + settings.scheduledSwitchesSettledAt = date + } + log.default("Activated therapy profile %{public}@ (%{public}@)", name, trigger.rawValue) + } + + func saveTherapyProfile(_ profile: TherapyProfile, originalName: String?, expectedFingerprint: String?) async throws { + try await loggingFailure("Saving therapy profile \(profile.name)") { + try await self.performSaveTherapyProfile(profile, originalName: originalName, expectedFingerprint: expectedFingerprint) + } + } + + /// Saves `profile`; for the active profile the basal schedule goes to the pump first. Values that are already + /// stored (a rename or a copy) are not validated again, and glucose values sent back unchanged keep their stored value. + private func performSaveTherapyProfile(_ profile: TherapyProfile, originalName: String?, expectedFingerprint: String?) async throws { + var profile = profile + try beginTherapyProfileChange() + defer { therapyProfileChangeInProgress = false } + + let isActive = originalName != nil && originalName == activeTherapyProfileName + var original: TherapyProfile? + if let originalName { + guard let stored = therapyProfile(named: originalName) else { + throw TherapyProfileError.notFound(originalName) + } + try checkFingerprint(of: stored, expected: expectedFingerprint) + original = stored + profile = profile.keepingStoredGlucoseValues(of: stored) + } + var validated: TherapyProfile + let holdsStoredValues = original.map { profile.hasSameValues(as: $0) } ?? therapyProfiles.contains { profile.hasSameValues(as: $0) } + if holdsStoredValues { + validated = profile + } else { + guard let validator = therapyProfileValidator else { + throw TherapyProfileError.noPump + } + validated = try validator.validate(profile) + } + if let timeZone = settingsManager.loopSettings.basalRateSchedule?.timeZone { + validated = validated.withTimeZone(timeZone) + } + + let loopSettings = settingsManager.loopSettings + let live = loopSettings.liveTherapyProfile + let result = try TherapyProfiles.saving(validated, + originalName: originalName, + in: loopSettings.therapyProfiles, + activeProfileName: loopSettings.activeProfileName, + live: live) + + if isActive { + let syncedBasal: BasalRateSchedule + if let live, live.basalRateSchedule.items == validated.basalRateSchedule.items { + syncedBasal = live.basalRateSchedule + } else { + syncedBasal = try await syncBasalRateSchedule(items: validated.basalRateSchedule.items) + } + settingsManager.mutateLoopSettings { settings in + settings.therapyProfiles = result.profiles + settings.activeProfileName = result.activeProfileName + settings.applyTherapyProfileSchedules(validated) + settings.basalRateSchedule = syncedBasal + if let originalName { + settings.scheduledSwitches = ScheduledProfileSwitches.renaming(settings.scheduledSwitches, from: originalName, to: validated.name) + } + } + } else { + settingsManager.mutateLoopSettings { settings in + settings.therapyProfiles = result.profiles + settings.activeProfileName = result.activeProfileName + if let originalName { + settings.scheduledSwitches = ScheduledProfileSwitches.renaming(settings.scheduledSwitches, from: originalName, to: validated.name) + } + } + } + if let originalName, originalName != validated.name { + await armScheduledProfileSwitchAlert() + } + log.default("Saved therapy profile %{public}@ (original %{public}@, active %{public}@)", validated.name, originalName ?? "none", String(describing: isActive)) + } + + func deleteTherapyProfile(named name: String, expectedFingerprint: String?) async throws { + try await loggingFailure("Deleting therapy profile \(name)") { + try await self.performDeleteTherapyProfile(named: name, expectedFingerprint: expectedFingerprint) + } + } + + private func performDeleteTherapyProfile(named name: String, expectedFingerprint: String?) async throws { + try beginTherapyProfileChange() + defer { therapyProfileChangeInProgress = false } + + let loopSettings = settingsManager.loopSettings + let profiles = try TherapyProfiles.deleting(name, in: loopSettings.therapyProfiles, activeProfileName: loopSettings.activeProfileName, live: loopSettings.liveTherapyProfile) + if let profile = therapyProfile(named: name) { + try checkFingerprint(of: profile, expected: expectedFingerprint) + } + let removedSwitches = settingsManager.loopSettings.scheduledSwitches.filter { $0.profileName == name }.count + settingsManager.mutateLoopSettings { settings in + settings.therapyProfiles = profiles + settings.scheduledSwitches = ScheduledProfileSwitches.removing(profile: name, from: settings.scheduledSwitches) + } + log.default("Deleted therapy profile %{public}@ and %d scheduled switches", name, removedSwitches) + if removedSwitches > 0 { + await armScheduledProfileSwitchAlert() + } + } + + var scheduledProfileSwitches: [ScheduledProfileSwitch] { + settingsManager.loopSettings.scheduledSwitches + } + + func saveScheduledProfileSwitches(_ entries: [ScheduledProfileSwitch], expectedFingerprint: String?) async throws { + try await loggingFailure("Saving \(entries.count) scheduled profile switches") { + try await self.performSaveScheduledProfileSwitches(entries, expectedFingerprint: expectedFingerprint) + } + } + + /// Only starts after the save run: the save settles every earlier one. + private func performSaveScheduledProfileSwitches(_ entries: [ScheduledProfileSwitch], expectedFingerprint: String?) async throws { + try beginTherapyProfileChange() + defer { therapyProfileChangeInProgress = false } + + let current = settingsManager.loopSettings.scheduledSwitches + if let expectedFingerprint, ScheduledProfileSwitches.fingerprint(current) != expectedFingerprint { + throw ScheduledProfileSwitchError.conflict + } + try ScheduledProfileSwitches.validate(entries, profileNames: therapyProfiles.map(\.name)) + guard entries != current else { + return + } + let now = Date() + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitches = entries + settings.scheduledSwitchesSettledAt = now + } + log.default("Saved %d scheduled profile switches", entries.count) + await armScheduledProfileSwitchAlert(now: now) + } + + func reorderTherapyProfiles(names: [String]) { + let loopSettings = settingsManager.loopSettings + let profiles = TherapyProfiles.reordering(loopSettings.therapyProfiles, names: names, live: loopSettings.liveTherapyProfile, activeProfileName: loopSettings.activeProfileName) + settingsManager.mutateLoopSettings { settings in + settings.therapyProfiles = profiles + } + } + + private func loggingFailure(_ action: String, _ operation: () async throws -> Void) async throws { + do { + try await operation() + } catch { + log.error("%{public}@ failed: %{public}@", action, String(describing: error)) + throw error + } + } + + private func checkFingerprint(of profile: TherapyProfile, expected fingerprint: String?) throws { + if let fingerprint, !profile.matches(fingerprint: fingerprint) { + throw TherapyProfileError.conflict + } + } + + private func beginTherapyProfileChange() throws { + guard !therapyProfileChangeInProgress else { + throw TherapyProfileError.operationInProgress + } + therapyProfileChangeInProgress = true + } + + /// A suspended pump would resume delivery when it takes a basal schedule, so the schedule is not sent. + private func syncBasalRateSchedule(items: [RepeatingScheduleValue]) async throws -> BasalRateSchedule { + guard let pumpManager else { + throw TherapyProfileError.noPump + } + switch pumpManager.status.basalDeliveryState { + case .suspended, .suspending: + throw TherapyProfileError.pumpSuspended + default: + break + } + do { + return try await withCheckedThrowingContinuation { continuation in + pumpManager.syncBasalRateSchedule(items: items) { result in + continuation.resume(with: result) + } + } + } catch { + throw TherapyProfileError.pumpSyncFailed(error.localizedDescription) + } + } +} + +// MARK: - Scheduled profile switches + +extension DeviceDataManager { + + static let scheduledProfileSwitchManagerIdentifier = "ScheduledProfileSwitches" + /// A start is applied at most this late; an older one is reported as missed. + static let scheduledProfileSwitchGrace = TimeInterval(hours: 24) + /// A start not applied this long after its time raises an alert. + static let scheduledProfileSwitchLateAlertDelay = TimeInterval(minutes: 30) + + private var scheduleTimeZone: TimeZone { + settingsManager.loopSettings.basalRateSchedule?.timeZone ?? .current + } + + private var pendingScheduledProfileSwitchAlertIdentifier: Alert.Identifier { + Alert.Identifier(managerIdentifier: Self.scheduledProfileSwitchManagerIdentifier, alertIdentifier: "pending") + } + + /// The next start that makes another profile than the active one active. + func nextScheduledProfileSwitch(after date: Date) -> ScheduledProfileSwitchOccurrence? { + let settings = settingsManager.loopSettings + let activeName = TherapyProfiles.activeName(settings.activeProfileName) + return ScheduledProfileSwitches.next(in: settings.scheduledSwitches.filter { $0.profileName != activeName }, after: date, timeZone: scheduleTimeZone) + } + + /// Runs the newest start since the settled instant: a start older than the grace is reported as missed, a start + /// whose profile is already active is settled, and other starts switch through the validated switch. A pump that + /// can not take the basal schedule now leaves the start unsettled, so the next run tries again. + func runDueScheduledProfileSwitch(now: Date = Date()) async { + let settings = settingsManager.loopSettings + guard !settings.scheduledSwitches.isEmpty, !therapyProfileChangeInProgress else { + return + } + guard let settledAt = settings.scheduledSwitchesSettledAt else { + settleScheduledProfileSwitches(at: now) + await armScheduledProfileSwitchAlert(now: now) + return + } + guard let due = ScheduledProfileSwitches.occurrences(of: settings.scheduledSwitches, after: settledAt, upTo: now, timeZone: scheduleTimeZone).first else { + return + } + let name = due.entry.profileName + + guard now.timeIntervalSince(due.date) <= Self.scheduledProfileSwitchGrace else { + log.error("Scheduled switch to %{public}@ at %{public}@ is too late to run", name, String(describing: due.date)) + settleScheduledProfileSwitches(at: now) + await issueScheduledProfileSwitchFailedAlert(due, reason: String(format: NSLocalizedString("It is more than %1$d hours late.", comment: "Reason a scheduled profile switch did not run: too late (1: hours)"), Int(Self.scheduledProfileSwitchGrace.hours))) + await armScheduledProfileSwitchAlert(now: now) + return + } + guard therapyProfile(named: name) != nil else { + settleScheduledProfileSwitches(at: now) + await issueScheduledProfileSwitchFailedAlert(due, reason: TherapyProfileError.notFound(name).localizedDescription) + await armScheduledProfileSwitchAlert(now: now) + return + } + guard name != activeTherapyProfileName else { + settleScheduledProfileSwitches(at: now) + await armScheduledProfileSwitchAlert(now: now) + return + } + if case .inProgress = pumpManager?.status.bolusState { + log.default("Scheduled switch to %{public}@ waits for the bolus to finish", name) + return + } + + do { + try await activateTherapyProfile(named: name, expectedFingerprint: nil, trigger: .scheduled, at: due.date) + NotificationManager.sendScheduledProfileSwitchNotification(profileName: name, scheduledAt: due.date, switchedAt: now, timeZone: scheduleTimeZone) + await armScheduledProfileSwitchAlert(now: now) + } catch let error as TherapyProfileError where error.isTransientForScheduledSwitch { + log.default("Scheduled switch to %{public}@ not run yet: %{public}@", name, error.localizedDescription) + await armScheduledProfileSwitchAlert(now: now, reason: error.localizedDescription) + } catch { + log.error("Scheduled switch to %{public}@ failed: %{public}@", name, error.localizedDescription) + settleScheduledProfileSwitches(at: now) + await issueScheduledProfileSwitchFailedAlert(due, reason: error.localizedDescription) + await armScheduledProfileSwitchAlert(now: now) + } + } + + private func settleScheduledProfileSwitches(at date: Date) { + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitchesSettledAt = date + } + } + + /// Arms one alert for 30 minutes after the next start that is not settled, which iOS delivers also when Loop is + /// not running. A successful switch or a settle arms it for the following start instead; a retry of a start whose + /// alert was delivered leaves it. + func armScheduledProfileSwitchAlert(now: Date = Date(), reason: String? = nil) async { + let identifier = pendingScheduledProfileSwitchAlertIdentifier + let settledAt = settingsManager.loopSettings.scheduledSwitchesSettledAt ?? now + let next = nextScheduledProfileSwitch(after: settledAt) + let alertDate = next?.date.addingTimeInterval(Self.scheduledProfileSwitchLateAlertDelay) + if let alertDate, alertDate <= now { + // Delivered already; it stays until the start is settled. + return + } + await scheduledProfileSwitchAlertIssuer?.retractAlert(identifier: identifier) + guard let next, let alertDate else { + return + } + let time = ScheduledProfileSwitches.localizedTime(next.entry.time) + var body = String(format: NSLocalizedString("Loop has not been able to switch to %1$@ as scheduled at %2$@. Open Loop.", comment: "Alert body when a scheduled profile switch has not run (1: profile name, 2: time)"), next.entry.profileName, time) + if let reason { + body += "\n\n" + reason + } + let content = Alert.Content(title: NSLocalizedString("Profile Not Switched", comment: "Title of the alerts about a scheduled profile switch that did not run"), + body: body, + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Acknowledge button of a scheduled profile switch alert")) + await scheduledProfileSwitchAlertIssuer?.issueAlert(Alert(identifier: identifier, + foregroundContent: content, + backgroundContent: content, + trigger: .delayed(interval: alertDate.timeIntervalSince(now)), + interruptionLevel: .timeSensitive, + categoryIdentifier: LoopNotificationCategory.scheduledProfileSwitch.rawValue)) + } + + private func issueScheduledProfileSwitchFailedAlert(_ occurrence: ScheduledProfileSwitchOccurrence, reason: String) async { + let body = String(format: NSLocalizedString("The scheduled switch to %1$@ (%2$@) did not happen: %3$@", comment: "Alert body when a scheduled profile switch did not run (1: profile name, 2: weekday and time, 3: reason)"), + occurrence.entry.profileName, occurrence.entry.localizedWeekdayAndTime, reason) + let content = Alert.Content(title: NSLocalizedString("Profile Not Switched", comment: "Title of the alerts about a scheduled profile switch that did not run"), + body: body, + acknowledgeActionButtonLabel: NSLocalizedString("OK", comment: "Acknowledge button of a scheduled profile switch alert")) + let identifier = Alert.Identifier(managerIdentifier: Self.scheduledProfileSwitchManagerIdentifier, + alertIdentifier: "\(occurrence.entry.id).\(Int(occurrence.date.timeIntervalSince1970))") + await scheduledProfileSwitchAlertIssuer?.issueAlert(Alert(identifier: identifier, + foregroundContent: content, + backgroundContent: content, + trigger: .immediate, + interruptionLevel: .timeSensitive, + categoryIdentifier: LoopNotificationCategory.scheduledProfileSwitch.rawValue)) + } +} + +private extension PumpManagerStatus { + /// A bolus is running or delivery is suspended, so a scheduled switch can not write the basal schedule now. + var holdsScheduledProfileSwitch: Bool { + if case .inProgress = bolusState { + return true + } + switch basalDeliveryState { + case .suspended, .suspending: + return true + default: + return false + } + } +} + +private extension TherapyProfileError { + /// The pump can take the basal schedule later, so a scheduled switch tries again on the next run. + var isTransientForScheduledSwitch: Bool { + switch self { + case .pumpSuspended, .pumpSyncFailed, .operationInProgress, .noPump: + return true + default: + return false + } + } +} + extension DeviceDataManager: DeviceSupportDelegate { var availableSupports: [SupportUI] { [cgmManager, pumpManager].compactMap { $0 as? SupportUI } } @@ -1461,7 +1944,15 @@ extension DeviceDataManager: PumpManagerDeliveryDelegate { } } -extension DeviceDataManager: DeviceStatusProvider {} +extension DeviceDataManager: DeviceStatusProvider { + var pumpSupportedBasalRates: [Double]? { + pumpManager?.supportedBasalRates + } + + var pumpMaximumBasalScheduleEntryCount: Int? { + pumpManager?.maximumBasalScheduleEntryCount + } +} extension DeviceDataManager: BolusStateProvider { var bolusState: LoopKit.PumpManagerStatus.BolusState? { diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 2e9761470e..2cbd7f10fa 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -402,6 +402,7 @@ class LoopAppManager: NSObject { dosingDecisionStore.delegate = deviceDataManager remoteDataServicesManager.delegate = deviceDataManager + servicesManager.therapyProfileDelegate = deviceDataManager let criticalEventLogs: [CriticalEventLog] = [settingsManager.settingsStore!, glucoseStore, carbStore, dosingDecisionStore, doseStore, temporaryPresetsManager.presetHistory, deviceDataManager.deviceLog, alertManager.alertStore] criticalEventLogExportManager = CriticalEventLogExportManager(logs: criticalEventLogs, @@ -534,6 +535,7 @@ class LoopAppManager: NSObject { } private func loopCycleDidComplete() async { + await deviceDataManager.runDueScheduledProfileSwitch() DispatchQueue.main.asyncAfter(deadline: .now() + 5) { self.widgetLog.default("Refreshing widget. Reason: Loop completed") WidgetCenter.shared.reloadAllTimelines() @@ -601,6 +603,7 @@ class LoopAppManager: NSObject { availableSupports: supportManager.availableSupports, isOnboardingComplete: onboardingManager.isComplete, therapySettingsViewModelDelegate: deviceDataManager, + therapyProfileDelegate: deviceDataManager, presetHistory: temporaryPresetsManager.presetHistory, deliveryDelegate: deviceDataManager, deviceManager: deviceDataManager @@ -676,6 +679,7 @@ class LoopAppManager: NSObject { ], animated: true) await deviceDataManager.refreshDeviceData() + await deviceDataManager.runDueScheduledProfileSwitch() handleRemoteNotificationFromLaunchOptions() @@ -964,7 +968,10 @@ extension LoopAppManager: UNUserNotificationCenterDelegate { LoopNotificationCategory.remoteBolusFailure.rawValue, LoopNotificationCategory.remoteCarbs.rawValue, LoopNotificationCategory.remoteCarbsFailure.rawValue, + LoopNotificationCategory.remoteProfile.rawValue, + LoopNotificationCategory.remoteProfileFailure.rawValue, LoopNotificationCategory.missedMeal.rawValue, + LoopNotificationCategory.scheduledProfileSwitch.rawValue, LoopNotificationCategory.requiredUpdate.rawValue: completionHandler([.badge, .sound, .list, .banner]) default: diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 42eff19f42..9d5843c4b6 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -220,6 +220,41 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + static func sendRemoteTherapyProfileNotification(message: String, category: LoopNotificationCategory) { + let notification = UNMutableNotificationContent() + notification.title = NSLocalizedString("Remote Profile Command", comment: "The notification title for a remote therapy profile command") + notification.body = message + notification.sound = .default + + let request = UNNotificationRequest( + identifier: category.rawValue, + content: notification, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) + } + + /// Tells the user that a scheduled start switched the profile, and when it was late, when. + static func sendScheduledProfileSwitchNotification(profileName: String, scheduledAt: Date, switchedAt: Date, timeZone: TimeZone) { + let notification = UNMutableNotificationContent() + notification.title = NSLocalizedString("Profile Switched", comment: "The notification title for a scheduled profile switch") + let formatter = DateFormatter() + formatter.dateStyle = .none + formatter.timeStyle = .short + formatter.timeZone = timeZone + if switchedAt.timeIntervalSince(scheduledAt) < .minutes(15) { + notification.body = String(format: NSLocalizedString("Loop switched to %1$@ as scheduled (%2$@).", comment: "Notification body of a scheduled profile switch (1: profile name, 2: scheduled time)"), profileName, formatter.string(from: scheduledAt)) + } else { + notification.body = String(format: NSLocalizedString("Loop switched to %1$@, scheduled for %2$@, at %3$@.", comment: "Notification body of a late scheduled profile switch (1: profile name, 2: scheduled time, 3: time of the switch)"), profileName, formatter.string(from: scheduledAt), formatter.string(from: switchedAt)) + } + notification.interruptionLevel = .active + notification.categoryIdentifier = LoopNotificationCategory.scheduledProfileSwitch.rawValue + + let request = UNNotificationRequest(identifier: LoopNotificationCategory.scheduledProfileSwitch.rawValue, content: notification, trigger: nil) + UNUserNotificationCenter.current().add(request) + } + static func sendMissedMealNotification(mealStart: Date, amountInGrams: Double, delay: TimeInterval? = nil) { let notification = UNMutableNotificationContent() /// Notifications should expire after the missed meal is no longer relevant diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index b557f1840a..fc583780ea 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -30,6 +30,7 @@ class ServicesManager { weak var servicesManagerDelegate: ServicesManagerDelegate? weak var servicesManagerDosingDelegate: ServicesManagerDosingDelegate? weak var supportManager: SupportManager? + weak var therapyProfileDelegate: TherapyProfileDelegate? private var services = [Service]() @@ -361,6 +362,83 @@ extension ServicesManager: ServiceDelegate { } } + func switchRemoteTherapyProfile(name: String, expectedFingerprint: String) async throws { + do { + try await availableTherapyProfileDelegate().activateTherapyProfile(named: name, expectedFingerprint: expectedFingerprint, trigger: .remote) + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Profile '%1$@' activated", comment: "Remote profile switch notification (1: profile name)"), name), category: .remoteProfile) + } catch { + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Profile '%1$@' not activated: %2$@", comment: "Remote profile switch failure notification (1: profile name, 2: reason)"), name, error.localizedDescription), category: .remoteProfileFailure) + throw error + } + await remoteDataServicesManager.performUpload(for: .settings) + } + + func saveRemoteTherapyProfile(_ profile: TherapyProfile, originalName: String?, expectedFingerprint: String?) async throws { + do { + try await availableTherapyProfileDelegate().saveTherapyProfile(profile, originalName: originalName, expectedFingerprint: expectedFingerprint) + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Profile '%1$@' saved", comment: "Remote profile save notification (1: profile name)"), profile.name), category: .remoteProfile) + } catch { + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Profile '%1$@' not saved: %2$@", comment: "Remote profile save failure notification (1: profile name, 2: reason)"), profile.name, error.localizedDescription), category: .remoteProfileFailure) + throw error + } + await remoteDataServicesManager.performUpload(for: .settings) + } + + func deleteRemoteTherapyProfile(name: String, expectedFingerprint: String) async throws { + do { + let delegate = try availableTherapyProfileDelegate() + let removedSwitches = delegate.scheduledProfileSwitches.filter { $0.profileName == name }.count + try await delegate.deleteTherapyProfile(named: name, expectedFingerprint: expectedFingerprint) + var message = String(format: NSLocalizedString("Profile '%1$@' deleted", comment: "Remote profile delete notification (1: profile name)"), name) + if removedSwitches > 0 { + message += ", " + String(format: NSLocalizedString("%1$d scheduled switches removed", comment: "Remote profile delete notification: scheduled switches to the deleted profile were removed (1: number of scheduled switches)"), removedSwitches) + } + NotificationManager.sendRemoteTherapyProfileNotification(message: message, category: .remoteProfile) + } catch { + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Profile '%1$@' not deleted: %2$@", comment: "Remote profile delete failure notification (1: profile name, 2: reason)"), name, error.localizedDescription), category: .remoteProfileFailure) + throw error + } + await remoteDataServicesManager.performUpload(for: .settings) + } + + func saveRemoteScheduledProfileSwitches(_ entries: [ScheduledProfileSwitch], expectedFingerprint: String) async throws { + do { + try await availableTherapyProfileDelegate().saveScheduledProfileSwitches(entries, expectedFingerprint: expectedFingerprint) + let descriptions = ScheduledProfileSwitches.sortedAsWeek(entries).map { entry in + entry.paused + ? String(format: NSLocalizedString("%1$@ (paused)", comment: "A paused scheduled switch in a notification (1: scheduled switch)"), entry.localizedDescription) + : entry.localizedDescription + } + let message = descriptions.isEmpty + ? NSLocalizedString("All scheduled switches were removed remotely", comment: "Remote scheduled switches notification when the list was cleared") + : String(format: NSLocalizedString("Scheduled switches changed remotely: %1$@", comment: "Remote scheduled switches notification (1: the scheduled switches)"), descriptions.joined(separator: ", ")) + NotificationManager.sendRemoteTherapyProfileNotification(message: message, category: .remoteProfile) + } catch { + NotificationManager.sendRemoteTherapyProfileNotification(message: String(format: NSLocalizedString("Scheduled switches not saved: %1$@", comment: "Remote scheduled switches failure notification (1: reason)"), error.localizedDescription), category: .remoteProfileFailure) + throw error + } + await remoteDataServicesManager.performUpload(for: .settings) + } + + private func availableTherapyProfileDelegate() throws -> TherapyProfileDelegate { + guard let therapyProfileDelegate else { + throw TherapyProfileActionError.unavailable + } + return therapyProfileDelegate + } + + enum TherapyProfileActionError: LocalizedError { + + case unavailable + + var errorDescription: String? { + switch self { + case .unavailable: + return NSLocalizedString("Profiles are not available", comment: "Therapy profile action error description: no profile handler.") + } + } + } + enum BolusActionError: LocalizedError { case invalidBolus diff --git a/Loop/Managers/SettingsManager.swift b/Loop/Managers/SettingsManager.swift index caab4dff6b..01066e54ae 100644 --- a/Loop/Managers/SettingsManager.swift +++ b/Loop/Managers/SettingsManager.swift @@ -20,6 +20,8 @@ import os.log protocol DeviceStatusProvider { var pumpManagerStatus: PumpManagerStatus? { get } var cgmManagerStatus: CGMManagerStatus? { get } + var pumpSupportedBasalRates: [Double]? { get } + var pumpMaximumBasalScheduleEntryCount: Int? { get } } @MainActor @@ -32,7 +34,12 @@ class SettingsManager { var analyticsServicesManager: AnalyticsServicesManager? - var deviceStatusProvider: DeviceStatusProvider? + /// Settings are stored once this is set, so a stored snapshot knows the pump. + var deviceStatusProvider: DeviceStatusProvider? { + didSet { + storeSettings() + } + } var alertMuter: AlertMuter @@ -42,6 +49,8 @@ class SettingsManager { private var remoteNotificationRegistrationResult: Swift.Result? + private var isStoreWaitingForDevices = false + private var cancellables: Set = [] private let log = OSLog(category: "SettingsManager") @@ -114,13 +123,21 @@ class SettingsManager { maximumBolus: settings.maximumBolus, suspendThreshold: settings.suspendThreshold, automaticDosingStrategy: settings.automaticDosingStrategy, - defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin) + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin, + therapyProfiles: settings.therapyProfiles, + activeProfileName: settings.activeProfileName, + scheduledSwitches: settings.scheduledSwitches, + scheduledSwitchesSettledAt: settings.scheduledSwitchesSettledAt, + activeProfileSwitchTrigger: settings.activeProfileSwitchTrigger) } } private func mergeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil, deviceToken: String? = nil) -> StoredSettings { - let newLoopSettings = newLoopSettings ?? loopSettings + var newLoopSettings = newLoopSettings ?? loopSettings + if let unknownName = newLoopSettings.synchronizeTherapyProfiles() { + log.error("Active profile %{public}@ was not stored; it is stored again with the live settings", unknownName) + } let newNotificationSettings = notificationSettings ?? settingsStore?.latestSettings?.notificationSettings return StoredSettings(date: Date(), @@ -142,7 +159,14 @@ class SettingsManager { cgmDevice: deviceStatusProvider?.cgmManagerStatus?.device, pumpDevice: deviceStatusProvider?.pumpManagerStatus?.device, bloodGlucoseUnit: displayGlucosePreference?.unit, - automaticDosingStrategy: newLoopSettings.automaticDosingStrategy) + automaticDosingStrategy: newLoopSettings.automaticDosingStrategy, + pumpSupportedBasalRates: deviceStatusProvider?.pumpSupportedBasalRates, + pumpMaximumBasalScheduleEntryCount: deviceStatusProvider?.pumpMaximumBasalScheduleEntryCount, + therapyProfiles: newLoopSettings.therapyProfiles, + activeProfileName: newLoopSettings.activeProfileName, + scheduledSwitches: newLoopSettings.scheduledSwitches, + scheduledSwitchesSettledAt: newLoopSettings.scheduledSwitchesSettledAt, + activeProfileSwitchTrigger: newLoopSettings.activeProfileSwitchTrigger) } func storeSettings(newLoopSettings: LoopSettings? = nil, notificationSettings: NotificationSettings? = nil) { @@ -155,7 +179,7 @@ class SettingsManager { let mergedSettings = mergeSettings(newLoopSettings: newLoopSettings, notificationSettings: notificationSettings, deviceToken: deviceTokenStr) - guard settings != mergedSettings else { + guard settings != mergedSettings || isStoreWaitingForDevices else { // Skipping unchanged settings store return } @@ -167,6 +191,12 @@ class SettingsManager { return } + guard deviceStatusProvider != nil else { + isStoreWaitingForDevices = true + return + } + isStoreWaitingForDevices = false + if settings.insulinSensitivitySchedule == nil { log.default("Saving settings with no ISF schedule.") } @@ -193,6 +223,7 @@ class SettingsManager { settings.carbRatioSchedule?.timeZone = timeZone settings.insulinSensitivitySchedule?.timeZone = timeZone settings.glucoseTargetRangeSchedule?.timeZone = timeZone + settings.therapyProfiles = settings.therapyProfiles.map { $0.withTimeZone(timeZone) } } } @@ -313,7 +344,10 @@ extension SettingsManager { insulinSensitivitySchedule: settings.insulinSensitivitySchedule, carbRatioSchedule: settings.carbRatioSchedule, basalRateSchedule: settings.basalRateSchedule, - defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin + defaultRapidActingModel: settings.defaultRapidActingModel?.presetForRapidActingInsulin, + therapyProfiles: settings.resolvedTherapyProfiles, + activeProfileName: settings.activeProfileName, + scheduledSwitches: settings.scheduledSwitches ) } @@ -329,6 +363,15 @@ extension SettingsManager { settings.maximumBolus = newValue.maximumBolus settings.maximumBasalRatePerHour = newValue.maximumBasalRatePerHour settings.overridePresets = newValue.overridePresets ?? [] + if let therapyProfiles = newValue.therapyProfiles { + settings.therapyProfiles = therapyProfiles + settings.activeProfileName = newValue.activeProfileName + } + // Only starts after the list is set run. + if let scheduledSwitches = newValue.scheduledSwitches, scheduledSwitches != settings.scheduledSwitches { + settings.scheduledSwitches = scheduledSwitches + settings.scheduledSwitchesSettledAt = Date() + } } } } diff --git a/Loop/Models/OutcomesPackage.swift b/Loop/Models/OutcomesPackage.swift index 43fd684c82..cd32fb94ef 100644 --- a/Loop/Models/OutcomesPackage.swift +++ b/Loop/Models/OutcomesPackage.swift @@ -272,7 +272,7 @@ extension SetupChange.Item { /// Stable, unlocalized name for the shared manifest. var identifier: String { switch self { - case .therapySetting(let setting): + case .therapySetting(let setting, _): switch setting { case .glucoseTargetRange: return "glucoseTargetRange" case .preMealCorrectionRangeOverride: return "preMealCorrectionRange" @@ -283,6 +283,8 @@ extension SetupChange.Item { case .insulinSensitivity: return "insulinSensitivity" case .none: return "none" } + case .profileSwitch: + return "profileSwitch" case .insulinType: return "insulinType" case .insulinModel: diff --git a/Loop/Models/SetupChange.swift b/Loop/Models/SetupChange.swift index 8cce4340ce..4ef65e8753 100644 --- a/Loop/Models/SetupChange.swift +++ b/Loop/Models/SetupChange.swift @@ -12,7 +12,10 @@ import LoopAlgorithm /// A change to the settings that shape dosing, used to tell how long a setup ran. struct SetupChange: Equatable { enum Item: Equatable { - case therapySetting(TherapySetting) + /// A therapy setting changed; `profile` names the active profile when profiles other than Default exist. + case therapySetting(TherapySetting, profile: String? = nil) + /// Another profile became active. + case profileSwitch(to: String, from: String) case insulinType case insulinModel case dosingStrategy @@ -95,18 +98,32 @@ struct SetupChange: Equatable { static func differences(from a: StoredSettings, to b: StoredSettings) -> [Item] { var items: [Item] = [] - if a.basalRateSchedule?.items != b.basalRateSchedule?.items { - items.append(.therapySetting(.basalRate)) - } - if !sameSchedule(a.insulinSensitivitySchedule, b.insulinSensitivitySchedule) { - items.append(.therapySetting(.insulinSensitivity)) - } - if !sameSchedule(a.carbRatioSchedule, b.carbRatioSchedule) { - items.append(.therapySetting(.carbRatio)) - } - if !sameRanges(a.glucoseTargetRangeSchedule?.quantityRanges.map(\.value), b.glucoseTargetRangeSchedule?.quantityRanges.map(\.value)) - || a.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) != b.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) { - items.append(.therapySetting(.glucoseTargetRange)) + let previousProfile = TherapyProfiles.activeName(a.activeProfileName) + let profile = TherapyProfiles.activeName(b.activeProfileName) + // The active profile cannot be deleted, so a previous profile missing from the new settings was renamed + if previousProfile != profile, b.resolvedTherapyProfiles.contains(where: { $0.name == previousProfile }) { + // The schedules changed with the switch, to the values of the profile switched to. A scheduled switch + // between two stored profiles repeats a setup that is already in place. + let isScheduledSwitchBetweenStoredProfiles = b.activeProfileSwitchTrigger == .scheduled + && a.resolvedTherapyProfiles.contains(where: { $0.name == profile }) + if !isScheduledSwitchBetweenStoredProfiles { + items.append(.profileSwitch(to: profile, from: previousProfile)) + } + } else { + let editedProfile = b.therapyProfiles.isEmpty ? nil : profile + if a.basalRateSchedule?.items != b.basalRateSchedule?.items { + items.append(.therapySetting(.basalRate, profile: editedProfile)) + } + if !sameSchedule(a.insulinSensitivitySchedule, b.insulinSensitivitySchedule) { + items.append(.therapySetting(.insulinSensitivity, profile: editedProfile)) + } + if !sameSchedule(a.carbRatioSchedule, b.carbRatioSchedule) { + items.append(.therapySetting(.carbRatio, profile: editedProfile)) + } + if !sameRanges(a.glucoseTargetRangeSchedule?.quantityRanges.map(\.value), b.glucoseTargetRangeSchedule?.quantityRanges.map(\.value)) + || a.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) != b.glucoseTargetRangeSchedule?.quantityRanges.map(\.startTime) { + items.append(.therapySetting(.glucoseTargetRange, profile: editedProfile)) + } } if !sameRanges(a.preMealTargetRange.map { [$0] }, b.preMealTargetRange.map { [$0] }) { items.append(.therapySetting(.preMealCorrectionRangeOverride)) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 9991db642c..9d26b036d1 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -412,6 +412,33 @@ final class StatusTableViewController: LoopChartsTableViewController { return max(0, window.edgeInsets(for: .safeArea(cornerAdaptation: .vertical)).top - window.safeAreaInsets.top) } + /// The active profile's name, once profiles other than Default exist, and until when it stays active. + private var activeProfileName: String? { + let loopSettings = settingsManager.loopSettings + guard !landscapeMode, loopSettings.therapyProfiles.count > 1 || loopSettings.activeProfileName != nil else { + return nil + } + var label = String(format: NSLocalizedString("Profile: %1$@", comment: "Status screen label naming the active therapy profile (1: profile name)"), TherapyProfiles.activeName(loopSettings.activeProfileName)) + let scheduledSwitches = loopSettings.scheduledSwitches + let pausedCount = scheduledSwitches.filter(\.paused).count + if !scheduledSwitches.isEmpty, pausedCount == scheduledSwitches.count { + label += " · " + NSLocalizedString("all starts paused", comment: "Status screen: every scheduled profile start is paused") + return label + } + if let next = deviceManager.nextScheduledProfileSwitch(after: loopSettings.scheduledSwitchesSettledAt ?? Date()) { + label += " · " + String(format: NSLocalizedString("until %1$@ starts %2$@ %3$@", comment: "Status screen: the next scheduled profile start (1: profile name, 2: short weekday, 3: time)"), + next.entry.profileName, ScheduledProfileSwitches.weekdayName(next.entry.weekday, short: true), ScheduledProfileSwitches.localizedTime(next.entry.time)) + } + if pausedCount == 1 { + label += " · " + NSLocalizedString("1 start paused", comment: "Status screen: one scheduled profile start is paused") + } else if pausedCount > 1 { + label += " · " + String(format: NSLocalizedString("%1$d starts paused", comment: "Status screen: scheduled profile starts are paused (1: number of paused starts)"), pausedCount) + } + return label + } + + private var displayedActiveProfileName: String? + private var shouldShowStatus: Bool { return !landscapeMode && statusRowMode.hasRow } @@ -718,6 +745,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case presets case alertWarning case hud + case activeProfile case status case charts } @@ -870,6 +898,9 @@ final class StatusTableViewController: LoopChartsTableViewController { landscapeMode = traitCollection.verticalSizeClass == .compact } + let oldActiveProfileName = displayedActiveProfileName + displayedActiveProfileName = activeProfileName + let presetsIsVisible = self.shouldShowPresets let hudIsVisible = self.shouldShowHUD let statusIsVisible = self.shouldShowStatus @@ -899,6 +930,18 @@ final class StatusTableViewController: LoopChartsTableViewController { break } + let activeProfileIndexPath = IndexPath(row: 0, section: Section.activeProfile.rawValue) + switch (oldActiveProfileName, displayedActiveProfileName) { + case (nil, .some): + tableView.insertRows(at: [activeProfileIndexPath], with: animated ? .top : .none) + case (.some, nil): + tableView.deleteRows(at: [activeProfileIndexPath], with: animated ? .fade : .none) + case (.some(let oldName), .some(let newName)) where oldName != newName: + tableView.reloadRows(at: [activeProfileIndexPath], with: animated ? .fade : .none) + default: + break + } + let statusIndexPath = IndexPath(row: StatusRow.status.rawValue, section: Section.status.rawValue) switch (statusWasVisible, statusIsVisible) { @@ -979,6 +1022,8 @@ final class StatusTableViewController: LoopChartsTableViewController { return shouldShowBannerWarning ? 1 : 0 case .hud: return shouldShowHUD ? 1 : 0 + case .activeProfile: + return displayedActiveProfileName != nil ? 1 : 0 case .charts: return ChartRow.allCases.count case .status: @@ -1067,6 +1112,26 @@ final class StatusTableViewController: LoopChartsTableViewController { hudView = cell.hudView cell.hudView.loopCompletionHUD.loopStatusColors = .loopStatus + return cell + case .activeProfile: + let cell = UITableViewCell() + let label = displayedActiveProfileName ?? "" + cell.contentConfiguration = UIHostingConfiguration { + HStack(spacing: 6) { + Image(systemName: "person.crop.circle") + Text(label) + .lineLimit(2) + .multilineTextAlignment(.center) + } + .font(.footnote.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + .accessibilityIdentifier("label_ActiveProfile") + } + .margins(.vertical, 4) + cell.backgroundColor = .secondarySystemBackground + cell.selectionStyle = .none return cell case .charts: let cell = tableView.dequeueReusableCell(withIdentifier: ChartTableViewCell.className, for: indexPath) as! ChartTableViewCell @@ -1274,7 +1339,7 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setSubtitleLabel(label: nil) } } - case .presets, .hud, .status, .alertWarning: + case .presets, .hud, .activeProfile, .status, .alertWarning: break } } @@ -1299,7 +1364,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } case .alertWarning: return UITableView.automaticDimension - case .presets, .hud, .status: + case .presets, .hud, .activeProfile, .status: return UITableView.automaticDimension } } @@ -1316,7 +1381,7 @@ final class StatusTableViewController: LoopChartsTableViewController { tableView.deselectRow(at: indexPath, animated: true) presentUnmuteAlertConfirmation() } - case .hud: + case .hud, .activeProfile: break case .status: switch StatusRow(rawValue: indexPath.row)! { diff --git a/Loop/View Models/SettingsViewModel.swift b/Loop/View Models/SettingsViewModel.swift index 626ae21e28..e20a7ef0ab 100644 --- a/Loop/View Models/SettingsViewModel.swift +++ b/Loop/View Models/SettingsViewModel.swift @@ -76,6 +76,7 @@ class SettingsViewModel { let therapySettings: () -> TherapySettings var isOnboardingComplete: Bool let therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate? + weak var therapyProfileDelegate: TherapyProfileDelegate? let presetHistory: TemporaryScheduleOverrideHistory private(set) var automaticDosingEnabled: Bool { @@ -157,6 +158,7 @@ class SettingsViewModel { availableSupports: [SupportUI], isOnboardingComplete: Bool, therapySettingsViewModelDelegate: TherapySettingsViewModelDelegate?, + therapyProfileDelegate: TherapyProfileDelegate?, presetHistory: TemporaryScheduleOverrideHistory, deliveryDelegate: DeliveryDelegate?, deviceManager: DeviceDataManager?, @@ -176,6 +178,7 @@ class SettingsViewModel { self.availableSupports = availableSupports self.isOnboardingComplete = isOnboardingComplete self.therapySettingsViewModelDelegate = therapySettingsViewModelDelegate + self.therapyProfileDelegate = therapyProfileDelegate self.presetHistory = presetHistory self.deliveryDelegate = deliveryDelegate self.deviceManager = deviceManager @@ -256,6 +259,7 @@ extension SettingsViewModel { availableSupports: [], isOnboardingComplete: false, therapySettingsViewModelDelegate: nil, + therapyProfileDelegate: nil, presetHistory: TemporaryScheduleOverrideHistory(), deliveryDelegate: nil, deviceManager: nil diff --git a/Loop/Views/CurrentSetupView.swift b/Loop/Views/CurrentSetupView.swift index 587cb9a6bb..d28377af9c 100644 --- a/Loop/Views/CurrentSetupView.swift +++ b/Loop/Views/CurrentSetupView.swift @@ -248,8 +248,12 @@ extension SetupChange { extension SetupChange.Item { var title: String { switch self { - case .therapySetting(let setting): + case .therapySetting(let setting, nil): return setting.title + case .therapySetting(let setting, let profile?): + return String(format: NSLocalizedString("%1$@ of profile %2$@", comment: "Setup change item for a therapy setting of a therapy profile (1: setting name, 2: profile name)"), setting.title, profile) + case .profileSwitch(let profile, let previousProfile): + return String(format: NSLocalizedString("Switched to profile %1$@ (from %2$@)", comment: "Setup change item for a switch of therapy profile (1: profile switched to, 2: profile switched from)"), profile, previousProfile) case .insulinType: return NSLocalizedString("Insulin Type", comment: "Setup change item for the insulin type") case .insulinModel: diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index 70f6d26b4e..a7656f7ae4 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -55,6 +55,7 @@ struct SettingsView: View { case favoriteFoods case presets + case profiles } } @@ -156,6 +157,10 @@ struct SettingsView: View { } case .favoriteFoods: FavoriteFoodsView(insightsDelegate: viewModel.favoriteFoodInsightsDelegate) + case .profiles: + TherapyProfilesView(viewModel: TherapyProfilesViewModel(profileDelegate: viewModel.therapyProfileDelegate, + therapySettingsDelegate: viewModel.therapySettingsViewModelDelegate, + therapySettings: viewModel.therapySettings)) } } .environmentObject(displayGlucosePreference) @@ -464,6 +469,13 @@ extension SettingsView { .accessibilityIdentifier("button_TherapySettings") } + LargeButton(action: { sheet = .profiles }, + includeArrow: true, + imageView: Image(systemName: "person.2.crop.square.stack").font(.system(size: 28, weight: .semibold)).foregroundColor(.accentColor), + label: NSLocalizedString("Profiles", comment: "Title text for button to Profiles"), + descriptiveText: String(format: NSLocalizedString("Active: %1$@", comment: "Descriptive text for Profiles (1: active profile name)"), TherapyProfiles.activeName(viewModel.therapySettings().activeProfileName))) + .accessibilityIdentifier("button_Profiles") + ForEach(pluginMenuItems.filter {$0.section == .configuration}) { item in item.view } diff --git a/LoopCore/LoopSettings.swift b/LoopCore/LoopSettings.swift index 43d1afa93a..f702452e5c 100644 --- a/LoopCore/LoopSettings.swift +++ b/LoopCore/LoopSettings.swift @@ -43,7 +43,22 @@ public struct LoopSettings: Equatable { public var automaticDosingStrategy: AutomaticDosingStrategy = .tempBasalOnly public var defaultRapidActingModel: ExponentialInsulinModelPreset? - + + /// Every profile with Default first, or empty when Default is the only one. + public var therapyProfiles: [TherapyProfile] = [] + + /// The profile the live schedules belong to; nil means Default. + public var activeProfileName: String? + + /// The weekly profile starts. + public var scheduledSwitches: [ScheduledProfileSwitch] = [] + + /// Scheduled starts up to this instant are settled: run, superseded or skipped. + public var scheduledSwitchesSettledAt: Date? + + /// What made the active profile change last. + public var activeProfileSwitchTrigger: TherapyProfileSwitchTrigger? + public var glucoseUnit: LoopUnit? { return glucoseTargetRangeSchedule?.unit } @@ -60,7 +75,12 @@ public struct LoopSettings: Equatable { maximumBolus: Double? = nil, suspendThreshold: GlucoseThreshold? = nil, automaticDosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, - defaultRapidActingModel: ExponentialInsulinModelPreset? = nil + defaultRapidActingModel: ExponentialInsulinModelPreset? = nil, + therapyProfiles: [TherapyProfile] = [], + activeProfileName: String? = nil, + scheduledSwitches: [ScheduledProfileSwitch] = [], + scheduledSwitchesSettledAt: Date? = nil, + activeProfileSwitchTrigger: TherapyProfileSwitchTrigger? = nil ) { self.dosingEnabled = dosingEnabled self.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule @@ -74,6 +94,68 @@ public struct LoopSettings: Equatable { self.suspendThreshold = suspendThreshold self.automaticDosingStrategy = automaticDosingStrategy self.defaultRapidActingModel = defaultRapidActingModel + self.therapyProfiles = therapyProfiles + self.activeProfileName = activeProfileName + self.scheduledSwitches = scheduledSwitches + self.scheduledSwitchesSettledAt = scheduledSwitchesSettledAt + self.activeProfileSwitchTrigger = activeProfileSwitchTrigger + } + + /// The live schedules as a profile named after the active profile, or nil when any schedule is missing. + public var liveTherapyProfile: TherapyProfile? { + TherapyProfile(name: TherapyProfiles.activeName(activeProfileName), + glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + carbRatioSchedule: carbRatioSchedule, + basalRateSchedule: basalRateSchedule, + insulinSensitivitySchedule: insulinSensitivitySchedule) + } + + /// Every profile with Default first. Before any other profile exists this is Default alone. + public var resolvedTherapyProfiles: [TherapyProfile] { + TherapyProfiles.resolved(therapyProfiles, live: TherapyProfiles.activeName(activeProfileName) == TherapyProfile.defaultName ? liveTherapyProfile : nil) + } + + /// Writes the live schedules to the active profile, stores Default as nil and drops a list holding Default alone, + /// so the stored profiles always agree with the schedules Loop doses with. An active name that is not stored, for + /// example a profile whose stored values could not be read, is recreated from the live schedules and returned. + /// Default keeps its own values, and is taken from the live schedules only when it is not stored either. Without + /// live schedules nothing changes and nil is returned. + @discardableResult + public mutating func synchronizeTherapyProfiles() -> String? { + var unknownName: String? + if let name = activeProfileName, name != TherapyProfile.defaultName, !therapyProfiles.contains(where: { $0.name == name }) { + guard let live = liveTherapyProfile else { + return nil + } + if !therapyProfiles.contains(where: \.isDefault) { + var defaultProfile = live + defaultProfile.name = TherapyProfile.defaultName + therapyProfiles.insert(defaultProfile, at: 0) + } + therapyProfiles.append(live) + unknownName = name + } + if activeProfileName == TherapyProfile.defaultName { + activeProfileName = nil + } + var profiles = TherapyProfiles.synchronizingActive(therapyProfiles, activeProfileName: activeProfileName, live: liveTherapyProfile) + if profiles.count == 1, profiles[0].isDefault, activeProfileName == nil { + profiles = [] + } + therapyProfiles = profiles + return unknownName + } + + /// Sets the live schedules to `profile`'s values, keeping the live glucose unit and time zone. + public mutating func applyTherapyProfileSchedules(_ profile: TherapyProfile) { + var applied = profile.withGlucoseUnit(glucoseTargetRangeSchedule?.unit ?? profile.glucoseTargetRangeSchedule.unit) + if let timeZone = basalRateSchedule?.timeZone { + applied = applied.withTimeZone(timeZone) + } + glucoseTargetRangeSchedule = applied.glucoseTargetRangeSchedule + carbRatioSchedule = applied.carbRatioSchedule + basalRateSchedule = applied.basalRateSchedule + insulinSensitivitySchedule = applied.insulinSensitivitySchedule } } diff --git a/LoopTests/Managers/DeviceDataManagerScheduledProfileSwitchTests.swift b/LoopTests/Managers/DeviceDataManagerScheduledProfileSwitchTests.swift new file mode 100644 index 0000000000..062fd04f61 --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerScheduledProfileSwitchTests.swift @@ -0,0 +1,330 @@ +// +// DeviceDataManagerScheduledProfileSwitchTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopCore +@testable import Loop + +@MainActor +final class RecordingAlertIssuer: AlertIssuer { + private(set) var issued: [LoopKit.Alert] = [] + private(set) var retracted: [LoopKit.Alert.Identifier] = [] + + func issueAlert(_ alert: LoopKit.Alert) async { + issued.append(alert) + } + + func retractAlert(identifier: LoopKit.Alert.Identifier) async { + retracted.append(identifier) + } + + var pending: [LoopKit.Alert] { + issued.filter { $0.identifier.alertIdentifier == "pending" } + } + + var failures: [LoopKit.Alert] { + issued.filter { $0.identifier.alertIdentifier != "pending" } + } +} + +extension DeviceDataManagerTests { + + private var scheduleCalendar: Calendar { + var calendar = Calendar(identifier: .gregorian) + // The schedules follow the pump's time zone. + calendar.timeZone = pumpManager.status.timeZone + return calendar + } + + /// A start time two hours before the tests run, on a whole minute, so a switch made by hand is after it. + private static let occurrenceTime = Date(timeIntervalSinceReferenceDate: (Date().timeIntervalSinceReferenceDate / 60).rounded(.down) * 60 - .hours(2)) + + private var occurrence: Date { + Self.occurrenceTime + } + + private func start(_ profileName: String, at date: Date, id: String = UUID().uuidString, paused: Bool = false) -> ScheduledProfileSwitch { + let components = scheduleCalendar.dateComponents([.weekday, .hour, .minute], from: date) + return ScheduledProfileSwitch(id: id, profileName: profileName, weekday: components.weekday!, time: TimeInterval(components.hour! * 3600 + components.minute! * 60), paused: paused) + } + + /// Default active, a stored Sport profile, `entries` saved and settled at `settledAt`. + private func setUpSchedule(_ entries: (Date) -> [ScheduledProfileSwitch], settledAt: Date) async throws -> RecordingAlertIssuer { + pumpManager.supportedBasalRates = (0...600).map { (Double($0) * 5).rounded() / 100 } + settingsManager.therapySettings = TherapySettings.mockTherapySettings + settingsManager.setScheduleTimeZone(pumpManager.status.timeZone) + let live = settingsManager.loopSettings.liveTherapyProfile! + var sport = live + sport.name = "Sport" + sport.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.5)], timeZone: live.basalRateSchedule.timeZone)! + try await deviceDataManager.saveTherapyProfile(sport, originalName: nil) + let issuer = RecordingAlertIssuer() + deviceDataManager.scheduledProfileSwitchAlertIssuer = issuer + try await deviceDataManager.saveScheduledProfileSwitches(entries(occurrence), expectedFingerprint: nil) + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitchesSettledAt = settledAt + } + return issuer + } + + private func storedSettingsCount() async throws -> Int { + try await settingsManager.settingsStore!.getStoredSettings(start: .distantPast).count + } + + func testDueStartSwitchesWithTheScheduledTriggerAndSettlesAtItsTime() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let pendingBefore = issuer.pending.count + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(3))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + XCTAssertEqual(pumpManager.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(settingsManager.loopSettings.activeProfileSwitchTrigger, .scheduled) + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence) + XCTAssertEqual(settingsManager.settings.activeProfileSwitchTrigger, .scheduled) + XCTAssertTrue(issuer.failures.isEmpty) + // The pending alert moves to the next start, a week later. + XCTAssertEqual(issuer.retracted.last?.alertIdentifier, "pending") + XCTAssertEqual(issuer.pending.count, pendingBefore, "The only start makes Sport active, so nothing is pending while Sport is") + } + + func testStartWhoseProfileIsActiveIsSettledWithoutASwitch() async throws { + _ = try await setUpSchedule({ [self.start("Default", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let before = settingsManager.loopSettings + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(3))) + + XCTAssertNil(pumpManager.basalRateSchedule) + XCTAssertEqual(settingsManager.loopSettings.activeProfileName, before.activeProfileName) + XCTAssertNil(settingsManager.loopSettings.activeProfileSwitchTrigger) + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence.addingTimeInterval(.minutes(3))) + } + + func testManualSwitchAfterTheStartHolds() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + try await deviceDataManager.activateTherapyProfile(named: "Default") + XCTAssertEqual(settingsManager.loopSettings.activeProfileSwitchTrigger, .local) + let settledByHand = try XCTUnwrap(settingsManager.loopSettings.scheduledSwitchesSettledAt) + + await deviceDataManager.runDueScheduledProfileSwitch(now: max(settledByHand, occurrence).addingTimeInterval(.minutes(1))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + } + + func testOnlyTheNewestOfTwoMissedStartsRuns() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0.addingTimeInterval(-.hours(2))), self.start("Default", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(3))) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitchesSettledAt = self.occurrence.addingTimeInterval(-.hours(3)) + } + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(10))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence) + } + + func testStartOlderThanTheGraceIsReportedAndNotRun() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let now = occurrence.addingTimeInterval(.hours(25)) + + await deviceDataManager.runDueScheduledProfileSwitch(now: now) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertNil(pumpManager.basalRateSchedule) + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, now) + XCTAssertEqual(issuer.failures.count, 1) + XCTAssertEqual(issuer.failures.first?.interruptionLevel, .timeSensitive) + XCTAssertEqual(issuer.failures.first?.trigger, .immediate) + } + + func testStartWithinTheGraceRunsLate() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.hours(23))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence) + } + + func testPumpFailureIsRetriedAndArmsTheLateAlertWithTheReason() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let settledBefore = settingsManager.loopSettings.scheduledSwitchesSettledAt + pumpManager.basalRateScheduleSyncError = PumpManagerError.communication(nil) + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(5))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, settledBefore) + XCTAssertTrue(issuer.failures.isEmpty) + let pending = try XCTUnwrap(issuer.pending.last) + XCTAssertEqual(pending.trigger, .delayed(interval: .minutes(25))) + XCTAssertTrue(pending.backgroundContent.body.contains(TherapyProfileError.pumpSyncFailed(PumpManagerError.communication(nil).localizedDescription).localizedDescription)) + + // Once delivered, a retry leaves the alert in place. + let retractions = issuer.retracted.count + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(35))) + XCTAssertEqual(issuer.retracted.count, retractions) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + + pumpManager.basalRateScheduleSyncError = nil + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(40))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence) + } + + func testSuspendedPumpIsRetried() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let status = pumpManager.status + pumpManager.status = PumpManagerStatus(timeZone: status.timeZone, device: status.device, pumpBatteryChargeRemaining: nil, + basalDeliveryState: .suspended(occurrence), bolusState: .noBolus, insulinType: status.insulinType) + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(5))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertNil(pumpManager.basalRateSchedule) + + pumpManager.status = status + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(15))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + } + + func testBolusInProgressSkipsTheRunWithoutAPumpCall() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + let status = pumpManager.status + let bolus = DoseEntry(type: .bolus, startDate: occurrence, value: 1, unit: .units, decisionId: nil, automatic: false) + pumpManager.status = PumpManagerStatus(timeZone: status.timeZone, device: status.device, pumpBatteryChargeRemaining: nil, + basalDeliveryState: status.basalDeliveryState, bolusState: .inProgress(bolus), insulinType: status.insulinType) + let settledBefore = settingsManager.loopSettings.scheduledSwitchesSettledAt + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(1))) + + XCTAssertNil(pumpManager.basalRateSchedule) + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, settledBefore) + } + + func testValidationFailureSettlesAndAlertsWithoutRetry() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + pumpManager.supportedBasalRates = [0.05, 0.1] + let now = occurrence.addingTimeInterval(.minutes(2)) + + await deviceDataManager.runDueScheduledProfileSwitch(now: now) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, now) + XCTAssertEqual(issuer.failures.count, 1) + XCTAssertTrue(issuer.failures[0].backgroundContent.body.contains(TherapyProfileError.unsupportedBasalRate(0.5).localizedDescription)) + XCTAssertNil(pumpManager.basalRateSchedule) + } + + func testPausedStartDoesNotRunWhileAnotherStartDoes() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0, paused: true), self.start("Default", at: $0.addingTimeInterval(-.minutes(30)))] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitchesSettledAt = self.occurrence.addingTimeInterval(-.hours(1)) + } + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(5))) + + // The paused Sport start is not newer than the Default start, which runs. + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, occurrence.addingTimeInterval(-.minutes(30))) + XCTAssertFalse(issuer.pending.contains { $0.backgroundContent.body.contains("Sport") }) + XCTAssertTrue(issuer.failures.isEmpty) + } + + func testResumingDoesNotCatchUpAndTheNextStartRuns() async throws { + let paused = start("Sport", at: occurrence, id: "sport", paused: true) + _ = try await setUpSchedule({ _ in [paused] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + + var resumed = paused + resumed.paused = false + try await deviceDataManager.saveScheduledProfileSwitches([resumed], expectedFingerprint: ScheduledProfileSwitches.fingerprint([paused])) + let resumedAt = try XCTUnwrap(settingsManager.loopSettings.scheduledSwitchesSettledAt) + XCTAssertGreaterThan(resumedAt, occurrence) + + await deviceDataManager.runDueScheduledProfileSwitch(now: resumedAt.addingTimeInterval(.minutes(5))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + + let next = try XCTUnwrap(ScheduledProfileSwitches.next(in: [resumed], after: resumedAt, timeZone: scheduleCalendar.timeZone)) + await deviceDataManager.runDueScheduledProfileSwitch(now: next.date.addingTimeInterval(.minutes(1))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + } + + func testManualSwitchWhileStartsArePausedHolds() async throws { + _ = try await setUpSchedule({ [self.start("Default", at: $0, paused: true)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + let settledAt = try XCTUnwrap(settingsManager.loopSettings.scheduledSwitchesSettledAt) + + await deviceDataManager.runDueScheduledProfileSwitch(now: settledAt.addingTimeInterval(.hours(24 * 8))) + + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + } + + func testPendingAlertIsArmedForTheNextStartOfAnotherProfile() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0), self.start("Default", at: $0.addingTimeInterval(.hours(48)))] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + + await deviceDataManager.armScheduledProfileSwitchAlert(now: occurrence.addingTimeInterval(-.hours(1))) + XCTAssertEqual(issuer.pending.last?.trigger, .delayed(interval: .minutes(90))) + XCTAssertEqual(issuer.pending.last?.interruptionLevel, .timeSensitive) + XCTAssertEqual(issuer.pending.last?.categoryIdentifier, LoopNotificationCategory.scheduledProfileSwitch.rawValue) + + await deviceDataManager.runDueScheduledProfileSwitch(now: occurrence.addingTimeInterval(.minutes(1))) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + XCTAssertEqual(issuer.pending.last?.trigger, .delayed(interval: .hours(48) + .minutes(29))) + } + + func testSavingTheListValidatesChecksTheFingerprintAndSettles() async throws { + _ = try await setUpSchedule({ _ in [] }, settledAt: occurrence) + let entry = start("Sport", at: occurrence) + + do { + try await deviceDataManager.saveScheduledProfileSwitches([entry], expectedFingerprint: "0000000000000000") + XCTFail("Saved over a list with another fingerprint") + } catch { + XCTAssertEqual(error as? ScheduledProfileSwitchError, .conflict) + } + do { + try await deviceDataManager.saveScheduledProfileSwitches([start("Gone", at: occurrence)], expectedFingerprint: ScheduledProfileSwitches.fingerprint([])) + XCTFail("Saved a start of a missing profile") + } catch { + XCTAssertEqual(error as? TherapyProfileError, .notFound("Gone")) + } + XCTAssertEqual(deviceDataManager.scheduledProfileSwitches, []) + + try await deviceDataManager.saveScheduledProfileSwitches([entry], expectedFingerprint: ScheduledProfileSwitches.fingerprint([])) + XCTAssertEqual(deviceDataManager.scheduledProfileSwitches, [entry]) + XCTAssertGreaterThan(try XCTUnwrap(settingsManager.loopSettings.scheduledSwitchesSettledAt), occurrence) + } + + func testRenameFollowsAndDeleteRemovesScheduledSwitches() async throws { + _ = try await setUpSchedule({ [self.start("Sport", at: $0, id: "a"), self.start("Default", at: $0.addingTimeInterval(.hours(48)), id: "b")] }, settledAt: occurrence) + var running = try XCTUnwrap(deviceDataManager.therapyProfile(named: "Sport")) + running.name = "Running" + try await deviceDataManager.saveTherapyProfile(running, originalName: "Sport") + XCTAssertEqual(deviceDataManager.scheduledProfileSwitches.map(\.profileName), ["Running", "Default"]) + XCTAssertEqual(deviceDataManager.scheduledProfileSwitches.map(\.id), ["a", "b"]) + + try await deviceDataManager.deleteTherapyProfile(named: "Running") + XCTAssertEqual(deviceDataManager.scheduledProfileSwitches.map(\.id), ["b"]) + } + + func testStartOfAMissingProfileIsReportedAndSettled() async throws { + let issuer = try await setUpSchedule({ [self.start("Sport", at: $0)] }, settledAt: occurrence.addingTimeInterval(-.hours(1))) + settingsManager.mutateLoopSettings { settings in + settings.scheduledSwitches = [self.start("Gone", at: self.occurrence)] + settings.scheduledSwitchesSettledAt = self.occurrence.addingTimeInterval(-.hours(1)) + } + let now = occurrence.addingTimeInterval(.minutes(1)) + + await deviceDataManager.runDueScheduledProfileSwitch(now: now) + + XCTAssertEqual(settingsManager.loopSettings.scheduledSwitchesSettledAt, now) + XCTAssertTrue(issuer.failures.last?.backgroundContent.body.contains(TherapyProfileError.notFound("Gone").localizedDescription) ?? false) + } +} diff --git a/LoopTests/Managers/DeviceDataManagerTherapyProfileTests.swift b/LoopTests/Managers/DeviceDataManagerTherapyProfileTests.swift new file mode 100644 index 0000000000..10a8e44b1d --- /dev/null +++ b/LoopTests/Managers/DeviceDataManagerTherapyProfileTests.swift @@ -0,0 +1,398 @@ +// +// DeviceDataManagerTherapyProfileTests.swift +// LoopTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import LoopKit +import LoopCore +import LoopKitUI +@testable import Loop + +extension DeviceDataManagerTests { + + private func setUpTherapySettings() { + pumpManager.supportedBasalRates = (0...600).map { (Double($0) * 5).rounded() / 100 } + settingsManager.therapySettings = TherapySettings.mockTherapySettings + } + + private func assertThrows(_ expected: TherapyProfileError, file: StaticString = #filePath, line: UInt = #line, _ operation: () async throws -> Void) async { + do { + try await operation() + XCTFail("No error, expected \(expected)", file: file, line: line) + } catch { + XCTAssertEqual(error as? TherapyProfileError, expected, file: file, line: line) + } + } + + private func sportProfile(basal: Double = 0.5) -> TherapyProfile { + let live = settingsManager.loopSettings.liveTherapyProfile! + return TherapyProfile(name: "Sport", + glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 120, maxValue: 130))], timeZone: live.basalRateSchedule.timeZone)!, + carbRatioSchedule: live.carbRatioSchedule, + basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: basal)], timeZone: live.basalRateSchedule.timeZone)!, + insulinSensitivitySchedule: live.insulinSensitivitySchedule) + } + + func testCreatingProfileKeepsDefaultActiveAndPumpUntouched() async throws { + setUpTherapySettings() + let liveBefore = settingsManager.loopSettings.liveTherapyProfile! + + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + + XCTAssertEqual(deviceDataManager.therapyProfiles.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Default") + XCTAssertTrue(settingsManager.loopSettings.liveTherapyProfile!.hasSameValues(as: liveBefore)) + XCTAssertNil(pumpManager.basalRateSchedule) + } + + func testActivatingProfileWritesPumpThenSettings() async throws { + setUpTherapySettings() + let defaultValues = settingsManager.loopSettings.liveTherapyProfile! + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + + try await deviceDataManager.activateTherapyProfile(named: "Sport") + + XCTAssertEqual(pumpManager.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(settingsManager.loopSettings.activeProfileName, "Sport") + XCTAssertEqual(settingsManager.loopSettings.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(settingsManager.loopSettings.glucoseTargetRangeSchedule?.items.map(\.value.minValue), [120]) + XCTAssertEqual(settingsManager.settings.activeProfileName, "Sport") + XCTAssertTrue(deviceDataManager.therapyProfile(named: "Default")!.hasSameValues(as: defaultValues)) + + try await deviceDataManager.activateTherapyProfile(named: "Default") + XCTAssertNil(settingsManager.loopSettings.activeProfileName) + XCTAssertTrue(settingsManager.loopSettings.liveTherapyProfile!.hasSameValues(as: defaultValues)) + } + + func testPumpFailureLeavesSettingsUnchanged() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let before = settingsManager.loopSettings + pumpManager.basalRateScheduleSyncError = PumpManagerError.communication(nil) + + do { + try await deviceDataManager.activateTherapyProfile(named: "Sport") + XCTFail("Activation succeeded although the pump failed") + } catch { + XCTAssertEqual(error as? TherapyProfileError, .pumpSyncFailed(PumpManagerError.communication(nil).localizedDescription)) + } + XCTAssertEqual(settingsManager.loopSettings, before) + + try await deviceDataManager.activateTherapyProfile(named: "Default") + do { + var edited = deviceDataManager.therapyProfile(named: "Default")! + edited.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.7)], timeZone: edited.basalRateSchedule.timeZone)! + try await deviceDataManager.saveTherapyProfile(edited, originalName: "Default") + XCTFail("Saving the active profile succeeded although the pump failed") + } catch { + XCTAssertEqual(error as? TherapyProfileError, .pumpSyncFailed(PumpManagerError.communication(nil).localizedDescription)) + } + XCTAssertEqual(settingsManager.loopSettings, before) + } + + func testSuspendedPumpRefusesBasalChangesAndKeepsOtherEdits() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let status = pumpManager.status + pumpManager.status = PumpManagerStatus(timeZone: status.timeZone, device: status.device, pumpBatteryChargeRemaining: nil, + basalDeliveryState: .suspended(Date()), bolusState: .noBolus, insulinType: status.insulinType) + let before = settingsManager.loopSettings + + await assertThrows(.pumpSuspended) { try await self.deviceDataManager.activateTherapyProfile(named: "Sport") } + var edited = deviceDataManager.therapyProfile(named: "Default")! + edited.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.7)], timeZone: edited.basalRateSchedule.timeZone)! + await assertThrows(.pumpSuspended) { try await self.deviceDataManager.saveTherapyProfile(edited, originalName: "Default") } + let editorRefused = expectation(description: "Editor basal sync refused") + deviceDataManager.syncBasalRateSchedule(items: [RepeatingScheduleValue(startTime: 0, value: 0.7)]) { result in + if case .failure(let error) = result { + XCTAssertEqual(error as? TherapyProfileError, .pumpSuspended) + } else { + XCTFail("Editor basal sync reached a suspended pump") + } + editorRefused.fulfill() + } + await fulfillment(of: [editorRefused], timeout: 2) + XCTAssertFalse(deviceDataManager.therapyProfileChangeInProgress) + XCTAssertEqual(settingsManager.loopSettings, before) + XCTAssertNil(pumpManager.basalRateSchedule) + + // Profiles that do not reach the pump can still be changed. + try await deviceDataManager.saveTherapyProfile(sportProfile(basal: 0.6), originalName: "Sport") + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Sport")?.basalRateSchedule.items.map(\.value), [0.6]) + XCTAssertNil(pumpManager.basalRateSchedule) + } + + func testValidationRejectsBeforePump() async throws { + setUpTherapySettings() + do { + try await deviceDataManager.saveTherapyProfile(sportProfile(basal: 9.0), originalName: nil) + XCTFail("A basal rate above the maximum was saved") + } catch { + XCTAssertEqual(error as? TherapyProfileError, .basalRateAboveMaximum(9.0)) + } + XCTAssertEqual(deviceDataManager.therapyProfiles.map(\.name), ["Default"]) + XCTAssertNil(pumpManager.basalRateSchedule) + } + + func testEditingTherapySettingsWritesThroughToActiveProfile() async throws { + setUpTherapySettings() + let defaultValues = settingsManager.loopSettings.liveTherapyProfile! + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + + var therapySettings = settingsManager.therapySettings + therapySettings.carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 7)], timeZone: therapySettings.carbRatioSchedule!.timeZone) + deviceDataManager.saveCompletion(therapySettings: therapySettings) + + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Sport")?.carbRatioSchedule.items.map(\.value), [7]) + XCTAssertTrue(deviceDataManager.therapyProfile(named: "Default")!.hasSameValues(as: defaultValues)) + XCTAssertEqual(settingsManager.settings.therapyProfiles.first { $0.name == "Sport" }?.carbRatioSchedule.items.map(\.value), [7]) + } + + func testEditingProfileThatIsNotActiveLeavesLiveSettings() async throws { + setUpTherapySettings() + let live = settingsManager.loopSettings + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + + try await deviceDataManager.saveTherapyProfile(sportProfile(basal: 0.6), originalName: "Sport") + + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Sport")?.basalRateSchedule.items.map(\.value), [0.6]) + XCTAssertEqual(settingsManager.loopSettings.basalRateSchedule, live.basalRateSchedule) + XCTAssertNil(pumpManager.basalRateSchedule) + } + + func testFingerprintMismatchRejectsChangeAndDeleteRules() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let before = settingsManager.loopSettings + let sportFingerprint = try XCTUnwrap(sportProfile().fingerprint(unit: .milligramsPerDeciliter)) + + await assertThrows(.conflict) { try await self.deviceDataManager.activateTherapyProfile(named: "Sport", expectedFingerprint: "0000000000000000", trigger: .remote) } + await assertThrows(.conflict) { try await self.deviceDataManager.saveTherapyProfile(self.sportProfile(basal: 0.6), originalName: "Sport", expectedFingerprint: "0000000000000000") } + await assertThrows(.conflict) { try await self.deviceDataManager.deleteTherapyProfile(named: "Sport", expectedFingerprint: "0000000000000000") } + XCTAssertEqual(settingsManager.loopSettings, before) + XCTAssertNil(pumpManager.basalRateSchedule) + + await assertThrows(.cannotDeleteDefault) { try await self.deviceDataManager.deleteTherapyProfile(named: "Default", expectedFingerprint: "0000000000000000") } + try await deviceDataManager.activateTherapyProfile(named: "Sport", expectedFingerprint: sportFingerprint, trigger: .remote) + await assertThrows(.cannotDeleteActive) { try await self.deviceDataManager.deleteTherapyProfile(named: "Sport", expectedFingerprint: "0000000000000000") } + try await deviceDataManager.activateTherapyProfile(named: "Default") + try await deviceDataManager.deleteTherapyProfile(named: "Sport", expectedFingerprint: sportFingerprint) + XCTAssertEqual(deviceDataManager.therapyProfiles.map(\.name), ["Default"]) + XCTAssertEqual(settingsManager.loopSettings.therapyProfiles, []) + } + + // MARK: Editors and profile changes + + func testEditorSaveFromPreviousActiveProfileIsRefused() async throws { + setUpTherapySettings() + let defaultValues = settingsManager.loopSettings.liveTherapyProfile! + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let editorSettings = settingsManager.therapySettings + try await deviceDataManager.activateTherapyProfile(named: "Sport") + let sportValues = settingsManager.loopSettings.liveTherapyProfile! + + XCTAssertThrowsError(try deviceDataManager.validateTherapySettingsEdit(from: editorSettings)) { + XCTAssertEqual($0 as? TherapyProfileError, .activeProfileChangedWhileEditing("Sport")) + } + var edited = editorSettings + edited.carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 7)], timeZone: edited.carbRatioSchedule!.timeZone) + deviceDataManager.saveCompletion(therapySettings: edited) + + XCTAssertEqual(settingsManager.loopSettings.activeProfileName, "Sport") + XCTAssertTrue(settingsManager.loopSettings.liveTherapyProfile!.hasSameValues(as: sportValues)) + XCTAssertTrue(deviceDataManager.therapyProfile(named: "Default")!.hasSameValues(as: defaultValues)) + } + + func testEditorSaveAfterActiveProfileChangedElsewhereIsRefused() async throws { + setUpTherapySettings() + let editorSettings = settingsManager.therapySettings + XCTAssertNoThrow(try deviceDataManager.validateTherapySettingsEdit(from: editorSettings)) + + var remote = deviceDataManager.therapyProfile(named: "Default")! + remote.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.7)], timeZone: remote.basalRateSchedule.timeZone)! + try await deviceDataManager.saveTherapyProfile(remote, originalName: "Default") + + XCTAssertThrowsError(try deviceDataManager.validateTherapySettingsEdit(from: editorSettings)) { + XCTAssertEqual($0 as? TherapyProfileError, .settingsChangedWhileEditing) + } + XCTAssertNoThrow(try deviceDataManager.validateTherapySettingsEdit(from: settingsManager.therapySettings)) + } + + func testEditorBasalSyncHoldsOffProfileChanges() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + pumpManager.holdsBasalRateScheduleSync = true + + let synced = expectation(description: "Editor basal sync finished") + deviceDataManager.syncBasalRateSchedule(items: [RepeatingScheduleValue(startTime: 0, value: 0.9)]) { result in + XCTAssertNotNil(try? result.get()) + synced.fulfill() + } + await assertThrows(.operationInProgress) { try await self.deviceDataManager.activateTherapyProfile(named: "Sport") } + XCTAssertThrowsError(try deviceDataManager.validateTherapySettingsEdit(from: settingsManager.therapySettings)) { + XCTAssertEqual($0 as? TherapyProfileError, .operationInProgress) + } + + pumpManager.holdsBasalRateScheduleSync = false + pumpManager.finishBasalRateScheduleSync() + await fulfillment(of: [synced], timeout: 2) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + XCTAssertEqual(pumpManager.basalRateSchedule?.items.map(\.value), [0.5]) + } + + func testEditorBasalSyncIsRefusedWhileProfileSwitches() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + pumpManager.holdsBasalRateScheduleSync = true + + let switching = Task { try await deviceDataManager.activateTherapyProfile(named: "Sport") } + while !deviceDataManager.therapyProfileChangeInProgress { + await Task.yield() + } + let refused = expectation(description: "Editor basal sync refused") + deviceDataManager.syncBasalRateSchedule(items: [RepeatingScheduleValue(startTime: 0, value: 0.9)]) { result in + if case .failure(let error) = result { + XCTAssertEqual(error as? TherapyProfileError, .operationInProgress) + } else { + XCTFail("Editor basal sync ran during a profile switch") + } + refused.fulfill() + } + await fulfillment(of: [refused], timeout: 2) + + pumpManager.holdsBasalRateScheduleSync = false + while deviceDataManager.therapyProfileChangeInProgress { + pumpManager.finishBasalRateScheduleSync() + await Task.yield() + } + try await switching.value + XCTAssertEqual(pumpManager.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(settingsManager.loopSettings.basalRateSchedule?.items.map(\.value), [0.5]) + } + + func testSecondProfileCommandWhileOneRunsIsRefused() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + pumpManager.holdsBasalRateScheduleSync = true + + let switching = Task { try await deviceDataManager.activateTherapyProfile(named: "Sport") } + while !deviceDataManager.therapyProfileChangeInProgress { + await Task.yield() + } + await assertThrows(.operationInProgress) { try await self.deviceDataManager.activateTherapyProfile(named: "Default") } + await assertThrows(.operationInProgress) { try await self.deviceDataManager.saveTherapyProfile(self.sportProfile(basal: 0.6), originalName: "Sport") } + + pumpManager.holdsBasalRateScheduleSync = false + while deviceDataManager.therapyProfileChangeInProgress { + pumpManager.finishBasalRateScheduleSync() + await Task.yield() + } + try await switching.value + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Sport") + XCTAssertEqual(settingsManager.loopSettings.basalRateSchedule?.items.map(\.value), [0.5]) + } + + func testEditorBasalSaveAfterProfileSwitchIsRefused() async throws { + setUpTherapySettings() + let defaultValues = settingsManager.loopSettings.liveTherapyProfile! + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let editor = TherapySettingsViewModel(therapySettings: settingsManager.therapySettings, delegate: deviceDataManager) + + let synced = expectation(description: "Editor basal sync finished") + var syncedSchedule: BasalRateSchedule? + editor.syncBasalRateSchedule(items: [RepeatingScheduleValue(startTime: 0, value: 0.9)]) { result in + syncedSchedule = try? result.get() + synced.fulfill() + } + await fulfillment(of: [synced], timeout: 2) + try await deviceDataManager.activateTherapyProfile(named: "Sport") + + // The editor saves the synced schedule on the next main queue turn, after the switch finished. + editor.saveBasalRates(basalRates: try XCTUnwrap(syncedSchedule)) + + XCTAssertEqual(editor.saveError as? TherapyProfileError, .activeProfileChangedWhileEditing("Sport")) + XCTAssertEqual(settingsManager.loopSettings.activeProfileName, "Sport") + XCTAssertEqual(settingsManager.loopSettings.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(pumpManager.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertTrue(deviceDataManager.therapyProfile(named: "Default")!.hasSameValues(as: defaultValues)) + } + + // MARK: Time zones + + private func setPumpTimeZone(_ timeZone: TimeZone) { + let status = pumpManager.status + pumpManager.status = PumpManagerStatus(timeZone: timeZone, device: status.device, pumpBatteryChargeRemaining: status.pumpBatteryChargeRemaining, + basalDeliveryState: status.basalDeliveryState, bolusState: status.bolusState, insulinType: status.insulinType) + settingsManager.setScheduleTimeZone(timeZone) + } + + func testProfilesFollowThePumpTimeZoneWithoutMovingStartTimes() async throws { + setUpTherapySettings() + let home = TimeZone(secondsFromGMT: 3600)! + let travel = TimeZone(secondsFromGMT: -5 * 3600)! + setPumpTimeZone(home) + let multiSegment = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.5), + RepeatingScheduleValue(startTime: 7200, value: 0.6), + RepeatingScheduleValue(startTime: 9000, value: 0.7)], timeZone: home)! + var sport = sportProfile() + sport.basalRateSchedule = multiSegment + try await deviceDataManager.saveTherapyProfile(sport, originalName: nil) + + // The pump clock moves to a new time zone (travel, or a daylight saving change synced to the pump). + setPumpTimeZone(travel) + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Sport")?.basalRateSchedule.timeZone, travel) + + try await deviceDataManager.activateTherapyProfile(named: "Sport") + let live = settingsManager.loopSettings + XCTAssertEqual(live.basalRateSchedule?.items.map(\.startTime), [0, 7200, 9000]) + XCTAssertEqual([live.basalRateSchedule?.timeZone, live.carbRatioSchedule?.timeZone, live.insulinSensitivitySchedule?.timeZone, live.glucoseTargetRangeSchedule?.timeZone], [travel, travel, travel, travel]) + let localTwoThirty = DateComponents(calendar: Calendar(identifier: .gregorian), timeZone: travel, year: 2026, month: 3, day: 29, hour: 2, minute: 30).date! + XCTAssertEqual(live.basalRateSchedule?.value(at: localTwoThirty), 0.7) + + // A save made in another time zone (a follower's phone) is stored in the live time zone. + var edited = deviceDataManager.therapyProfile(named: "Sport")!.withTimeZone(TimeZone(identifier: "Asia/Tokyo")!) + edited.carbRatioSchedule = CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 9), RepeatingScheduleValue(startTime: 1800, value: 8)], timeZone: edited.carbRatioSchedule.timeZone)! + try await deviceDataManager.saveTherapyProfile(edited, originalName: "Sport") + XCTAssertEqual(settingsManager.loopSettings.carbRatioSchedule?.timeZone, travel) + XCTAssertEqual(settingsManager.loopSettings.carbRatioSchedule?.items.map(\.startTime), [0, 1800]) + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Sport")?.carbRatioSchedule.timeZone, travel) + } + + // MARK: Renames and unknown names + + func testRenameAndCopyWithoutMaximumBasalRate() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + settingsManager.mutateLoopSettings { $0.maximumBasalRatePerHour = nil } + + var renamed = deviceDataManager.therapyProfile(named: "Sport")! + renamed.name = "Running" + try await deviceDataManager.saveTherapyProfile(renamed, originalName: "Sport") + var copy = renamed + copy.name = "Copy" + try await deviceDataManager.saveTherapyProfile(copy, originalName: nil) + XCTAssertEqual(deviceDataManager.therapyProfiles.map(\.name), ["Default", "Running", "Copy"]) + + await assertThrows(.maximumBasalRateNotSet) { try await self.deviceDataManager.saveTherapyProfile(self.sportProfile(basal: 0.6), originalName: nil) } + await assertThrows(.maximumBasalRateNotSet) { try await self.deviceDataManager.activateTherapyProfile(named: "Running") } + } + + func testUnknownActiveProfileNameIsStoredAgainWithTheLiveSettings() async throws { + setUpTherapySettings() + try await deviceDataManager.saveTherapyProfile(sportProfile(), originalName: nil) + let live = settingsManager.loopSettings.liveTherapyProfile! + let storedDefault = deviceDataManager.therapyProfile(named: "Default")! + + settingsManager.mutateLoopSettings { $0.activeProfileName = "Missing" } + + XCTAssertEqual(settingsManager.loopSettings.activeProfileName, "Missing") + XCTAssertEqual(deviceDataManager.activeTherapyProfileName, "Missing") + XCTAssertEqual(deviceDataManager.therapyProfiles.map(\.name), ["Default", "Sport", "Missing"]) + XCTAssertTrue(deviceDataManager.therapyProfile(named: "Missing")!.hasSameValues(as: live)) + XCTAssertEqual(deviceDataManager.therapyProfile(named: "Default"), storedDefault) + } +} diff --git a/LoopTests/Managers/SettingsManagerTests.swift b/LoopTests/Managers/SettingsManagerTests.swift index 45611191a8..975749de2d 100644 --- a/LoopTests/Managers/SettingsManagerTests.swift +++ b/LoopTests/Managers/SettingsManagerTests.swift @@ -8,6 +8,7 @@ import XCTest import LoopKit +import LoopCore @testable import Loop @MainActor @@ -31,5 +32,114 @@ final class SettingsManagerTests: XCTestCase { NotificationCenter.default.removeObserver(observer) } + func testTherapySettingsWithProfilesAreKeptWithTheActiveProfile() throws { + let settingsManager = SettingsManager(cacheStore: PersistenceController.mock(), expireAfter: .days(1), alertMuter: AlertMuter()) + var therapySettings = TherapySettings.mockTherapySettings + var sport = try XCTUnwrap(TherapyProfile(name: "Sport", + glucoseTargetRangeSchedule: therapySettings.glucoseTargetRangeSchedule, + carbRatioSchedule: therapySettings.carbRatioSchedule, + basalRateSchedule: therapySettings.basalRateSchedule, + insulinSensitivitySchedule: therapySettings.insulinSensitivitySchedule)) + var defaultProfile = sport + defaultProfile.name = TherapyProfile.defaultName + sport.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: 0.5)], timeZone: sport.basalRateSchedule.timeZone)! + therapySettings.basalRateSchedule = sport.basalRateSchedule + therapySettings.therapyProfiles = [defaultProfile, sport] + therapySettings.activeProfileName = "Sport" + settingsManager.therapySettings = therapySettings + + let stored = settingsManager.settings + XCTAssertEqual(stored.therapyProfiles.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(stored.activeProfileName, "Sport") + XCTAssertEqual(stored.basalRateSchedule?.items.map(\.value), [0.5]) + XCTAssertEqual(stored.therapyProfiles[1].basalRateSchedule.items.map(\.value), [0.5]) + + therapySettings.therapyProfiles = nil + therapySettings.activeProfileName = nil + settingsManager.therapySettings = therapySettings + + XCTAssertEqual(settingsManager.settings.therapyProfiles.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(settingsManager.settings.activeProfileName, "Sport") + } + + private func loopSettings(basal: Double) -> LoopSettings { + let therapySettings = TherapySettings.mockTherapySettings + var settings = LoopSettings() + settings.glucoseTargetRangeSchedule = therapySettings.glucoseTargetRangeSchedule + settings.carbRatioSchedule = therapySettings.carbRatioSchedule + settings.insulinSensitivitySchedule = therapySettings.insulinSensitivitySchedule + settings.basalRateSchedule = BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: basal)], timeZone: therapySettings.basalRateSchedule?.timeZone) + return settings + } + + private func profile(_ name: String, from settings: LoopSettings) throws -> TherapyProfile { + var profile = try XCTUnwrap(settings.liveTherapyProfile) + profile.name = name + return profile + } + + func testAnActiveProfileThatIsNotStoredIsRecreatedFromTheLiveSettingsAndDefaultKeepsItsValues() throws { + var settings = loopSettings(basal: 0.5) + let defaultProfile = try profile(TherapyProfile.defaultName, from: loopSettings(basal: 1.0)) + let weekend = try profile("Weekend", from: loopSettings(basal: 0.7)) + settings.therapyProfiles = [defaultProfile, weekend] + settings.activeProfileName = "Sport" + + XCTAssertEqual(settings.synchronizeTherapyProfiles(), "Sport") + + XCTAssertEqual(settings.activeProfileName, "Sport") + XCTAssertEqual(settings.therapyProfiles.map(\.name), ["Default", "Weekend", "Sport"]) + XCTAssertEqual(settings.therapyProfiles.map { $0.basalRateSchedule.items.map(\.value) }, [[1.0], [0.7], [0.5]]) + XCTAssertNil(settings.synchronizeTherapyProfiles()) + } + + func testAnActiveProfileThatIsNotStoredIsKeptByNameWithoutLiveSettings() throws { + var settings = LoopSettings() + let defaultProfile = try profile(TherapyProfile.defaultName, from: loopSettings(basal: 1.0)) + settings.therapyProfiles = [defaultProfile] + settings.activeProfileName = "Sport" + + XCTAssertNil(settings.synchronizeTherapyProfiles()) + + XCTAssertEqual(settings.activeProfileName, "Sport") + XCTAssertEqual(settings.therapyProfiles, [defaultProfile]) + } + + func testAnActiveProfileWithNoStoredProfilesRecreatesDefaultAndTheActiveProfile() throws { + var settings = loopSettings(basal: 0.5) + settings.activeProfileName = "Sport" + + XCTAssertEqual(settings.synchronizeTherapyProfiles(), "Sport") + + XCTAssertEqual(settings.activeProfileName, "Sport") + XCTAssertEqual(settings.therapyProfiles.map(\.name), ["Default", "Sport"]) + XCTAssertEqual(settings.therapyProfiles.map { $0.basalRateSchedule.items.map(\.value) }, [[0.5], [0.5]]) + } + + func testSettingsAreStoredOnceTheDevicesAreKnown() async throws { + let settingsManager = SettingsManager(cacheStore: PersistenceController.mock(), expireAfter: .days(1), alertMuter: AlertMuter()) + let settingsStore = try XCTUnwrap(settingsManager.settingsStore) + + settingsManager.remoteNotificationRegistrationDidFinish(.success(Data([0xAB]))) + settingsManager.mutateLoopSettings { $0.maximumBasalRatePerHour = 2.0 } + settingsManager.deviceStatusProvider = DeviceStatusProviderStub() + + var stored: [StoredSettings] = [] + for _ in 0..<20 where stored.isEmpty { + try await Task.sleep(nanoseconds: 100_000_000) + stored = try await settingsStore.getStoredSettings(limit: 10) + } + XCTAssertEqual(stored.count, 1) + XCTAssertEqual(stored.first?.deviceToken, "ab") + XCTAssertEqual(stored.first?.maximumBasalRatePerHour, 2.0) + XCTAssertEqual(stored.first?.pumpSupportedBasalRates, [0.05, 0.1]) + } +} + +private struct DeviceStatusProviderStub: DeviceStatusProvider { + var pumpManagerStatus: PumpManagerStatus? { nil } + var cgmManagerStatus: CGMManagerStatus? { nil } + var pumpSupportedBasalRates: [Double]? { [0.05, 0.1] } + var pumpMaximumBasalScheduleEntryCount: Int? { nil } } diff --git a/LoopTests/Mocks/MockPumpManager.swift b/LoopTests/Mocks/MockPumpManager.swift index 2010d141cb..974831ebe8 100644 --- a/LoopTests/Mocks/MockPumpManager.swift +++ b/LoopTests/Mocks/MockPumpManager.swift @@ -104,7 +104,36 @@ class MockPumpManager: PumpManager { completion(nil) } + /// The last basal schedule written to the pump. + var basalRateSchedule: BasalRateSchedule? + + var basalRateScheduleSyncError: Error? + + /// When true, basal schedule writes wait until `finishBasalRateScheduleSync()`. + var holdsBasalRateScheduleSync = false + private var heldBasalRateScheduleSyncs: [() -> Void] = [] + + func finishBasalRateScheduleSync() { + let held = heldBasalRateScheduleSyncs + heldBasalRateScheduleSyncs = [] + held.forEach { $0() } + } + func syncBasalRateSchedule(items scheduleItems: [RepeatingScheduleValue], completion: @escaping (Result) -> Void) { + let sync = { [self] in + if let basalRateScheduleSyncError { + completion(.failure(basalRateScheduleSyncError)) + return + } + let schedule = BasalRateSchedule(dailyItems: scheduleItems, timeZone: status.timeZone)! + basalRateSchedule = schedule + completion(.success(schedule)) + } + if holdsBasalRateScheduleSync { + heldBasalRateScheduleSyncs.append(sync) + } else { + sync() + } } func syncDeliveryLimits(limits deliveryLimits: DeliveryLimits, completion: @escaping (Result) -> Void) { diff --git a/LoopTests/Models/SetupChangeTests.swift b/LoopTests/Models/SetupChangeTests.swift index 4f39d2d889..23bbd6b465 100644 --- a/LoopTests/Models/SetupChangeTests.swift +++ b/LoopTests/Models/SetupChangeTests.swift @@ -160,4 +160,81 @@ final class SetupChangeTests: XCTestCase { let history = history([settings(at: 5)], [experiments(at: 1, nil), experiments(at: 3, [])]) XCTAssertEqual(history.start, start.addingTimeInterval(.hours(3))) } + + // MARK: Therapy profiles + + private func therapyProfile(_ name: String, basal: Double, isf: Double = 50) -> TherapyProfile { + let timeZone = TimeZone(identifier: "America/Chicago")! + return TherapyProfile(name: name, + glucoseTargetRangeSchedule: GlucoseRangeSchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: DoubleRange(minValue: 100, maxValue: 110))], timeZone: timeZone)!, + carbRatioSchedule: CarbRatioSchedule(unit: .gram, dailyItems: [RepeatingScheduleValue(startTime: 0, value: 10)], timeZone: timeZone)!, + basalRateSchedule: BasalRateSchedule(dailyItems: [RepeatingScheduleValue(startTime: 0, value: basal)], timeZone: timeZone)!, + insulinSensitivitySchedule: InsulinSensitivitySchedule(unit: .milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0, value: isf)], timeZone: timeZone)!) + } + + /// Settings whose live schedules are those of the active profile in `profiles`. + private func settings(at hours: Double, active: String?, profiles: [TherapyProfile]) -> StoredSettings { + let live = profiles.first { $0.name == TherapyProfiles.activeName(active) }! + var settings = settings(at: hours, basal: live.basalRateSchedule.items[0].value, isf: live.insulinSensitivitySchedule.items[0].value) + settings.glucoseTargetRangeSchedule = live.glucoseTargetRangeSchedule + settings.carbRatioSchedule = live.carbRatioSchedule + settings.therapyProfiles = profiles + settings.activeProfileName = active + return settings + } + + func testProfileSwitchIsOneChange() { + let profiles = [therapyProfile("Default", basal: 1.0), therapyProfile("Weekend", basal: 0.7, isf: 60)] + let changes = SetupChange.changes(in: [settings(at: 0, active: nil, profiles: profiles), settings(at: 10, active: "Weekend", profiles: profiles)]) + XCTAssertEqual(changes.map(\.items), [[.profileSwitch(to: "Weekend", from: "Default")]]) + } + + func testScheduledSwitchBetweenStoredProfilesIsNoSetupChange() { + let profiles = [therapyProfile("Default", basal: 1.0), therapyProfile("Weekend", basal: 0.7, isf: 60)] + var scheduled = settings(at: 10, active: "Weekend", profiles: profiles) + scheduled.activeProfileSwitchTrigger = .scheduled + var back = settings(at: 20, active: nil, profiles: profiles) + back.activeProfileSwitchTrigger = .local + let changes = SetupChange.changes(in: [settings(at: 0, active: nil, profiles: profiles), scheduled, back]) + XCTAssertEqual(changes.map(\.items), [[.profileSwitch(to: "Default", from: "Weekend")]]) + } + + func testEditOfActiveProfileNamesTheProfile() { + let before = [therapyProfile("Default", basal: 1.0), therapyProfile("Weekend", basal: 0.7)] + let after = [therapyProfile("Default", basal: 1.0), therapyProfile("Weekend", basal: 0.8)] + let changes = SetupChange.changes(in: [settings(at: 0, active: "Weekend", profiles: before), settings(at: 10, active: "Weekend", profiles: after)]) + XCTAssertEqual(changes.map(\.items), [[.therapySetting(.basalRate, profile: "Weekend")]]) + } + + func testEditWithoutProfilesNamesNoProfile() { + let changes = SetupChange.changes(in: [settings(at: 0), settings(at: 10, basal: 1.2)]) + XCTAssertEqual(changes.map(\.items), [[.therapySetting(.basalRate)]]) + } + + func testProfilesThatAreNotActiveAreNoSetupChange() { + let defaultProfile = therapyProfile("Default", basal: 1.0) + let history = [ + settings(at: 0, active: nil, profiles: [defaultProfile]), + // Weekend is added, then renamed, edited and deleted + settings(at: 10, active: nil, profiles: [defaultProfile, therapyProfile("Weekend", basal: 0.7)]), + settings(at: 20, active: nil, profiles: [defaultProfile, therapyProfile("Saturday", basal: 0.7)]), + settings(at: 30, active: nil, profiles: [defaultProfile, therapyProfile("Saturday", basal: 0.6)]), + settings(at: 40, active: nil, profiles: [defaultProfile]), + ] + XCTAssertEqual(SetupChange.changes(in: history), []) + } + + func testRenamingTheActiveProfileIsNoSetupChange() { + let before = [therapyProfile("Default", basal: 1.0), therapyProfile("Weekend", basal: 0.7)] + let after = [therapyProfile("Default", basal: 1.0), therapyProfile("Saturday", basal: 0.7)] + let changes = SetupChange.changes(in: [settings(at: 0, active: "Weekend", profiles: before), settings(at: 10, active: "Saturday", profiles: after)]) + XCTAssertEqual(changes, []) + } + + func testProfileSwitchItemTitleAndIdentifier() { + let item = SetupChange.Item.profileSwitch(to: "Weekend", from: "Default") + XCTAssertEqual(item.title, "Switched to profile Weekend (from Default)") + XCTAssertEqual(item.identifier, "profileSwitch") + XCTAssertEqual(SetupChange.Item.therapySetting(.basalRate, profile: "Weekend").identifier, "basalRate") + } }