diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 6ddb07b1c9..e4e95f380e 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -389,6 +389,12 @@ A98556852493F901000FD662 /* AlertStore+SimulatedCoreData.swift in Sources */ = {isa = PBXBuildFile; fileRef = A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */; }; A987CD4924A58A0100439ADC /* ZipArchive.swift in Sources */ = {isa = PBXBuildFile; fileRef = A987CD4824A58A0100439ADC /* ZipArchive.swift */; }; A999D40624663D18004C89D4 /* PumpManagerError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A999D40524663D18004C89D4 /* PumpManagerError.swift */; }; + A99A114229A581F4007919CE /* BolusAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114129A581F4007919CE /* BolusAction.swift */; }; + A99A114429A5829A007919CE /* CarbAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114329A5829A007919CE /* CarbAction.swift */; }; + A99A114629A582A2007919CE /* OverrideAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114529A582A2007919CE /* OverrideAction.swift */; }; + A99A114E29A5879D007919CE /* BolusActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114B29A5879C007919CE /* BolusActionTests.swift */; }; + A99A114F29A5879D007919CE /* CarbActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114C29A5879C007919CE /* CarbActionTests.swift */; }; + A99A115029A5879D007919CE /* OverrideActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A99A114D29A5879C007919CE /* OverrideActionTests.swift */; }; A9A056B324B93C62007CF06D /* CriticalEventLogExportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */; }; A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */; }; A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897341CD2F7DE00223065 /* NSTimeInterval.swift */; }; @@ -1386,6 +1392,12 @@ A98556842493F901000FD662 /* AlertStore+SimulatedCoreData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AlertStore+SimulatedCoreData.swift"; sourceTree = ""; }; A987CD4824A58A0100439ADC /* ZipArchive.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZipArchive.swift; sourceTree = ""; }; A999D40524663D18004C89D4 /* PumpManagerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerError.swift; sourceTree = ""; }; + A99A114129A581F4007919CE /* BolusAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusAction.swift; sourceTree = ""; }; + A99A114329A5829A007919CE /* CarbAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbAction.swift; sourceTree = ""; }; + A99A114529A582A2007919CE /* OverrideAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideAction.swift; sourceTree = ""; }; + A99A114B29A5879C007919CE /* BolusActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusActionTests.swift; sourceTree = ""; }; + A99A114C29A5879C007919CE /* CarbActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CarbActionTests.swift; sourceTree = ""; }; + A99A114D29A5879C007919CE /* OverrideActionTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OverrideActionTests.swift; sourceTree = ""; }; A9A056B224B93C62007CF06D /* CriticalEventLogExportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportView.swift; sourceTree = ""; }; A9A056B424B94123007CF06D /* CriticalEventLogExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CriticalEventLogExportViewModel.swift; sourceTree = ""; }; A9B607AF247F000F00792BE4 /* UserNotifications+Loop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UserNotifications+Loop.swift"; sourceTree = ""; }; @@ -2020,6 +2032,7 @@ 43757D131C06F26C00910CB9 /* Models */ = { isa = PBXGroup; children = ( + A99A114029A581D6007919CE /* Remote */, 43511CDD21FD80AD00566C63 /* RetrospectiveCorrection */, A9FB75F0252BE320004C7D3F /* BolusDosingDecision.swift */, C17824A41E1AD4D100D9D25C /* ManualBolusRecommendation.swift */, @@ -2786,6 +2799,27 @@ path = Shortcuts; sourceTree = ""; }; + A99A114029A581D6007919CE /* Remote */ = { + isa = PBXGroup; + children = ( + A99A114129A581F4007919CE /* BolusAction.swift */, + A99A114329A5829A007919CE /* CarbAction.swift */, + A99A114529A582A2007919CE /* OverrideAction.swift */, + ); + path = Remote; + sourceTree = ""; + }; + A99A114A29A58789007919CE /* Remote */ = { + isa = PBXGroup; + children = ( + A9E8A80428A7CAC000C0A8A4 /* RemoteCommandTests.swift */, + A99A114B29A5879C007919CE /* BolusActionTests.swift */, + A99A114C29A5879C007919CE /* CarbActionTests.swift */, + A99A114D29A5879C007919CE /* OverrideActionTests.swift */, + ); + path = Remote; + sourceTree = ""; + }; A9E6DFE4246A0418005B1A1C /* Extensions */ = { isa = PBXGroup; children = ( @@ -2797,13 +2831,13 @@ A9E6DFED246A0460005B1A1C /* Models */ = { isa = PBXGroup; children = ( + A99A114A29A58789007919CE /* Remote */, A9DFAFB224F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift */, A963B279252CEBAE0062AA12 /* SetBolusUserInfoTests.swift */, A9DFAFB424F048A000950D1E /* WatchHistoricalCarbsTests.swift */, C19008FF252271BB00721625 /* SimpleBolusCalculatorTests.swift */, A9C1719625366F780053BCBD /* WatchHistoricalGlucoseTest.swift */, A9BD28E6272226B40071DF15 /* TestLocalizedError.swift */, - A9E8A80428A7CAC000C0A8A4 /* RemoteCommandTests.swift */, ); path = Models; sourceTree = ""; @@ -3786,6 +3820,7 @@ C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4372E487213C86240068E043 /* SampleValue.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, + A99A114229A581F4007919CE /* BolusAction.swift in Sources */, C1201E2C23ECDBD0002DA84A /* WatchContextRequestUserInfo.swift in Sources */, 1D49795824E7289700948F05 /* ServicesViewModel.swift in Sources */, 1D4A3E2D2478628500FD601B /* StoredAlert+CoreDataClass.swift in Sources */, @@ -3845,6 +3880,7 @@ 1D6B1B6726866D89009AC446 /* AlertPermissionsChecker.swift in Sources */, 4F08DE8F1E7BB871006741EA /* CollectionType+Loop.swift in Sources */, A9F703772489D8AA00C98AD8 /* PersistentDeviceLog+SimulatedCoreData.swift in Sources */, + A99A114629A582A2007919CE /* OverrideAction.swift in Sources */, E9B080B1253BDA6300BAD8F8 /* UserDefaults+LoopIntents.swift in Sources */, C1AF062329426300002C1B19 /* ManualGlucoseEntryRow.swift in Sources */, C148CEE724FD91BD00711B3B /* DeliveryUncertaintyAlertManager.swift in Sources */, @@ -3892,6 +3928,7 @@ A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, + A99A114429A5829A007919CE /* CarbAction.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, @@ -4092,10 +4129,12 @@ buildActionMask = 2147483647; files = ( A9DF02CB24F72B9E00B7C988 /* CriticalEventLogTests.swift in Sources */, + A99A114F29A5879D007919CE /* CarbActionTests.swift in Sources */, B44251B3252350CE00605937 /* ChartAxisValuesStaticGeneratorTests.swift in Sources */, 1D80313D24746274002810DF /* AlertStoreTests.swift in Sources */, C1777A6625A125F100595963 /* ManualEntryDoseViewModelTests.swift in Sources */, C16B984026B4898800256B05 /* DoseEnactorTests.swift in Sources */, + A99A115029A5879D007919CE /* OverrideActionTests.swift in Sources */, A9A63F8E246B271600588D5B /* NSTimeInterval.swift in Sources */, A9DFAFB324F0415E00950D1E /* CarbBackfillRequestUserInfoTests.swift in Sources */, A963B27A252CEBAE0062AA12 /* SetBolusUserInfoTests.swift in Sources */, @@ -4113,6 +4152,7 @@ B4CAD8792549D2540057946B /* LoopCompletionFreshnessTests.swift in Sources */, 1D8D55BC252274650044DBB6 /* BolusEntryViewModelTests.swift in Sources */, A91E4C2124F867A700BE9213 /* StoredAlertTests.swift in Sources */, + A99A114E29A5879D007919CE /* BolusActionTests.swift in Sources */, 1DA7A84224476EAD008257F0 /* AlertManagerTests.swift in Sources */, A91E4C2324F86F1000BE9213 /* CriticalEventLogExportManagerTests.swift in Sources */, E9C58A7324DB4A2700487A17 /* LoopDataManagerTests.swift in Sources */, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 762eb0408b..93199f863d 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -812,6 +812,18 @@ extension DeviceDataManager { self.loopManager.updateRemoteRecommendation() } } + + func enactBolus(units: Double, activationType: BolusActivationType) async throws { + return try await withCheckedThrowingContinuation { continuation in + enactBolus(units: units, activationType: activationType) { error in + if let error = error { + continuation.resume(throwing: error) + return + } + continuation.resume() + } + } + } var pumpManagerStatus: PumpManagerStatus? { return pumpManager?.status @@ -1341,8 +1353,17 @@ extension Notification.Name { // MARK: - Remote Notification Handling extension DeviceDataManager { + func handleRemoteNotification(_ notification: [String: AnyObject]) { - + Task { + let backgroundTask = await beginBackgroundTask(name: "Remote Data Upload") + await handleRemoteNotification(notification) + await endBackgroundTask(backgroundTask) + } + } + + func handleRemoteNotification(_ notification: [String: AnyObject]) async { + defer { log.default("Remote Notification: Finished handling") } @@ -1351,9 +1372,6 @@ extension DeviceDataManager { log.error("Remote Notification: Overrides not enabled.") return } - - let defaultServiceIdentifier = "NightscoutService" - let serviceIdentifer = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier if let expirationStr = notification["expiration"] as? String { let formatter = ISO8601DateFormatter() @@ -1362,7 +1380,7 @@ extension DeviceDataManager { let nowDate = Date() guard nowDate < expiration else { let expiredInterval = nowDate.timeIntervalSince(expiration) - NotificationManager.sendRemoteCommandExpiredNotification(timeExpired: expiredInterval) + await NotificationManager.sendRemoteCommandExpiredNotification(timeExpired: expiredInterval) log.error("Remote Notification: Expired: %{public}@", String(describing: notification)) return } @@ -1372,156 +1390,137 @@ extension DeviceDataManager { } } - let command: RemoteCommand + let action: Action do { - command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: loopManager.settings.overridePresets, defaultAbsorptionTime: carbStore.defaultAbsorptionTimes.medium).get() + action = try RemoteCommand.createRemoteAction(notification: notification).get() } catch { log.error("Remote Notification: Parse Error: %{public}@", String(describing: error)) return } - - switch command { - - case .temporaryScheduleOverride(let remoteOverride): - log.default("Remote Notification: Enacting temporary override: %{public}@", String(describing: remoteOverride)) - activateRemoteOverride(remoteOverride) - case .cancelTemporaryOverride: - log.default("Remote Notification: Canceling temporary override") - activateRemoteOverride(nil) - case .bolusEntry(let bolusAmount): - log.default("Remote Notification: Enacting bolus entry: %{public}@", String(describing: bolusAmount)) - - //Remote bolus requires validation from its remote source - - let validationResult = remoteDataServicesManager.validatePushNotificationSource(notification, serviceIdentifier: serviceIdentifer) - - switch validationResult { - case .success(): - log.info("Remote Notification: Validation successful") - case .failure(let error): - NotificationManager.sendRemoteBolusFailureNotification(for: error, amount: bolusAmount) - log.error("Remote Notification: Could not validate notification: %{public}@", String(describing: notification)) - return - } - - guard let maxBolusAmount = loopManager.settings.maximumBolus else { - NotificationManager.sendRemoteBolusFailureNotification(for: RemoteCommandError.missingMaxBolus, amount: bolusAmount) - log.error("Remote Notification: No max bolus detected. Aborting...") - return - } - - guard bolusAmount.isLessThanOrEqualTo(maxBolusAmount) else { - NotificationManager.sendRemoteBolusFailureNotification(for: RemoteCommandError.exceedsMaxBolus, amount: bolusAmount) - log.error("Remote Notification: Bolus exceeds maximum allowed. Aborting...") - return - } - - self.enactRemoteBolus(units: bolusAmount) { error in - if let error = error { - self.log.error("Remote Notification: Error adding bolus: %{public}@", String(describing: error)) - NotificationManager.sendRemoteBolusFailureNotification(for: error, amount: bolusAmount) - } else { - NotificationManager.sendRemoteBolusNotification(amount: bolusAmount) - } - } - case .carbsEntry(let candidateCarbEntry): - log.default("Remote Notification: Adding carbs entry: %{public}@", String(describing: candidateCarbEntry)) - - let candidateCarbsInGrams = candidateCarbEntry.quantity.doubleValue(for: .gram()) - - //Remote carb entry requires validation from its remote source - let validationResult = remoteDataServicesManager.validatePushNotificationSource(notification, serviceIdentifier: serviceIdentifer) - switch validationResult { - case .success(): - log.info("Remote Notification: Validation successful") - case .failure(let error): - NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: candidateCarbsInGrams) - log.error("Remote Notification: Could not validate notification: %{public}@", String(describing: notification)) - return + + log.default("Remote Notification: Handling action %{public}@", String(describing: action)) + + switch action { + case .temporaryScheduleOverride(let overrideAction): + do { + try await handleOverrideAction(overrideAction) + } catch { + log.error("Remote Notification: Override Action Error: %{public}@", String(describing: error)) } - - guard candidateCarbsInGrams > 0.0 else { - NotificationManager.sendRemoteCarbEntryFailureNotification(for: RemoteCommandError.invalidCarbs, amountInGrams: candidateCarbsInGrams) - log.error("Remote Notification: Invalid carb entry amount. Aborting...") - return + case .cancelTemporaryOverride(let overrideCancelAction): + do { + try await handleOverrideCancelAction(overrideCancelAction) + } catch { + log.error("Remote Notification: Override Action Cancel Error: %{public}@", String(describing: error)) } - - guard candidateCarbsInGrams <= LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()) else { - NotificationManager.sendRemoteCarbEntryFailureNotification(for: RemoteCommandError.exceedsMaxCarbs, amountInGrams: candidateCarbsInGrams) - log.error("Remote Notification: Carbs higher than maximum. Aborting...") - return + case .bolusEntry(let bolusAction): + do { + try validatePushNotificationSource(notification: notification) + try await handleBolusAction(bolusAction) + } catch { + await NotificationManager.sendRemoteBolusFailureNotification(for: error, amount: bolusAction.amountInUnits) + log.error("Remote Notification: Bolus Action Error: %{public}@", String(describing: notification)) } - - addRemoteCarbEntry(candidateCarbEntry) { carbEntryAddResult in - switch carbEntryAddResult { - case .success(let completedCarbEntry): - NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: completedCarbEntry.quantity.doubleValue(for: .gram())) - case .failure(let error): - self.log.error("Remote Notification: Error adding carb entry: %{public}@", String(describing: error)) - NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: candidateCarbsInGrams) - } + case .carbsEntry(let carbAction): + do { + try validatePushNotificationSource(notification: notification) + try await handleCarbAction(carbAction) + } catch { + await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: carbAction.amountInGrams) + log.error("Remote Notification: Carb Action Error: %{public}@", String(describing: notification)) } } } - func activateRemoteOverride(_ remoteOverride: TemporaryScheduleOverride?) { + func validatePushNotificationSource(notification: [String: AnyObject]) throws { + + let defaultServiceIdentifier = "NightscoutService" + let serviceIdentifer = notification["serviceIdentifier"] as? String ?? defaultServiceIdentifier + + let validationResult = remoteDataServicesManager.validatePushNotificationSource(notification, serviceIdentifier: serviceIdentifer) + switch validationResult { + case .success(): + log.info("Remote Notification: Validation successful") + case .failure(let error): + throw error + } + } + + //Remote Overrides + + func handleOverrideAction(_ action: OverrideAction) async throws { + let remoteOverride = try action.toValidOverride(allowedPresets: loopManager.settings.overridePresets) + await activateRemoteOverride(remoteOverride) + } + + func handleOverrideCancelAction(_ action: OverrideCancelAction) async throws { + await activateRemoteOverride(nil) + } + + func activateRemoteOverride(_ remoteOverride: TemporaryScheduleOverride?) async { loopManager.mutateSettings { settings in settings.scheduleOverride = remoteOverride } - self.triggerBackgroundUpload(for: .overrides) + await remoteDataServicesManager.triggerUpload(for: .overrides) } - func addRemoteCarbEntry(_ carbEntry: NewCarbEntry, completion: @escaping (_ result: CarbStoreResult) -> Void) { - - var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "Add Remote Carb Entry") { - guard let backgroundTask = backgroundTask else {return} - UIApplication.shared.endBackgroundTask(backgroundTask) - self.log.error("Add Remote Carb Entry background task expired") - } - - carbStore.addCarbEntry(carbEntry) { result in - self.triggerBackgroundUpload(for: .carb) - if let backgroundTask = backgroundTask { - UIApplication.shared.endBackgroundTask(backgroundTask) - } - completion(result) - self.analyticsServicesManager.didAddCarbs(source: "Remote", amount: carbEntry.quantity.doubleValue(for: .gram())) - } + //Remote Bolus + + func handleBolusAction(_ action: BolusAction) async throws { + let validBolusAmount = try action.toValidBolusAmount(maximumBolus: loopManager.settings.maximumBolus) + try await self.enactBolus(units: validBolusAmount, activationType: .manualNoRecommendation) + await remoteDataServicesManager.triggerUpload(for: .dose) + self.analyticsServicesManager.didBolus(source: "Remote", units: validBolusAmount) } - func enactRemoteBolus(units: Double, completion: @escaping (_ error: Error?) -> Void = { _ in }) { - - var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "Enact Remote Bolus") { - guard let backgroundTask = backgroundTask else {return} - UIApplication.shared.endBackgroundTask(backgroundTask) - self.log.error("Enact Remote Bolus background task expired") - } + //Remote Carb Entry + + func handleCarbAction(_ action: CarbAction) async throws { + let candidateCarbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: carbStore.defaultAbsorptionTimes.medium, + minAbsorptionTime: LoopConstants.minCarbAbsorptionTime, + maxAbsorptionTime: LoopConstants.maxCarbAbsorptionTime, + maxCarbEntryQuantity: LoopConstants.maxCarbEntryQuantity.doubleValue(for: .gram()), + maxCarbEntryPastTime: LoopConstants.maxCarbEntryPastTime, + maxCarbEntryFutureTime: LoopConstants.maxCarbEntryFutureTime + ) - self.enactBolus(units: units, activationType: .manualNoRecommendation) { error in - self.triggerBackgroundUpload(for: .dose) - if let backgroundTask = backgroundTask { - UIApplication.shared.endBackgroundTask(backgroundTask) + let _ = try await addRemoteCarbEntry(candidateCarbEntry) + await remoteDataServicesManager.triggerUpload(for: .carb) + } + + //Can't add this concurrency wrapper method to LoopKit due to the minimum iOS version + func addRemoteCarbEntry(_ carbEntry: NewCarbEntry) async throws -> StoredCarbEntry { + return try await withCheckedThrowingContinuation { continuation in + carbStore.addCarbEntry(carbEntry) { result in + switch result { + case .success(let storedCarbEntry): + self.analyticsServicesManager.didAddCarbs(source: "Remote", amount: carbEntry.quantity.doubleValue(for: .gram())) + continuation.resume(returning: storedCarbEntry) + case .failure(let error): + continuation.resume(throwing: error) + } } - completion(error) - self.analyticsServicesManager.didBolus(source: "Remote", units: units) } } - func triggerBackgroundUpload(for triggeringType: RemoteDataType) { - + //Background Uploads + + func beginBackgroundTask(name: String) async -> UIBackgroundTaskIdentifier? { var backgroundTask: UIBackgroundTaskIdentifier? - backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "Remote Data Upload") { + backgroundTask = await UIApplication.shared.beginBackgroundTask(withName: name) { guard let backgroundTask = backgroundTask else {return} - UIApplication.shared.endBackgroundTask(backgroundTask) - self.log.error("Remote Data Upload background task expired") - } - - self.remoteDataServicesManager.triggerUpload(for: triggeringType) { - if let backgroundTask = backgroundTask { - UIApplication.shared.endBackgroundTask(backgroundTask) + Task { + await UIApplication.shared.endBackgroundTask(backgroundTask) } + + self.log.error("Background Task Expired: %{public}@", name) } + + return backgroundTask + } + + func endBackgroundTask(_ backgroundTask: UIBackgroundTaskIdentifier?) async { + guard let backgroundTask else {return} + await UIApplication.shared.endBackgroundTask(backgroundTask) } } diff --git a/Loop/Managers/NotificationManager.swift b/Loop/Managers/NotificationManager.swift index 1c92ffb7f2..b74be9ef72 100644 --- a/Loop/Managers/NotificationManager.swift +++ b/Loop/Managers/NotificationManager.swift @@ -78,6 +78,7 @@ extension NotificationManager { // MARK: - Notifications + @MainActor static func sendRemoteCommandExpiredNotification(timeExpired: TimeInterval) { let notification = UNMutableNotificationContent() @@ -134,6 +135,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + @MainActor static func sendRemoteBolusNotification(amount: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter() @@ -157,6 +159,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + @MainActor static func sendRemoteBolusFailureNotification(for error: Error, amount: Double) { let notification = UNMutableNotificationContent() let quantityFormatter = QuantityFormatter() @@ -178,6 +181,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + @MainActor static func sendRemoteCarbEntryNotification(amountInGrams: Double) { let notification = UNMutableNotificationContent() @@ -198,6 +202,7 @@ extension NotificationManager { UNUserNotificationCenter.current().add(request) } + @MainActor static func sendRemoteCarbEntryFailureNotification(for error: Error, amountInGrams: Double) { let notification = UNMutableNotificationContent() diff --git a/Loop/Managers/RemoteDataServicesManager.swift b/Loop/Managers/RemoteDataServicesManager.swift index ccd17c7051..6b37ce6c9a 100644 --- a/Loop/Managers/RemoteDataServicesManager.swift +++ b/Loop/Managers/RemoteDataServicesManager.swift @@ -209,6 +209,14 @@ final class RemoteDataServicesManager { completion() } } + + func triggerUpload(for triggeringType: RemoteDataType) async { + return await withCheckedContinuation { continuation in + triggerUpload(for: triggeringType) { + continuation.resume(returning: ()) + } + } + } } extension RemoteDataServicesManager { diff --git a/Loop/Models/LoopConstants.swift b/Loop/Models/LoopConstants.swift index 1f439c08ef..a62fc13849 100644 --- a/Loop/Models/LoopConstants.swift +++ b/Loop/Models/LoopConstants.swift @@ -21,8 +21,13 @@ enum LoopConstants { static let validManualGlucoseEntryRange = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 10)...HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 600) + static let minCarbAbsorptionTime = TimeInterval(minutes: 30) static let maxCarbAbsorptionTime = TimeInterval(hours: 8) + + static let maxCarbEntryPastTime = TimeInterval(hours: (-12)) + static let maxCarbEntryFutureTime = TimeInterval(hours: 1) + static let maxOverrideDurationTime = TimeInterval(hours: 24) // MARK - Display settings diff --git a/Loop/Models/Remote/BolusAction.swift b/Loop/Models/Remote/BolusAction.swift new file mode 100644 index 0000000000..054b618089 --- /dev/null +++ b/Loop/Models/Remote/BolusAction.swift @@ -0,0 +1,46 @@ +// +// BolusAction.swift +// Loop +// +// Created by Bill Gestrich on 2/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension BolusAction { + func toValidBolusAmount(maximumBolus: Double?) throws -> Double { + + guard amountInUnits > 0 else { + throw BolusActionError.invalidBolus + } + + guard let maxBolusAmount = maximumBolus else { + throw BolusActionError.missingMaxBolus + } + + guard amountInUnits <= maxBolusAmount else { + throw BolusActionError.exceedsMaxBolus + } + + return amountInUnits + } +} + +enum BolusActionError: LocalizedError { + + case invalidBolus + case missingMaxBolus + case exceedsMaxBolus + + var errorDescription: String? { + switch self { + case .invalidBolus: + return NSLocalizedString("Invalid Bolus Amount", comment: "Remote command error description: invalid bolus amount.") + case .missingMaxBolus: + return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Remote command error description: missing maximum bolus in settings.") + case .exceedsMaxBolus: + return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Remote command error description: bolus exceeds maximum bolus in settings.") + } + } +} diff --git a/Loop/Models/Remote/CarbAction.swift b/Loop/Models/Remote/CarbAction.swift new file mode 100644 index 0000000000..fd00ed9d6a --- /dev/null +++ b/Loop/Models/Remote/CarbAction.swift @@ -0,0 +1,73 @@ +// +// CarbAction.swift +// Loop +// +// Created by Bill Gestrich on 2/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit +import HealthKit + +extension CarbAction { + + func toValidCarbEntry(defaultAbsorptionTime: TimeInterval, + minAbsorptionTime: TimeInterval, + maxAbsorptionTime: TimeInterval, + maxCarbEntryQuantity: Double, + maxCarbEntryPastTime: TimeInterval, + maxCarbEntryFutureTime: TimeInterval, + nowDate: Date = Date()) throws -> NewCarbEntry { + + let absorptionTime = absorptionTime ?? defaultAbsorptionTime + if absorptionTime < minAbsorptionTime || absorptionTime > maxAbsorptionTime { + throw CarbActionError.invalidAbsorptionTime(absorptionTime) + } + + guard amountInGrams > 0.0 else { + throw CarbActionError.invalidCarbs + } + + guard amountInGrams <= maxCarbEntryQuantity else { + throw CarbActionError.exceedsMaxCarbs + } + + if let startDate = startDate { + let maxStartDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime) + let minStartDate = nowDate.addingTimeInterval(maxCarbEntryPastTime) + guard startDate <= maxStartDate && startDate >= minStartDate else { + throw CarbActionError.invalidStartDate(startDate) + } + } + + let quantity = HKQuantity(unit: .gram(), doubleValue: amountInGrams) + return NewCarbEntry(quantity: quantity, startDate: startDate ?? nowDate, foodType: foodType, absorptionTime: absorptionTime) + } +} + +enum CarbActionError: LocalizedError { + + case invalidAbsorptionTime(TimeInterval) + case invalidStartDate(Date) + case exceedsMaxCarbs + case invalidCarbs + + var errorDescription: String? { + switch self { + case .exceedsMaxCarbs: + return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Remote command error description: carbs exceed maximum amount.") + case .invalidCarbs: + return NSLocalizedString("Invalid carb amount", comment: "Remote command error description: invalid carb amount.") + case .invalidAbsorptionTime(let absorptionTime): + return String(format: NSLocalizedString("Invalid absorption time: %d hours", comment: "Remote command error description: invalid absorption time."), absorptionTime.hours) + case .invalidStartDate(let startDate): + return String(format: NSLocalizedString("Start time is out of range: %@", comment: "Remote command error description: invalid start time is out of range."), Self.dateFormatter.string(from: startDate)) + } + } + + static var dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.timeStyle = .medium + return formatter + }() +} diff --git a/Loop/Models/Remote/OverrideAction.swift b/Loop/Models/Remote/OverrideAction.swift new file mode 100644 index 0000000000..28ea26e383 --- /dev/null +++ b/Loop/Models/Remote/OverrideAction.swift @@ -0,0 +1,57 @@ +// +// OverrideAction.swift +// Loop +// +// Created by Bill Gestrich on 2/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import LoopKit + +extension OverrideAction { + + func toValidOverride(allowedPresets: [TemporaryScheduleOverridePreset]) throws -> TemporaryScheduleOverride { + guard let preset = allowedPresets.first(where: { $0.name == name }) else { + throw OverrideActionError.unknownPreset(name) + } + + var remoteOverride = preset.createOverride(enactTrigger: .remote(remoteAddress)) + + if let durationTime = durationTime { + + guard durationTime <= LoopConstants.maxOverrideDurationTime else { + throw OverrideActionError.durationExceedsMax(LoopConstants.maxOverrideDurationTime) + } + + guard durationTime >= 0 else { + throw OverrideActionError.negativeDuration + } + + if durationTime == 0 { + remoteOverride.duration = .indefinite + } else { + remoteOverride.duration = .finite(durationTime) + } + } + + return remoteOverride + } +} + +enum OverrideActionError: LocalizedError { + + case unknownPreset(String) + case durationExceedsMax(TimeInterval) + case negativeDuration + + var errorDescription: String? { + switch self { + case .unknownPreset(let presetName): + return String(format: NSLocalizedString("Unknown preset: %1$@", comment: "Remote command error description: unknown preset (1: preset name)."), presetName) + case .durationExceedsMax(let maxDurationTime): + return String(format: NSLocalizedString("Duration exceeds: %1$.1f hours", comment: "Remote command error description: duration exceed max (1: max duration in hours)."), maxDurationTime.hours) + case .negativeDuration: + return String(format: NSLocalizedString("Negative duration not allowed", comment: "Remote command error description: negative duration error.")) + } + } +} diff --git a/Loop/Models/RemoteCommand.swift b/Loop/Models/RemoteCommand.swift index 52196b5a82..b927188d48 100644 --- a/Loop/Models/RemoteCommand.swift +++ b/Loop/Models/RemoteCommand.swift @@ -8,78 +8,34 @@ import Foundation import LoopKit -import HealthKit - -public enum RemoteCommandError: LocalizedError { - case expired - case invalidOTP - case missingMaxBolus - case exceedsMaxBolus - case exceedsMaxCarbs - case invalidCarbs - - public var errorDescription: String? { - get { - switch self { - case .expired: - return NSLocalizedString("Expired", comment: "Remote command error description: expired.") - case .invalidOTP: - return NSLocalizedString("Invalid OTP", comment: "Remote command error description: invalid OTP.") - case .missingMaxBolus: - return NSLocalizedString("Missing maximum allowed bolus in settings", comment: "Remote command error description: missing maximum bolus in settings.") - case .exceedsMaxBolus: - return NSLocalizedString("Exceeds maximum allowed bolus in settings", comment: "Remote command error description: bolus exceeds maximum bolus in settings.") - case .exceedsMaxCarbs: - return NSLocalizedString("Exceeds maximum allowed carbs", comment: "Remote command error description: carbs exceed maximum amount.") - case .invalidCarbs: - return NSLocalizedString("Invalid carb amount", comment: "Remote command error description: invalid carb amount.") - } - } - } -} - - -enum RemoteCommand { - case temporaryScheduleOverride(TemporaryScheduleOverride) - case cancelTemporaryOverride - case bolusEntry(Double) - case carbsEntry(NewCarbEntry) -} - // Push Notifications -extension RemoteCommand { - static func createRemoteCommand(notification: [String: Any], allowedPresets: [TemporaryScheduleOverridePreset], defaultAbsorptionTime: TimeInterval, nowDate: Date = Date()) -> Result { +struct RemoteCommand { + static func createRemoteAction(notification: [String: Any]) -> Result { if let overrideName = notification["override-name"] as? String, - let preset = allowedPresets.first(where: { $0.name == overrideName }), let remoteAddress = notification["remote-address"] as? String { - var override = preset.createOverride(enactTrigger: .remote(remoteAddress)) + var overrideTime: TimeInterval? = nil if let overrideDurationMinutes = notification["override-duration-minutes"] as? Double { - override.duration = .finite(TimeInterval(minutes: overrideDurationMinutes)) + overrideTime = TimeInterval(minutes: overrideDurationMinutes) } - return .success(.temporaryScheduleOverride(override)) - } else if let _ = notification["cancel-temporary-override"] as? String { - return .success(.cancelTemporaryOverride) + return .success(.temporaryScheduleOverride(OverrideAction(name: overrideName, durationTime: overrideTime, remoteAddress: remoteAddress))) + } else if let _ = notification["cancel-temporary-override"] as? String, + let remoteAddress = notification["remote-address"] as? String + { + return .success(.cancelTemporaryOverride(OverrideCancelAction(remoteAddress: remoteAddress))) } else if let bolusValue = notification["bolus-entry"] as? Double { - return .success(.bolusEntry(bolusValue)) + return .success(.bolusEntry(BolusAction(amountInUnits: bolusValue))) } else if let carbsValue = notification["carbs-entry"] as? Double { - - let minAbsorptionTime = TimeInterval(hours: 0.5) - let maxAbsorptionTime = LoopConstants.maxCarbAbsorptionTime - var absorptionTime = defaultAbsorptionTime + var absorptionTime: TimeInterval? = nil if let absorptionOverrideInHours = notification["absorption-time"] as? Double { absorptionTime = TimeInterval(hours: absorptionOverrideInHours) } - if absorptionTime < minAbsorptionTime || absorptionTime > maxAbsorptionTime { - return .failure(RemoteCommandParseError.invalidAbsorptionSeconds(absorptionTime)) - } - - let quantity = HKQuantity(unit: .gram(), doubleValue: carbsValue) + var foodType = notification["food-type"] as? String ?? nil - var startDate = nowDate + var startDate: Date? = nil if let notificationStartTimeString = notification["start-time"] as? String { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] @@ -90,8 +46,7 @@ extension RemoteCommand { } } - let newEntry = NewCarbEntry(quantity: quantity, startDate: startDate, foodType: "", absorptionTime: absorptionTime) - return .success(.carbsEntry(newEntry)) + return .success(.carbsEntry(CarbAction(amountInGrams: carbsValue, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate))) } else { return .failure(RemoteCommandParseError.unhandledNotication("\(notification)")) } @@ -99,7 +54,6 @@ extension RemoteCommand { enum RemoteCommandParseError: LocalizedError { case invalidStartTime(String) - case invalidAbsorptionSeconds(Double) case unhandledNotication(String) } } diff --git a/LoopTests/Models/Remote/BolusActionTests.swift b/LoopTests/Models/Remote/BolusActionTests.swift new file mode 100644 index 0000000000..2129ea68b4 --- /dev/null +++ b/LoopTests/Models/Remote/BolusActionTests.swift @@ -0,0 +1,101 @@ +// +// BolusActionTests.swift +// LoopKitTests +// +// Created by Bill Gestrich on 1/14/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import Loop +import LoopKit + +final class BolusActionTests: XCTestCase { + + override func setUpWithError() throws { + } + + override func tearDownWithError() throws { + } + + func testToValidBolusAtMaxAmount_Succeeds() throws { + + //Arrange + let maxBolusAmount = 10.0 + let bolusAmount = maxBolusAmount + let action = BolusAction(amountInUnits: bolusAmount) + + //Act + let validatedBolusAmount = try action.toValidBolusAmount(maximumBolus: 10.0) + + //Assert + XCTAssertEqual(validatedBolusAmount, bolusAmount) + + } + + func testToValidBolusAmount_AboveMaxAmount_Fails() throws { + + //Arrange + let maxBolusAmount = 10.0 + let bolusAmount = maxBolusAmount + 0.1 + let action = BolusAction(amountInUnits: bolusAmount) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidBolusAmount(maximumBolus: maxBolusAmount) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? BolusActionError, case .exceedsMaxBolus = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidBolusAmount_AtZero_Fails() throws { + + //Arrange + let bolusAmount = 0.0 + let action = BolusAction(amountInUnits: bolusAmount) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidBolusAmount(maximumBolus: 10.0) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? BolusActionError, case .invalidBolus = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidBolusAmount_NegativeAmount_Fails() throws { + + //Arrange + let bolusAmount = -1.0 + let action = BolusAction(amountInUnits: bolusAmount) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidBolusAmount(maximumBolus: 10.0) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? BolusActionError, case .invalidBolus = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + +} diff --git a/LoopTests/Models/Remote/CarbActionTests.swift b/LoopTests/Models/Remote/CarbActionTests.swift new file mode 100644 index 0000000000..277051252a --- /dev/null +++ b/LoopTests/Models/Remote/CarbActionTests.swift @@ -0,0 +1,402 @@ +// +// CarbActionTests.swift +// LoopTests +// +// Created by Bill Gestrich on 1/14/23. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +@testable import Loop +import LoopKit + +class CarbActionTests: XCTestCase { + + override func setUpWithError() throws { + } + + override func tearDownWithError() throws { + } + + + func testToValidCarbEntry_Succeeds() throws { + + //Arrange + let expectedCarbsInGrams = 15.0 + let expectedDate = Date() + let expectedAbsorptionTime = TimeInterval(hours: 4.0) + let foodType = "🍕" + + let action = CarbAction(amountInGrams: expectedCarbsInGrams, absorptionTime: expectedAbsorptionTime, foodType: foodType, startDate: expectedDate) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: expectedCarbsInGrams, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1) + ) + + //Assert + XCTAssertEqual(carbEntry.startDate, expectedDate) + XCTAssertEqual(carbEntry.absorptionTime, expectedAbsorptionTime) + XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) + XCTAssertEqual(carbEntry.foodType, foodType) + } + + func testToValidCarbEntry_MissingAbsorptionHours_UsesDefaultAbsorption() throws { + + //Arrange + let defaultAbsorptionTime = TimeInterval(hours: 4.0) + let action = CarbAction(amountInGrams: 15.0, startDate: Date()) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: defaultAbsorptionTime, + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1)) + + //Assert + XCTAssertEqual(carbEntry.absorptionTime, defaultAbsorptionTime) + } + + func testToValidCarbEntry_AtMinAbsorptionHours_Succeeds() throws { + + //Arrange + let minAbsorptionTime = TimeInterval(hours: 0.5) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: minAbsorptionTime, + startDate: Date()) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: minAbsorptionTime, + minAbsorptionTime: minAbsorptionTime, + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1)) + + //Assert + XCTAssertEqual(carbEntry.absorptionTime, minAbsorptionTime) + } + + func testToValidCarbEntry_BelowMinAbsorptionHours_Fails() throws { + + //Arrange + let minAbsorptionTime = TimeInterval(hours: 0.5) + let aborptionOverrideTime = TimeInterval(hours: 0.4) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: aborptionOverrideTime, + startDate: Date()) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: minAbsorptionTime, + minAbsorptionTime: minAbsorptionTime, + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidAbsorptionTime = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_AtMaxAbsorptionHours_Succeeds() throws { + + //Arrange + let maxAbsorptionTime = TimeInterval(hours: 5.0) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: maxAbsorptionTime, + startDate: Date()) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: maxAbsorptionTime, + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: maxAbsorptionTime, + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1) + ) + + //Assert + XCTAssertEqual(carbEntry.absorptionTime, maxAbsorptionTime) + } + + func testToValidCarbEntry_AboveMaxAbsorptionHours_Fails() throws { + + //Arrange + let maxAbsorptionTime = TimeInterval(hours: 5.0) + let absorptionTime = TimeInterval(hours: 5.1) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: absorptionTime, + startDate: Date()) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: maxAbsorptionTime, + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: maxAbsorptionTime, + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidAbsorptionTime = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_AtMinStartTime_Succeeds() throws { + + //Arrange + let maxCarbEntryPastTime = TimeInterval(hours: -12) + let nowDate = Date() + let startDate = nowDate.addingTimeInterval(maxCarbEntryPastTime) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: TimeInterval(hours: 5.0), + startDate: startDate) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: maxCarbEntryPastTime, + maxCarbEntryFutureTime: .hours(1), + nowDate: nowDate + ) + + //Assert + XCTAssertEqual(carbEntry.startDate, startDate) + } + + func testToValidCarbEntry_BeforeMinStartTime_Fails() throws { + + //Arrange + let maxCarbEntryPastTime = TimeInterval(hours: -12) + let nowDate = Date() + let startDate = nowDate.addingTimeInterval(maxCarbEntryPastTime - 1) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: TimeInterval(hours: 5.0), + startDate: startDate) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: maxCarbEntryPastTime, + maxCarbEntryFutureTime: .hours(1) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidStartDate = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_AtMaxStartTime_Succeeds() throws { + + //Arrange + let maxCarbEntryFutureTime = TimeInterval(hours: 1) + let nowDate = Date() + let startDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: TimeInterval(hours: 5.0), + startDate: startDate) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: maxCarbEntryFutureTime, + nowDate: nowDate + ) + + //Assert + XCTAssertEqual(carbEntry.startDate, startDate) + } + + func testToValidCarbEntry_AfterMaxStartTime_Fails() throws { + + //Arrange + let maxCarbEntryFutureTime = TimeInterval(hours: 1) + let nowDate = Date() + let startDate = nowDate.addingTimeInterval(maxCarbEntryFutureTime + 1) + let action = CarbAction(amountInGrams: 15.0, + absorptionTime: TimeInterval(hours: 5.0), + startDate: startDate) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: maxCarbEntryFutureTime + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidStartDate = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_AtMaxCarbs_Succeeds() throws { + + let maxCarbsAmount = 200.0 + let carbsAmount = maxCarbsAmount + + //Arrange + let action = CarbAction(amountInGrams: carbsAmount, + absorptionTime: TimeInterval(hours: 5.0), + startDate: Date()) + + //Act + let carbEntry = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: maxCarbsAmount, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: TimeInterval(hours: 1) + ) + + //Assert + XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: carbsAmount)) + } + + func testToValidCarbEntry_AboveMaxCarbs_Fails() throws { + + let maxCarbsAmount = 200.0 + let carbsAmount = maxCarbsAmount + 1 + + //Arrange + let action = CarbAction(amountInGrams: carbsAmount, + absorptionTime: TimeInterval(hours: 5.0), + startDate: Date()) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1.0) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .exceedsMaxCarbs = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_NegativeCarbs_Fails() throws { + + let carbsAmount = -1.0 + + //Arrange + let action = CarbAction(amountInGrams: carbsAmount, + absorptionTime: TimeInterval(hours: 5.0), + startDate: Date()) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1.0) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidCarbs = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidCarbEntry_ZeroCarbs_Fails() throws { + + let carbsAmount = 0.0 + + //Arrange + let action = CarbAction(amountInGrams: carbsAmount, + absorptionTime: TimeInterval(hours: 5.0), + startDate: Date()) + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidCarbEntry(defaultAbsorptionTime: TimeInterval(hours: 3.0), + minAbsorptionTime: TimeInterval(hours: 0.5), + maxAbsorptionTime: TimeInterval(hours: 5.0), + maxCarbEntryQuantity: 200, + maxCarbEntryPastTime: .hours(-12), + maxCarbEntryFutureTime: .hours(1.0) + ) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? CarbActionError, case .invalidCarbs = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + +} + + +//MARK: Utils + +func dateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +} + diff --git a/LoopTests/Models/Remote/OverrideActionTests.swift b/LoopTests/Models/Remote/OverrideActionTests.swift new file mode 100644 index 0000000000..fe9f5d7fb3 --- /dev/null +++ b/LoopTests/Models/Remote/OverrideActionTests.swift @@ -0,0 +1,152 @@ +// +// OverrideActionTests.swift +// LoopKitTests +// +// Created by Bill Gestrich on 1/14/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import Loop +import LoopKit + +final class OverrideActionTests: XCTestCase { + + override func setUpWithError() throws { + + } + + override func tearDownWithError() throws { + + } + + func testToValidOverride_Succeeds() throws { + + //Arrange + let durationTime = TimeInterval(hours: 1.0) + let remoteAddress = "1234-54321" + let overrideName = "My-Override" + let action = OverrideAction(name: overrideName, durationTime: durationTime, remoteAddress: remoteAddress) + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: overrideName, settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + let validOverride = try action.toValidOverride(allowedPresets: presets) + + //Assert + XCTAssertEqual(validOverride.duration, .finite(durationTime)) + switch validOverride.enactTrigger { + case .remote(let triggerAddress): + XCTAssertEqual(triggerAddress, remoteAddress) + default: + XCTFail("Unexpected trigger trigger type") + } + } + + func testToValidOverride_WhenOverrideNotInPresets_Fails() throws { + + //Arrange + let action = OverrideAction(name: "Unknown-Override", durationTime: TimeInterval(hours: 1.0), remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidOverride(allowedPresets: presets) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? OverrideActionError, case .unknownPreset = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + func testToValidOverride_WhenNoDuration_YieldsIndefiniteOverride() throws { + + //Arrange + let action = OverrideAction(name: "My-Override", durationTime: nil, remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + let validOverride = try action.toValidOverride(allowedPresets: presets) + + //Assert + XCTAssertEqual(validOverride.duration, .indefinite) + } + + func testToValidOverride_WhenDurationZero_YieldsIndefiniteOverride() throws { + + //Arrange + let action = OverrideAction(name: "My-Override", durationTime: TimeInterval(hours: 0), remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + let validOverride = try action.toValidOverride(allowedPresets: presets) + + //Assert + XCTAssertEqual(validOverride.duration, .indefinite) + } + + func testToValidOverride_WhenNegativeDuration_Fails() throws { + + //Arrange + let action = OverrideAction(name: "My-Override", durationTime: TimeInterval(hours: -1.0), remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidOverride(allowedPresets: presets) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? OverrideActionError, case .negativeDuration = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + + //Limit to 24 hour duration + + func testToValidOverride_WhenAtMaxDuration_Succeeds() throws { + + //Arrange + let duration = TimeInterval(hours: 24) + let action = OverrideAction(name: "My-Override", durationTime: duration, remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + let validOverride = try action.toValidOverride(allowedPresets: presets) + + //Assert + XCTAssertEqual(validOverride.duration, .finite(duration)) + + } + + func testToValidOverride_WhenAtMaxDuration_Fails() throws { + + //Arrange + let duration = TimeInterval(hours: 24) + 1 + let action = OverrideAction(name: "My-Override", durationTime: duration, remoteAddress: "1234-54321") + let presets = [TemporaryScheduleOverridePreset(symbol: "", name: "My-Override", settings: .init(targetRange: .none), duration: .indefinite)] + + //Act + var thrownError: Error? = nil + do { + let _ = try action.toValidOverride(allowedPresets: presets) + } catch { + thrownError = error + } + + //Assert + guard let validationError = thrownError as? OverrideActionError, case .durationExceedsMax = validationError else { + XCTFail("Unexpected type \(thrownError.debugDescription)") + return + } + } + +} diff --git a/LoopTests/Models/Remote/RemoteCommandTests.swift b/LoopTests/Models/Remote/RemoteCommandTests.swift new file mode 100644 index 0000000000..9b9163b55b --- /dev/null +++ b/LoopTests/Models/Remote/RemoteCommandTests.swift @@ -0,0 +1,108 @@ +// +// RemoteCommandTests.swift +// LoopTests +// +// Created by Bill Gestrich on 8/13/22. +// Copyright © 2022 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +@testable import Loop +import LoopKit + +class RemoteCommandTests: XCTestCase { + + override func setUpWithError() throws { + } + + override func tearDownWithError() throws { + } + + + //MARK: Carb Entry Command + + func testParseCarbEntryNotification_ValidPayload_Succeeds() throws { + + //Arrange + let expectedStartDateString = "2022-08-14T03:08:00.000Z" + let expectedCarbsInGrams = 15.0 + let expectedDate = dateFormatter().date(from: expectedStartDateString)! + let expectedAbsorptionTimeInHours = 3.0 + let expectedFoodType = "🍕" + let otp = 12345 + let notification: [String: Any] = [ + "carbs-entry":expectedCarbsInGrams, + "absorption-time": expectedAbsorptionTimeInHours, + "food-type": expectedFoodType, + "otp": otp, + "start-time": expectedStartDateString + ] + + //Act + let action = try RemoteCommand.createRemoteAction(notification: notification).get() + + //Assert + guard case .carbsEntry(let carbEntry) = action else { + XCTFail("Incorrect case") + return + } + XCTAssertEqual(carbEntry.startDate, expectedDate) + XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) + XCTAssertEqual(carbEntry.amountInGrams, expectedCarbsInGrams) + XCTAssertEqual(expectedFoodType, carbEntry.foodType) + } + + func testParseCarbEntryNotification_MissingCreatedDate_Succeeds() throws { + + //Arrange + let expectedCarbsInGrams = 15.0 + let expectedAbsorptionTimeInHours = 3.0 + let otp = 12345 + let notification: [String: Any] = [ + "carbs-entry":expectedCarbsInGrams, + "absorption-time": expectedAbsorptionTimeInHours, + "otp": otp + ] + + //Act + let action = try RemoteCommand.createRemoteAction(notification: notification).get() + + //Assert + guard case .carbsEntry(let carbEntry) = action else { + XCTFail("Incorrect case") + return + } + + XCTAssertEqual(carbEntry.startDate, nil) + XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) + XCTAssertEqual(carbEntry.amountInGrams, expectedCarbsInGrams) + } + + func testParseCarbEntryNotification_InvalidCreatedDate_Fails() throws { + + //Arrange + let expectedCarbsInGrams = 15.0 + let expectedAbsorptionTimeInHours = 3.0 + let otp = 12345 + let notification: [String: Any] = [ + "carbs-entry": expectedCarbsInGrams, + "absorption-time":expectedAbsorptionTimeInHours, + "otp": otp, + "start-time": "invalid-date-string" + ] + + //Act + Assert + XCTAssertThrowsError(try RemoteCommand.createRemoteAction(notification: notification).get()) + } + + + //MARK: Utils + + func dateFormatter() -> ISO8601DateFormatter { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + } + +} diff --git a/LoopTests/Models/RemoteCommandTests.swift b/LoopTests/Models/RemoteCommandTests.swift deleted file mode 100644 index 58cd8c650c..0000000000 --- a/LoopTests/Models/RemoteCommandTests.swift +++ /dev/null @@ -1,225 +0,0 @@ -// -// RemoteCommandTests.swift -// LoopTests -// -// Created by Bill Gestrich on 8/13/22. -// Copyright © 2022 LoopKit Authors. All rights reserved. -// - -import XCTest -import HealthKit -@testable import Loop - -class RemoteCommandTests: XCTestCase { - - override func setUpWithError() throws { - } - - override func tearDownWithError() throws { - } - - - //MARK: Carb Entry Command - - func testParseCarbEntryNotification_ValidPayload_Succeeds() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedDate = dateFormatter().date(from: expectedStartDateString)! - let expectedAbsorptionTimeInHours = 3.0 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry":expectedCarbsInGrams, - "absorption-time": expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act - let command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get() - - //Assert - guard case .carbsEntry(let carbEntry) = command else { - XCTFail("Incorrect case") - return - } - XCTAssertEqual(carbEntry.startDate, expectedDate) - XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - } - - func testParseCarbEntryNotification_MissingCreatedDate_Succeeds() throws { - - //Arrange - let expectedStartDate = Date() - let expectedCarbsInGrams = 15.0 - let expectedAbsorptionTimeInHours = 3.0 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry":expectedCarbsInGrams, - "absorption-time": expectedAbsorptionTimeInHours, - "otp": otp - ] - - //Act - let command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0), nowDate: expectedStartDate).get() - - //Assert - guard case .carbsEntry(let carbEntry) = command else { - XCTFail("Incorrect case") - return - } - - XCTAssertEqual(carbEntry.startDate, expectedStartDate) - XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - } - - func testParseCarbEntryNotification_InvalidCreatedDate_Fails() throws { - - //Arrange - let expectedCarbsInGrams = 15.0 - let expectedAbsorptionTimeInHours = 3.0 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "absorption-time":expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": "invalid-date-string" - ] - - //Act + Assert - XCTAssertThrowsError(try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get()) - } - - func testParseCarbEntryNotification_MissingAbsorptionHours_UsesDefaultAbsorption() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedDate = dateFormatter().date(from: expectedStartDateString)! - let expectedAbsorptionTimeInHours = 4.0 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act - let command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: expectedAbsorptionTimeInHours)).get() - - //Assert - guard case .carbsEntry(let carbEntry) = command else { - XCTFail("Incorrect case") - return - } - XCTAssertEqual(carbEntry.startDate, expectedDate) - XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - } - - - func testParseCarbEntryNotification_AtMinAbsorptionHours_Succeeds() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedDate = dateFormatter().date(from: expectedStartDateString)! - let expectedAbsorptionTimeInHours = 0.5 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "absorption-time":expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act - let command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get() - - //Assert - guard case .carbsEntry(let carbEntry) = command else { - XCTFail("Incorrect case") - return - } - XCTAssertEqual(carbEntry.startDate, expectedDate) - XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - } - - func testParseCarbEntryNotification_BelowMinAbsorptionHours_Fails() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedAbsorptionTimeInHours = 0.4 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "absorption-time":expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act + Assert - XCTAssertThrowsError(try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get()) - } - - func testParseCarbEntryNotification_AtMaxAbsorptionHours_Succeeds() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedDate = dateFormatter().date(from: expectedStartDateString)! - let expectedAbsorptionTimeInHours = 8.0 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "absorption-time":expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act - let command = try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get() - - //Assert - guard case .carbsEntry(let carbEntry) = command else { - XCTFail("Incorrect case") - return - } - XCTAssertEqual(carbEntry.startDate, expectedDate) - XCTAssertEqual(carbEntry.absorptionTime, TimeInterval(hours: expectedAbsorptionTimeInHours)) - XCTAssertEqual(carbEntry.quantity, HKQuantity(unit: .gram(), doubleValue: expectedCarbsInGrams)) - } - - func testParseCarbEntryNotification_AboveMaxAbsorptionHours_Fails() throws { - - //Arrange - let expectedStartDateString = "2022-08-14T03:08:00.000Z" - let expectedCarbsInGrams = 15.0 - let expectedAbsorptionTimeInHours = 8.1 - let otp = 12345 - let notification: [String: Any] = [ - "carbs-entry": expectedCarbsInGrams, - "absorption-time":expectedAbsorptionTimeInHours, - "otp": otp, - "start-time": expectedStartDateString - ] - - //Act + Assert - XCTAssertThrowsError(try RemoteCommand.createRemoteCommand(notification: notification, allowedPresets: [], defaultAbsorptionTime: TimeInterval(hours: 4.0)).get()) - } - - - //MARK: Utils - - func dateFormatter() -> ISO8601DateFormatter { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - } - -}