diff --git a/Loop/Managers/Alerts/AlertManager.swift b/Loop/Managers/Alerts/AlertManager.swift index 50b99666e2..83f2a12d45 100644 --- a/Loop/Managers/Alerts/AlertManager.swift +++ b/Loop/Managers/Alerts/AlertManager.swift @@ -161,6 +161,24 @@ public final class AlertManager { func loopDidComplete(_ lastLoopDate: Date? = nil) { // use now if there is no lastLoopDate rescheduleLoopNotRunningNotifications(lastLoopDate ?? Date()) + reportDisplayedScheduledAlerts() + } + + private func reportDisplayedScheduledAlerts() { + let now = getCurrentDate() + let start = UserDefaults.standard.scheduledAlertsReportedThrough ?? now + UserDefaults.standard.scheduledAlertsReportedThrough = now + guard start < now else { + return + } + alertStore.lookupScheduledAlertsDue(after: start, through: now) { result in + guard case .success(let alerts) = result else { + return + } + for alert in alerts { + self.analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: true) + } + } } private func rescheduleLoopNotRunningNotifications() { @@ -246,6 +264,7 @@ public final class AlertManager { let interruptionLevel: Alert.InterruptionLevel = notification.isCritical ? .critical : .timeSensitive let alert = Alert(identifier: alertIdentifier, foregroundContent: nil, backgroundContent: content, trigger: .immediate, interruptionLevel: interruptionLevel) recordIssued(alert: alert, at: notification.alertAt) + analyticsServicesManager.didDisplayAlert(identifier: alertIdentifier.value, interruptionLevel: interruptionLevel, scheduled: true) } else { stillPendingNotifications.append(notification) } @@ -361,7 +380,9 @@ extension AlertManager: AlertIssuer { deferredAlerts.append(alert) return } - analyticsServicesManager.didIssueAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel) + if alert.trigger == .immediate { + analyticsServicesManager.didDisplayAlert(identifier: alert.identifier.value, interruptionLevel: alert.interruptionLevel, scheduled: false) + } scheduleAlertWithSchedulers(alert) alertStore.recordIssued(alert: alert) } diff --git a/Loop/Managers/Alerts/AlertStore.swift b/Loop/Managers/Alerts/AlertStore.swift index d8d6db7e5c..702992adb8 100644 --- a/Loop/Managers/Alerts/AlertStore.swift +++ b/Loop/Managers/Alerts/AlertStore.swift @@ -210,6 +210,42 @@ public class AlertStore { } } + /// Delayed and repeating alerts that came due in (start, end] without being retracted first. + /// Only the latest issue of each identifier counts, since re-issuing replaces the scheduled alert. + public func lookupScheduledAlertsDue(after start: Date, through end: Date, completion: @escaping (Result<[(identifier: Alert.Identifier, interruptionLevel: Alert.InterruptionLevel)], Error>) -> Void) { + managedObjectContext.perform { + do { + let fetchRequest: NSFetchRequest = StoredAlert.fetchRequest() + fetchRequest.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "triggerType != %d", Alert.Trigger.immediate.storedType), + NSPredicate(format: "issuedDate > %@ AND issuedDate <= %@", end.addingTimeInterval(-.days(30)) as NSDate, end as NSDate) + ]) + fetchRequest.sortDescriptors = [ NSSortDescriptor(key: "issuedDate", ascending: true) ] + + var latest: [Alert.Identifier: StoredAlert] = [:] + for alert in try self.managedObjectContext.fetch(fetchRequest) { + latest[alert.identifier] = alert + } + + completion(.success(latest.values.compactMap { alert in + guard let interval = alert.triggerInterval?.doubleValue else { + return nil + } + let due = alert.issuedDate.addingTimeInterval(interval) + guard due > start, due <= end else { + return nil + } + if let retractedDate = alert.retractedDate, retractedDate <= due { + return nil + } + return (alert.identifier, alert.interruptionLevel) + })) + } catch { + completion(.failure(error)) + } + } + } + public func lookupAllAcknowledgedUnretractedRepeatingAlerts(completion: @escaping (Result<[StoredAlert], Error>) -> Void) { managedObjectContext.perform { do { diff --git a/Loop/Managers/AnalyticsServicesManager.swift b/Loop/Managers/AnalyticsServicesManager.swift index 861489aba2..122de34575 100644 --- a/Loop/Managers/AnalyticsServicesManager.swift +++ b/Loop/Managers/AnalyticsServicesManager.swift @@ -17,6 +17,10 @@ final class AnalyticsServicesManager { private var analyticsServices = [AnalyticsService]() + var stateProvider: (() -> AnalyticsState)? + + private var lastState: AnalyticsState? + init() {} func addService(_ analyticsService: AnalyticsService) { @@ -70,6 +74,14 @@ final class AnalyticsServicesManager { identify("CGM Type", value: cgmType) } + func identifyPumpModel(_ model: String?) { + identify("Pump Model", value: model ?? "Unknown") + } + + func identifyCGMModel(_ model: String?) { + identify("CGM Model", value: model ?? "Unknown") + } + // MARK: - Screens func didDisplayBolusScreen() { @@ -139,10 +151,6 @@ final class AnalyticsServicesManager { logEvent("Minimum BG Guard change") } - if newValue.dosingEnabled != oldValue.dosingEnabled { - logEvent("Closed loop enabled change") - } - if newValue.basalRateSchedule?.timeZone != oldValue.basalRateSchedule?.timeZone { logEvent("Therapy schedule time zone change") } @@ -160,6 +168,8 @@ final class AnalyticsServicesManager { func pumpWasRemoved() { logEvent("Pump Removed") + identifyPumpType("None") + identifyPumpModel("None") } func pumpWasAdded(identifier: String) { @@ -168,72 +178,116 @@ final class AnalyticsServicesManager { func cgmWasRemoved() { logEvent("CGM Removed") + identifyCGMType("None") + identifyCGMModel("None") } func cgmWasAdded(identifier: String) { logEvent("CGM Added", withProperties: ["identifier" : identifier]) } - func didAddCarbs(source: String, amount: Double, inSession: Bool = false) { - logEvent("Carb entry created", withProperties: ["source" : source, "amount": "\(amount)"], outOfSession: inSession) + func didAddCarbs(source: String) { + logEvent("Carb entry created", withProperties: ["source" : source], outOfSession: source != "Phone") } func didRetryBolus() { logEvent("Bolus Retry") } - func didBolus(source: String, units: Double, inSession: Bool = false) { - logEvent("Bolus set", withProperties: ["source" : source, "units": "\(units)"], outOfSession: true) + func didBolus(source: String) { + logEvent("Bolus set", withProperties: ["source" : source], outOfSession: source != "Phone") } - func didFetchNewCGMData() { - logEvent("CGM Fetch", outOfSession: true) + // MARK: - Daily Summary + + func didStoreGlucose(_ samples: [StoredGlucoseSample]) { + let count = samples.filter { !$0.wasUserEntered }.count + updateDailySummary { $0.glucoseCount += count } } func loopDidSucceed(_ duration: TimeInterval) { - logEvent("Loop success", withProperties: ["duration": duration], outOfSession: true) + updateDailySummary { summary in + summary.loopSuccessCount += 1 + summary.totalLoopDuration += duration + summary.maxLoopDuration = max(summary.maxLoopDuration, duration) + } } func loopDidError(error: LoopError) { - var props = [AnyHashable: Any]() - - props["issueId"] = error.issueId + updateDailySummary { $0.loopErrorCounts[error.issueId, default: 0] += 1 } + } - for (detailKey, detail) in error.issueDetails { - props[detailKey] = detail + func identifyState() { + guard let state = stateProvider?() else { + return + } + identify("Closed Loop", value: state.closedLoop ? "On" : "Off") + identify("Dosing Strategy", value: state.dosingStrategy) + identify("Integral Retrospective Correction", value: state.integralRetrospectiveCorrection ? "On" : "Off") + identify("Glucose Based Partial Application", value: state.glucoseBasedPartialApplication ? "On" : "Off") + lastState = state + } + + // Callers are on the loop and CGM queues; the summary and state are only touched on main + private func updateDailySummary(_ update: @escaping (inout AnalyticsDailySummary) -> Void) { + let now = Date() + DispatchQueue.main.async { + let day = Calendar.current.startOfDay(for: now) + var summary = UserDefaults.standard.analyticsDailySummary ?? AnalyticsDailySummary(day: day) + + if summary.day != day { + self.logEvent("Daily summary", withProperties: summary.properties, outOfSession: true) + summary = AnalyticsDailySummary(day: day) + } + + update(&summary) + + if let state = self.stateProvider?() { + summary.state = state + if let lastState = self.lastState, state != lastState { + self.logStateChanges(from: lastState, to: state) + self.identifyState() + } + } + + UserDefaults.standard.analyticsDailySummary = summary } - - logEvent("Loop error", withProperties: props, outOfSession: true) } - func didIssueAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel) { - logEvent("Alert Issued", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue]) + private func logStateChanges(from oldState: AnalyticsState, to newState: AnalyticsState) { + if newState.closedLoop != oldState.closedLoop { + logEvent("Closed loop enabled change", withProperties: ["enabled": newState.closedLoop]) + } + if newState.dosingStrategy != oldState.dosingStrategy { + logEvent("Dosing strategy change", withProperties: ["value": newState.dosingStrategy]) + } + if newState.integralRetrospectiveCorrection != oldState.integralRetrospectiveCorrection { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Integral Retrospective Correction", "enabled": newState.integralRetrospectiveCorrection]) + } + if newState.glucoseBasedPartialApplication != oldState.glucoseBasedPartialApplication { + logEvent("Algorithm experiment change", withProperties: ["experiment": "Glucose Based Partial Application", "enabled": newState.glucoseBasedPartialApplication]) + } } - func didEnactOverride(name: String, symbol: String, duration: TemporaryScheduleOverride.Duration, insulinSensitivityMultiplier: Double = 1.0, targetRange: ClosedRange? = nil) - { - let combinedName = "\(symbol) - \(name)" + func didDisplayAlert(identifier: String, interruptionLevel: Alert.InterruptionLevel, scheduled: Bool) { + logEvent("Alert Displayed", withProperties: ["identifier": identifier, "interruptionLevel": interruptionLevel.rawValue, "scheduled": scheduled], outOfSession: scheduled) + } - var properties: [String: Any] = [ - "name": name, - "symbol": symbol, - "sensitivityMultiplier": insulinSensitivityMultiplier, - "nameWithEmoji": combinedName - ] + func didEnactOverride(kind: String, duration: TemporaryScheduleOverride.Duration) { + var properties: [String: Any] = ["kind": kind] - if let targetUpperBound = targetRange?.upperBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { - properties["targetUpperBound"] = targetUpperBound - } - if let targetLowerBound = targetRange?.lowerBound.doubleValue(for: HKUnit.milligramsPerDeciliter) { - properties["targetLowerBound"] = targetLowerBound + switch duration { + case .finite(let interval): + properties["durationMinutes"] = Int(interval.minutes) + case .indefinite: + properties["durationMinutes"] = "indefinite" } - logEvent("Override Enacted", withProperties: properties) } - func didCancelOverride(name: String) { - logEvent("Override Canceled", withProperties: ["name": name]) + func didEndOverride(kind: String) { + logEvent("Override Ended", withProperties: ["kind": kind]) } } @@ -241,24 +295,78 @@ final class AnalyticsServicesManager { // MARK: - PresetActivationObserver extension AnalyticsServicesManager: PresetActivationObserver { func presetActivated(context: TemporaryScheduleOverride.Context, duration: TemporaryScheduleOverride.Duration) { - switch context { - case .legacyWorkout: - didEnactOverride(name: "workout", symbol: "", duration: duration) - case .preMeal: - didEnactOverride(name: "preMeal", symbol: "", duration: duration) - case .custom: - didEnactOverride(name: "custom", symbol: "", duration: duration) - case .preset(let preset): - didEnactOverride(name: preset.name, symbol: preset.symbol, duration: duration, insulinSensitivityMultiplier: preset.settings.effectiveInsulinNeedsScaleFactor, targetRange: preset.settings.targetRange) - } + didEnactOverride(kind: context.analyticsKind, duration: duration) } func presetDeactivated(context: TemporaryScheduleOverride.Context) { - switch context { - case .legacyWorkout: - break - default: - break + didEndOverride(kind: context.analyticsKind) + } +} + +extension TemporaryScheduleOverride.Context { + var analyticsKind: String { + switch self { + case .legacyWorkout: return "workout" + case .preMeal: return "preMeal" + case .custom: return "custom" + case .preset: return "preset" + } + } +} + +struct AnalyticsState: Codable, Equatable { + var closedLoop: Bool + var dosingStrategy: String + var integralRetrospectiveCorrection: Bool + var glucoseBasedPartialApplication: Bool +} + +struct AnalyticsDailySummary: Codable, Equatable { + var day: Date + var utcOffset = TimeZone.current.secondsFromGMT() + var loopSuccessCount = 0 + var totalLoopDuration: TimeInterval = 0 + var maxLoopDuration: TimeInterval = 0 + var loopErrorCounts: [String: Int] = [:] + var glucoseCount = 0 + var state: AnalyticsState? + + var properties: [AnyHashable: Any] { + var properties: [AnyHashable: Any] = [ + "day": ISO8601DateFormatter.string(from: day, timeZone: TimeZone(secondsFromGMT: utcOffset) ?? .current, formatOptions: .withFullDate), + "utcOffset": utcOffset, + "loopSuccessCount": loopSuccessCount, + "meanLoopDuration": loopSuccessCount > 0 ? totalLoopDuration / Double(loopSuccessCount) : 0, + "maxLoopDuration": maxLoopDuration, + "loopErrorCount": loopErrorCounts.values.reduce(0, +), + "loopErrorCounts": loopErrorCounts, + "glucoseCount": glucoseCount + ] + if let state { + properties["closedLoop"] = state.closedLoop + properties["dosingStrategy"] = state.dosingStrategy + properties["integralRetrospectiveCorrection"] = state.integralRetrospectiveCorrection + properties["glucoseBasedPartialApplication"] = state.glucoseBasedPartialApplication + } + return properties + } +} + +extension UserDefaults { + private static let analyticsDailySummaryKey = "com.loopkit.Loop.AnalyticsDailySummary" + private static let scheduledAlertsReportedThroughKey = "com.loopkit.Loop.AnalyticsScheduledAlertsReportedThrough" + + var scheduledAlertsReportedThrough: Date? { + get { object(forKey: Self.scheduledAlertsReportedThroughKey) as? Date } + set { set(newValue, forKey: Self.scheduledAlertsReportedThroughKey) } + } + + var analyticsDailySummary: AnalyticsDailySummary? { + get { + data(forKey: Self.analyticsDailySummaryKey).flatMap { try? JSONDecoder().decode(AnalyticsDailySummary.self, from: $0) } + } + set { + set(newValue.flatMap { try? JSONEncoder().encode($0) }, forKey: Self.analyticsDailySummaryKey) } } } diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 09ee0c11db..8c2b705c0b 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -43,6 +43,8 @@ final class DeviceDataManager { @Published var cgmHasValidSensorSession: Bool + private var cgmDeviceModel: String? + @Published var pumpIsAllowingAutomation: Bool private var lastCGMLoopTrigger: Date = .distantPast @@ -581,6 +583,9 @@ final class DeviceDataManager { switch readingResult { case .newData(let values): loopManager.addGlucoseSamples(values) { result in + if case .success(let samples) = result { + self.analyticsServicesManager.didStoreGlucose(samples) + } if !values.isEmpty { DispatchQueue.main.async { self.cgmStalenessMonitor.cgmGlucoseSamplesAvailable(values) @@ -733,6 +738,8 @@ private extension DeviceDataManager { cgmHasValidSensorSession = cgmManager.cgmManagerStatus.hasValidSensorSession analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + cgmDeviceModel = cgmManager.cgmManagerStatus.device?.model + analyticsServicesManager.identifyCGMModel(cgmDeviceModel) } if let cgmManagerUI = cgmManager as? CGMManagerUI { @@ -763,6 +770,7 @@ private extension DeviceDataManager { deliveryUncertaintyAlertManager = DeliveryUncertaintyAlertManager(pumpManager: pumpManager, alertPresenter: alertPresenter) analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) + analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) updatePumpManagerBLEHeartbeatPreference() } @@ -1055,6 +1063,10 @@ extension DeviceDataManager: CGMManagerDelegate { if self.cgmHasValidSensorSession != status.hasValidSensorSession { self.cgmHasValidSensorSession = status.hasValidSensorSession } + if self.cgmDeviceModel != status.device?.model { + self.cgmDeviceModel = status.device?.model + self.analyticsServicesManager.identifyCGMModel(self.cgmDeviceModel) + } } } } @@ -1112,10 +1124,6 @@ extension DeviceDataManager: PumpManagerDelegate { } cgmManager.fetchNewDataIfNeeded { (result) in - if case .newData = result { - self.analyticsServicesManager.didFetchNewCGMData() - } - self.queue.async { self.processCGMReadingResult(cgmManager, readingResult: result) { if self.loopManager.lastLoopCompleted == nil || self.loopManager.lastLoopCompleted!.timeIntervalSinceNow < -.minutes(4.2) { @@ -1157,6 +1165,10 @@ extension DeviceDataManager: PumpManagerDelegate { log.default("PumpManager:%{public}@ did update status: %{public}@", String(describing: type(of: pumpManager)), String(describing: status)) doseStore.device = status.device + + if status.device.model != oldStatus.device.model { + analyticsServicesManager.identifyPumpModel(status.device.model) + } if let newBatteryValue = status.pumpBatteryChargeRemaining, let oldBatteryValue = oldStatus.pumpBatteryChargeRemaining, diff --git a/Loop/Managers/LoopAppManager.swift b/Loop/Managers/LoopAppManager.swift index 3971e8c8c8..b9a36d1295 100644 --- a/Loop/Managers/LoopAppManager.swift +++ b/Loop/Managers/LoopAppManager.swift @@ -260,7 +260,23 @@ class LoopAppManager: NSObject { analyticsServicesManager.identifyWorkspaceGitRevision(workspaceGitRevision) } - analyticsServicesManager.identify("Dosing Strategy", value: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue) + analyticsServicesManager.stateProvider = { [settingsManager = settingsManager!] in + AnalyticsState( + closedLoop: settingsManager.loopSettings.dosingEnabled, + dosingStrategy: settingsManager.loopSettings.automaticDosingStrategy.analyticsValue, + integralRetrospectiveCorrection: UserDefaults.standard.integralRetrospectiveCorrectionEnabled, + glucoseBasedPartialApplication: UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ) + } + analyticsServicesManager.identifyState() + if let pumpManager = deviceDataManager.pumpManager { + analyticsServicesManager.identifyPumpType(pumpManager.pluginIdentifier) + analyticsServicesManager.identifyPumpModel(pumpManager.status.device.model) + } + if let cgmManager = deviceDataManager.cgmManager { + analyticsServicesManager.identifyCGMType(cgmManager.pluginIdentifier) + analyticsServicesManager.identifyCGMModel(cgmManager.cgmManagerStatus.device?.model) + } let serviceNames = deviceDataManager.servicesManager.activeServices.map { $0.pluginIdentifier } analyticsServicesManager.identify("Services", array: serviceNames) diff --git a/Loop/Managers/ServicesManager.swift b/Loop/Managers/ServicesManager.swift index 2393ceb073..3fb788f9da 100644 --- a/Loop/Managers/ServicesManager.swift +++ b/Loop/Managers/ServicesManager.swift @@ -322,7 +322,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDelegate?.deliverCarbs(amountInGrams: amountInGrams, absorptionTime: absorptionTime, foodType: foodType, startDate: startDate) await NotificationManager.sendRemoteCarbEntryNotification(amountInGrams: amountInGrams) await remoteDataServicesManager.triggerUpload(for: .carb) - analyticsServicesManager.didAddCarbs(source: "Remote", amount: amountInGrams) + analyticsServicesManager.didAddCarbs(source: "Remote") } catch { await NotificationManager.sendRemoteCarbEntryFailureNotification(for: error, amountInGrams: amountInGrams) throw error @@ -347,7 +347,7 @@ extension ServicesManager: ServiceDelegate { try await servicesManagerDosingDelegate?.deliverBolus(amountInUnits: amountInUnits) await NotificationManager.sendRemoteBolusNotification(amount: amountInUnits) await remoteDataServicesManager.triggerUpload(for: .dose) - analyticsServicesManager.didBolus(source: "Remote", units: amountInUnits) + analyticsServicesManager.didBolus(source: "Remote") } catch { await NotificationManager.sendRemoteBolusFailureNotification(for: error, amountInUnits: amountInUnits) throw error diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index bac60b71dc..b2f8c8db81 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -386,7 +386,7 @@ final class WatchDataManager: NSObject { deviceManager.enactBolus(units: bolus.value, activationType: bolus.activationType) { (error) in if error == nil { - self.deviceManager.analyticsServicesManager.didBolus(source: "Watch", units: bolus.value) + self.deviceManager.analyticsServicesManager.didBolus(source: "Watch") } // When we've successfully started the bolus, send a new context with our new prediction @@ -401,7 +401,7 @@ final class WatchDataManager: NSObject { switch result { case .success(let storedCarbEntry): dosingDecision.carbEntry = storedCarbEntry - self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.deviceManager.analyticsServicesManager.didAddCarbs(source: "Watch") enactBolus() case .failure(let error): self.log.error("%{public}@", String(describing: error)) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 3e124fa7f5..2e81f1ad52 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -1581,6 +1581,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } private func presentSettings() { + deviceManager.analyticsServicesManager.didDisplaySettingsScreen() let deletePumpDataFunc: () -> PumpManagerViewModel.DeleteTestingDataFunc? = { [weak self] in (self?.deviceManager.pumpManager is TestingPumpManager) ? { [weak self] in self?.deviceManager.deleteTestingPumpData() diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..20735e7fd7 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -407,7 +407,7 @@ final class BolusEntryViewModel: ObservableObject { } if let storedCarbEntry = await saveCarbEntry(carbEntry, replacingEntry: originalCarbEntry) { self.dosingDecision.carbEntry = storedCarbEntry - self.analyticsServicesManager?.didAddCarbs(source: "Phone", amount: storedCarbEntry.quantity.doubleValue(for: .gram())) + self.analyticsServicesManager?.didAddCarbs(source: "Phone") } else { self.presentAlert(.carbEntryPersistenceFailure) return false @@ -422,7 +422,7 @@ final class BolusEntryViewModel: ObservableObject { if amountToDeliver > 0 { savedPreMealOverride = nil delegate.enactBolus(units: amountToDeliver, activationType: activationType, completion: { _ in - self.analyticsServicesManager?.didBolus(source: "Phone", units: amountToDeliver) + self.analyticsServicesManager?.didBolus(source: "Phone") }) } return true diff --git a/LoopTests/Managers/Alerts/AlertStoreTests.swift b/LoopTests/Managers/Alerts/AlertStoreTests.swift index bb9d109633..e7419b6693 100644 --- a/LoopTests/Managers/Alerts/AlertStoreTests.swift +++ b/LoopTests/Managers/Alerts/AlertStoreTests.swift @@ -91,6 +91,33 @@ class AlertStoreTests: XCTestCase { XCTAssertEqual(999, newAnchor?.modificationCounter) } + func testLookupScheduledAlertsDue() { + let issued = Self.historicDate + let due = issued.addingTimeInterval(Self.delayedAlertDelay) + let expect = self.expectation(description: #function) + alertStore.recordIssued(alert: alert1, at: issued, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.delayedAlert, at: issued, completion: self.expectSuccess { + self.alertStore.recordIssued(alert: self.repeatingAlert, at: issued, completion: self.expectSuccess { + self.alertStore.recordRetraction(of: Self.repeatingAlertIdentifier, at: due.addingTimeInterval(-1), completion: self.expectSuccess { + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1), completion: self.expectSuccess { dueAlerts in + XCTAssertEqual([Self.delayedAlertIdentifier], dueAlerts.map { $0.identifier }) + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(-1), completion: self.expectSuccess { dueAlerts in + XCTAssertTrue(dueAlerts.isEmpty) + self.alertStore.recordIssued(alert: self.delayedAlert, at: issued.addingTimeInterval(10), completion: self.expectSuccess { + self.alertStore.lookupScheduledAlertsDue(after: issued, through: due.addingTimeInterval(1), completion: self.expectSuccess { dueAlerts in + XCTAssertTrue(dueAlerts.isEmpty) + expect.fulfill() + }) + }) + }) + }) + }) + }) + }) + }) + wait(for: [expect], timeout: Self.defaultTimeout) + } + func testRecordIssued() { let expect = self.expectation(description: #function) alertStore.recordIssued(alert: alert1, at: Self.historicDate, completion: self.expectSuccess {