diff --git a/LoopFollow/Alarm/Alarm.swift b/LoopFollow/Alarm/Alarm.swift index f429aa04a..8680aafba 100644 --- a/LoopFollow/Alarm/Alarm.swift +++ b/LoopFollow/Alarm/Alarm.swift @@ -220,8 +220,8 @@ struct Alarm: Identifiable, Codable, Equatable { /// Function for when the alarm is triggered. /// If this alarm, all alarms is disabled or snoozed, then should not be called. This or all alarmd could be muted, then this function will just generate a notification. - func trigger(config: AlarmConfiguration, now: Date) { - LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(type.rawValue)") + func trigger(config: AlarmConfiguration, now: Date, titleOverride: String? = nil) { + LogManager.shared.log(category: .alarm, message: "Alarm triggered: \(titleOverride ?? type.rawValue)") var playSound = true @@ -277,7 +277,7 @@ struct Alarm: Identifiable, Codable, Equatable { } }() - AlarmManager.shared.sendNotification(title: type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") + AlarmManager.shared.sendNotification(title: titleOverride ?? type.rawValue, actionTitle: snoozeDuration == 0 ? "Acknowledge" : "Snooze") if playSound { AlarmSound.setSoundFile(soundFile) diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..d8e90bba7 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -8,6 +8,9 @@ protocol AlarmCondition { init() /// pure, per-alarm logic against `AlarmData` func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool + /// Notification title for the firing that just evaluated true; nil (the + /// default) uses the alarm type's name. + func notificationTitle(alarm: Alarm, data: AlarmData, now: Date) -> String? /// `true` when `belowBG`/`aboveBG` are this condition's own trigger /// threshold, checked in `evaluate`; `false` (the default) treats them /// as activation limits enforced by `passesBGLimits`. @@ -17,6 +20,8 @@ protocol AlarmCondition { extension AlarmCondition { var checksOwnBGLimits: Bool { false } + func notificationTitle(alarm _: Alarm, data _: AlarmData, now _: Date) -> String? { nil } + /// Returns `true` when the alarm is allowed to continue evaluating /// after BG-limit checks; `false` blocks it immediately. func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index e54ebd08d..0edbe188b 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,18 +3,44 @@ import Foundation +/// Fires once when the active override ends and, if the alarm's +/// `predictiveMinutes` is set, once that many minutes before the scheduled end. struct OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestOverrideEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // The ended phase must stay ahead of the early-warning phase; + // notificationTitle(alarm:data:now:) relies on this ordering. + if let endTS = data.latestOverrideEnd, endTS > 0, + now.timeIntervalSince1970 - endTS <= 15 * 60 + { + let last = Storage.shared.lastOverrideEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastOverrideEndNotified.value = endTS + return true + } + } - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - guard endTS > last else { return false } + if let lead = alarm.predictiveMinutes, lead > 0, + let endTS = data.activeOverrideEnd, + now.timeIntervalSince1970 >= endTS - Double(lead) * 60 + { + let last = Storage.shared.lastOverridePreEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastOverridePreEndNotified.value = endTS + return true + } + } - Storage.shared.lastOverrideEndNotified.value = endTS - return true + return false + } + + func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { + guard let endTS = data.activeOverrideEnd, + now.timeIntervalSince1970 < endTS, + Storage.shared.lastOverridePreEndNotified.value == endTS + else { return nil } + return "Override Ending Soon" } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 207332e96..708976cf6 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,19 +3,44 @@ import Foundation -/// Fires once when the active temp target ends. +/// Fires once when the active temp target ends and, if the alarm's +/// `predictiveMinutes` is set, once that many minutes before the scheduled end. struct TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd init() {} - func evaluate(alarm _: Alarm, data: AlarmData, now: Date) -> Bool { - guard let endTS = data.latestTempTargetEnd, endTS > 0 else { return false } - guard now.timeIntervalSince1970 - endTS <= 15 * 60 else { return false } + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + // The ended phase must stay ahead of the early-warning phase; + // notificationTitle(alarm:data:now:) relies on this ordering. + if let endTS = data.latestTempTargetEnd, endTS > 0, + now.timeIntervalSince1970 - endTS <= 15 * 60 + { + let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastTempTargetEndNotified.value = endTS + return true + } + } - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - guard endTS > last else { return false } + if let lead = alarm.predictiveMinutes, lead > 0, + let endTS = data.activeTempTargetEnd, + now.timeIntervalSince1970 >= endTS - Double(lead) * 60 + { + let last = Storage.shared.lastTempTargetPreEndNotified.value ?? 0 + if endTS > last { + Storage.shared.lastTempTargetPreEndNotified.value = endTS + return true + } + } - Storage.shared.lastTempTargetEndNotified.value = endTS - return true + return false + } + + func notificationTitle(alarm _: Alarm, data: AlarmData, now: Date) -> String? { + guard let endTS = data.activeTempTargetEnd, + now.timeIntervalSince1970 < endTS, + Storage.shared.lastTempTargetPreEndNotified.value == endTS + else { return nil } + return "Temp Target Ending Soon" } } diff --git a/LoopFollow/Alarm/AlarmData.swift b/LoopFollow/Alarm/AlarmData.swift index 588c37c5f..dcf6bf946 100644 --- a/LoopFollow/Alarm/AlarmData.swift +++ b/LoopFollow/Alarm/AlarmData.swift @@ -12,6 +12,10 @@ struct AlarmData: Codable { let latestOverrideEnd: TimeInterval? let latestTempTargetStart: TimeInterval? let latestTempTargetEnd: TimeInterval? + /// Scheduled end of the currently-active override/temp target, if known + /// in advance — used by the end alarms' early warning. + var activeOverrideEnd: TimeInterval? + var activeTempTargetEnd: TimeInterval? let recBolus: Double? let COB: Double? let sageInsertTime: TimeInterval? diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift index 5d88a527a..adf465c83 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/OverrideEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct OverrideEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when an override ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when an override ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the override " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift index 03a7756f9..08072437f 100644 --- a/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift +++ b/LoopFollow/Alarm/AlarmEditing/Editors/TempTargetEndAlarmEditor.swift @@ -8,10 +8,21 @@ struct TempTargetEndAlarmEditor: View { var body: some View { Group { - InfoBanner(text: "Alerts when a temp target ends.", alarmType: alarm.type) + InfoBanner(text: "Alerts when a temp target ends, with an optional early warning before the scheduled end.", alarmType: alarm.type) AlarmGeneralSection(alarm: $alarm) + AlarmStepperSection( + header: "Early Warning", + footer: "Also alert this many minutes before the temp target " + + "is scheduled to end. Set to 0 to alert only when it ends.", + title: "Warn before end", + range: 0 ... 30, + step: 5, + unitLabel: alarm.type.snoozeTimeUnit.label, + value: $alarm.predictiveMinutes + ) + AlarmActiveSection(alarm: $alarm) AlarmAudioSection(alarm: $alarm, hideRepeat: true) AlarmSnoozeSection(alarm: $alarm) diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..ea24bfb3b 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -133,9 +133,11 @@ class AlarmManager { } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. + let titleOverride = checker.notificationTitle(alarm: alarm, data: data, now: now) + Observable.shared.currentAlarmTitleOverride.value = titleOverride Observable.shared.currentAlarm.value = alarm.id - alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now) + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) // Store the latest bg time so we don't use it again if alarm.type.isBGBased, @@ -183,6 +185,7 @@ class AlarmManager { func stopAlarm() { AlarmSound.stop() Observable.shared.currentAlarm.value = nil + Observable.shared.currentAlarmTitleOverride.value = nil UNUserNotificationCenter.current().removeAllPendingNotificationRequests() } diff --git a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift index 00dd745bd..9511d3cf8 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/Overrides.swift @@ -51,8 +51,9 @@ extension MainViewController { var end: TimeInterval = isIndefinite ? maxEndDate : start + durationSeconds - // True end for countdown display: based on the raw start and never - // clamped to the graph edge; nil while indefinite. + // True end for countdown display and the end alarm's early + // warning: based on the raw start and never clamped to the graph + // edge; nil while indefinite. var trueEnd: TimeInterval? = isIndefinite ? nil : startDate.timeIntervalSince1970 + durationSeconds if let nextStart = nextStart { @@ -84,7 +85,8 @@ extension MainViewController { reason: (e["notes"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? (e["reason"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", - sgv: -20 + sgv: -20, + scheduledEndDate: trueEnd ) overrideGraphData.append(dot) diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..798731155 100644 --- a/LoopFollow/Helpers/DataStructs.swift +++ b/LoopFollow/Helpers/DataStructs.swift @@ -46,6 +46,10 @@ class DataStructs { var enteredBy: String var reason: String var sgv: Float + /// Scheduled end (start + duration); unlike `endDate` it is never + /// clamped to the graph horizon. Nil when the override runs + /// indefinitely, so the end is not known in advance. + var scheduledEndDate: TimeInterval? } struct tempTargetStruct: Codable { diff --git a/LoopFollow/Snoozer/SnoozerView.swift b/LoopFollow/Snoozer/SnoozerView.swift index f70daaf8c..e2475da4b 100644 --- a/LoopFollow/Snoozer/SnoozerView.swift +++ b/LoopFollow/Snoozer/SnoozerView.swift @@ -102,7 +102,7 @@ struct SnoozerView: View { if let alarm = vm.activeAlarm { VStack(spacing: 16) { // Alarm name at the top - Text(alarm.name) + Text(vm.alarmTitle) .font(.system(size: 30, weight: .semibold)) .foregroundColor(.white) .lineLimit(1) diff --git a/LoopFollow/Snoozer/SnoozerViewModel.swift b/LoopFollow/Snoozer/SnoozerViewModel.swift index bb6a3aa16..70b182c82 100644 --- a/LoopFollow/Snoozer/SnoozerViewModel.swift +++ b/LoopFollow/Snoozer/SnoozerViewModel.swift @@ -6,6 +6,7 @@ import Foundation final class SnoozerViewModel: ObservableObject { @Published var activeAlarm: Alarm? + @Published var alarmTitle: String = "" @Published var snoozeUnits: Int = 5 @Published var timeUnitLabel: String = "minutes" @@ -21,6 +22,7 @@ final class SnoozerViewModel: ObservableObject { .sink { [weak self] alarm in self?.activeAlarm = alarm if let a = alarm { + self?.alarmTitle = Observable.shared.currentAlarmTitleOverride.value ?? a.name self?.snoozeUnits = a.snoozeDuration self?.timeUnitLabel = a.type.snoozeTimeUnit.label } diff --git a/LoopFollow/Storage/Observable.swift b/LoopFollow/Storage/Observable.swift index 07877f8a8..9e90da73c 100644 --- a/LoopFollow/Storage/Observable.swift +++ b/LoopFollow/Storage/Observable.swift @@ -36,6 +36,9 @@ class Observable { var predictionColor = ObservableValue(default: .purple) var currentAlarm = ObservableValue(default: nil) + /// Replaces the current alarm's name on screen when the firing needs its + /// own wording (e.g. an end alarm's early warning); nil shows the name. + var currentAlarmTitleOverride = ObservableValue(default: nil) var alarmSoundPlaying = ObservableValue(default: false) var debug = ObservableValue(default: false) diff --git a/LoopFollow/Storage/Storage.swift b/LoopFollow/Storage/Storage.swift index 4876924e2..1fe33a108 100644 --- a/LoopFollow/Storage/Storage.swift +++ b/LoopFollow/Storage/Storage.swift @@ -58,8 +58,10 @@ class Storage { var lastOverrideStartNotified = StorageValue(key: "lastOverrideStartNotified", defaultValue: nil) var lastOverrideEndNotified = StorageValue(key: "lastOverrideEndNotified", defaultValue: nil) + var lastOverridePreEndNotified = StorageValue(key: "lastOverridePreEndNotified", defaultValue: nil) var lastTempTargetStartNotified = StorageValue(key: "lastTempTargetStartNotified", defaultValue: nil) var lastTempTargetEndNotified = StorageValue(key: "lastTempTargetEndNotified", defaultValue: nil) + var lastTempTargetPreEndNotified = StorageValue(key: "lastTempTargetPreEndNotified", defaultValue: nil) var lastRecBolusNotified = StorageValue(key: "lastRecBolusNotified", defaultValue: nil) var lastCOBNotified = StorageValue(key: "lastCOBNotified", defaultValue: nil) var lastMissedBolusNotified = StorageValue(key: "lastMissedBolusNotified", defaultValue: nil) diff --git a/LoopFollow/Task/AlarmTask.swift b/LoopFollow/Task/AlarmTask.swift index c665c738d..c3c77b48a 100644 --- a/LoopFollow/Task/AlarmTask.swift +++ b/LoopFollow/Task/AlarmTask.swift @@ -19,6 +19,8 @@ extension MainViewController { let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate + let activeOverrideEnd = self.overrideGraphData.last { $0.date <= now && ($0.scheduledEndDate ?? 0) > now }?.scheduledEndDate + let activeTempTargetEnd = self.tempTargetGraphData.last { $0.date <= now && $0.endDate > now }?.endDate let recBolus = Observable.shared.deviceRecBolus.value let COB = self.latestCOB?.value let sensorInsertedAt = Storage.shared.sageInsertTime.value @@ -40,6 +42,8 @@ extension MainViewController { latestOverrideEnd: latestOverrideEnd, latestTempTargetStart: latestTempTargetStart, latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, recBolus: recBolus, COB: COB, sageInsertTime: sensorInsertedAt, diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index b4287504e..f8ab4ef96 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -43,6 +43,18 @@ extension Alarm { alarm.monitoringWindow = window return alarm } + + static func tempTargetEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .tempTargetEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } + + static func overrideEnd(warnBefore: Int? = nil) -> Self { + var alarm = Alarm(type: .overrideEnd) + alarm.predictiveMinutes = warnBefore + return alarm + } } // MARK: - AlarmData helpers @@ -160,6 +172,62 @@ extension AlarmData { ) } + static func withTempTargetEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: nil, + latestTempTargetStart: nil, + latestTempTargetEnd: latestEnd, + activeOverrideEnd: nil, + activeTempTargetEnd: activeEnd, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + + static func withOverrideEnds(latestEnd: TimeInterval? = nil, activeEnd: TimeInterval? = nil) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: nil, + latestOverrideEnd: latestEnd, + latestTempTargetStart: nil, + latestTempTargetEnd: nil, + activeOverrideEnd: activeEnd, + activeTempTargetEnd: nil, + recBolus: nil, + COB: nil, + sageInsertTime: nil, + pumpInsertTime: nil, + latestPumpVolume: nil, + IOB: nil, + recentBoluses: [], + latestBattery: nil, + latestBatteryIsCharging: nil, + latestPumpBattery: nil, + batteryHistory: [], + recentCarbs: [], + dbSizePercentage: nil + ) + } + static func withCarbs(_ carbs: [CarbSample]) -> Self { AlarmData( bgReadings: [], diff --git a/Tests/AlarmConditions/OverrideEndConditionTests.swift b/Tests/AlarmConditions/OverrideEndConditionTests.swift new file mode 100644 index 000000000..4bf3f6f38 --- /dev/null +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -0,0 +1,118 @@ +// LoopFollow +// OverrideEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct OverrideEndConditionTests { + let cond = OverrideEndCondition() + + private func reset() { + Storage.shared.lastOverrideEndNotified.value = nil + Storage.shared.lastOverridePreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withOverrideEnds(latestEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.overrideEnd() + let data = AlarmData.withOverrideEnds(latestEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: .overrideEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .overrideEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("no early warning when the end is unknown (indefinite override)") + func noEarlyWarningWithoutScheduledEnd() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withOverrideEnds(activeEnd: nil) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 10) + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + let data = AlarmData.withOverrideEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + let preData = AlarmData.withOverrideEnds(activeEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Override Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withOverrideEnds(latestEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + } + + @Test("extending the override re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.overrideEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withOverrideEnds(activeEnd: extendedEnd), now: laterNow)) + } +} diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift new file mode 100644 index 000000000..93e50cb01 --- /dev/null +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -0,0 +1,126 @@ +// LoopFollow +// TempTargetEndConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct TempTargetEndConditionTests { + let cond = TempTargetEndCondition() + + private func reset() { + Storage.shared.lastTempTargetEndNotified.value = nil + Storage.shared.lastTempTargetPreEndNotified.value = nil + } + + // MARK: - End firing + + @Test("end firing fires once, then dedups") + func endFiresOnceThenDedups() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withTempTargetEnds(latestEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("end firing ignores ends older than 15 minutes") + func endIgnoresStaleEnds() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 - 16 * 60 + let alarm = Alarm.tempTargetEnd() + let data = AlarmData.withTempTargetEnds(latestEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + // MARK: - Early warning + + @Test("no early warning without a lead time") + func noEarlyWarningByDefault() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: .tempTargetEnd(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .tempTargetEnd(warnBefore: 0), data: data, now: now)) + } + + @Test("early warning fires inside the lead window, once") + func earlyWarningFiresOnceInsideWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 5 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 10) + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning does NOT fire before the lead window") + func earlyWarningWaitsForWindow() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 6 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withTempTargetEnds(activeEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("early warning and end both fire for one event, with the right titles") + func bothPhasesFireWithTitles() { + reset() + let now = Date() + let end = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + let preData = AlarmData.withTempTargetEnds(activeEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: preData, now: now) == "Temp Target Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withTempTargetEnds(latestEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.notificationTitle(alarm: alarm, data: endData, now: endNow) == nil) + } + + @Test("extending the temp target re-arms the early warning for the new end") + func extensionRearmsEarlyWarning() { + reset() + let now = Date() + let firstEnd = now.timeIntervalSince1970 + 4 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + + #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withTempTargetEnds(activeEnd: extendedEnd), now: laterNow)) + } + + @Test("when both phases are due, the end fires first and the warning follows") + func endPhaseWinsSameTick() { + reset() + let now = Date() + let previousEnd = now.timeIntervalSince1970 - 60 + let activeEnd = now.timeIntervalSince1970 + 2 * 60 + let alarm = Alarm.tempTargetEnd(warnBefore: 5) + let data = AlarmData.withTempTargetEnds(latestEnd: previousEnd, activeEnd: activeEnd) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == nil) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.notificationTitle(alarm: alarm, data: data, now: now) == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +}