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..242d84069 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 + /// Title for the firing `evaluate` just reported; nil (the default) uses + /// the alarm type's name. + var firedTitle: String? { get } /// `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 } + var firedTitle: 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/EndAlarmPhases.swift b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift new file mode 100644 index 000000000..db4dd32b9 --- /dev/null +++ b/LoopFollow/Alarm/AlarmCondition/EndAlarmPhases.swift @@ -0,0 +1,47 @@ +// LoopFollow +// EndAlarmPhases.swift + +import Foundation + +/// What an end alarm reports: the event has ended, or its scheduled end is +/// within the alarm's early-warning lead time. +enum EndAlarmPhase { + case ended + case endingSoon +} + +/// Two-phase evaluation shared by the temp target and override end alarms. +/// Each phase fires at most once per event; the markers hold the end +/// timestamp of the event that last fired that phase. +struct EndAlarmPhases { + /// Ends older than this are not reported. + static let endedGrace: TimeInterval = 15 * 60 + + let latestStart: TimeInterval? + let latestEnd: TimeInterval? + let activeEnd: TimeInterval? + let leadMinutes: Int? + let endedMarker: StorageValue + let warnedMarker: StorageValue + + /// The ended phase takes precedence when both are due in the same tick. + func fire(now: Date) -> EndAlarmPhase? { + let nowTS = now.timeIntervalSince1970 + + if let endTS = latestEnd, endTS > 0, + nowTS - endTS <= Self.endedGrace, + endTS > (endedMarker.value ?? 0) + { + endedMarker.value = endTS + return .ended + } + + guard let lead = leadMinutes, lead > 0, let endTS = activeEnd else { return nil } + let warningOpensAt = endTS - Double(lead) * 60 + // An event shorter than the lead time is covered by its start alarm. + if let startTS = latestStart, warningOpensAt <= startTS { return nil } + guard nowTS >= warningOpensAt, warnedMarker.value != endTS else { return nil } + warnedMarker.value = endTS + return .endingSoon + } +} diff --git a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift index e54ebd08d..e10bae970 100644 --- a/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/OverrideEndCondition.swift @@ -3,18 +3,23 @@ import Foundation -struct OverrideEndCondition: AlarmCondition { +/// Fires once when the active override ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class OverrideEndCondition: AlarmCondition { static let type: AlarmType = .overrideEnd + private(set) var firedTitle: String? 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 } - - let last = Storage.shared.lastOverrideEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastOverrideEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestOverrideStart, + latestEnd: data.latestOverrideEnd, + activeEnd: data.activeOverrideEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastOverrideEndNotified, + warnedMarker: Storage.shared.lastOverridePreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Override Ending Soon" : nil + return phase != nil } } diff --git a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift index 207332e96..800129f96 100644 --- a/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/TempTargetEndCondition.swift @@ -3,19 +3,23 @@ import Foundation -/// Fires once when the active temp target ends. -struct TempTargetEndCondition: AlarmCondition { +/// Fires once when the active temp target ends and, with `predictiveMinutes` +/// set, once that many minutes before the scheduled end. +final class TempTargetEndCondition: AlarmCondition { static let type: AlarmType = .tempTargetEnd + private(set) var firedTitle: String? 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 } - - let last = Storage.shared.lastTempTargetEndNotified.value ?? 0 - guard endTS > last else { return false } - - Storage.shared.lastTempTargetEndNotified.value = endTS - return true + func evaluate(alarm: Alarm, data: AlarmData, now: Date) -> Bool { + let phase = EndAlarmPhases( + latestStart: data.latestTempTargetStart, + latestEnd: data.latestTempTargetEnd, + activeEnd: data.activeTempTargetEnd, + leadMinutes: alarm.predictiveMinutes, + endedMarker: Storage.shared.lastTempTargetEndNotified, + warnedMarker: Storage.shared.lastTempTargetPreEndNotified + ).fire(now: now) + firedTitle = phase == .endingSoon ? "Temp Target Ending Soon" : nil + return phase != nil } } 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..d29a4a4e3 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..b191da54f 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..218c9659e 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -126,16 +126,24 @@ class AlarmManager { continue } - // If this alarm is active, and still fulfill the requirements, let it be active - // Break the loop, nothing else to do + let titleOverride = checker.firedTitle + + // An active alarm that still fulfills the requirements stays active. + // A different phase of it (an end alarm's early warning followed + // by the end itself) is announced with its own title. if Observable.shared.currentAlarm.value == alarm.id { + if titleOverride != Observable.shared.currentAlarmTitleOverride.value { + Observable.shared.currentAlarmTitleOverride.value = titleOverride + alarm.trigger(config: Storage.shared.alarmConfiguration.value, now: now, titleOverride: titleOverride) + } break } // Fire the alarm and break the loop; we only allow one alarm per evaluation tick. + 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 +191,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/Controllers/Nightscout/Treatments/TemporaryTarget.swift b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift index 592e47f7a..c0d2e867b 100644 --- a/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift +++ b/LoopFollow/Controllers/Nightscout/Treatments/TemporaryTarget.swift @@ -60,9 +60,7 @@ extension MainViewController { let currentTime = Date().timeIntervalSince1970 if currentTime < endDate { activeTempTarget = Int(targetValue!) - // Trio represents indefinite temp targets as a ~30-day duration; - // treat a week or longer as indefinite (no end to display). - activeTempTargetEndAt = duration >= 7 * 24 * 3600 ? nil : endDate + activeTempTargetEndAt = dot.scheduledEndDate } } diff --git a/LoopFollow/Helpers/DataStructs.swift b/LoopFollow/Helpers/DataStructs.swift index 9cd8a175f..17e7cc506 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 { @@ -55,5 +59,11 @@ class DataStructs { var correctionRange: [Int] var enteredBy: String var reason: String + + /// Scheduled end; nil for an indefinite target, which Trio stores + /// with a duration of about 30 days. + var scheduledEndDate: TimeInterval? { + duration >= 7 * 24 * 3600 ? nil : endDate + } } } 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..7b806e52f 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,11 +22,19 @@ 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 } } .store(in: &cancellables) + Observable.shared.currentAlarmTitleOverride.$value + .receive(on: DispatchQueue.main) + .sink { [weak self] title in + guard let self, let alarm = self.activeAlarm else { return } + self.alarmTitle = title ?? alarm.name + } + .store(in: &cancellables) if let alarm = activeAlarm { snoozeUnits = alarm.snoozeDuration } 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..987a57427 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 }?.scheduledEndDate 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..2610dfb2b 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,41 @@ extension AlarmData { ) } + static func withEnds( + latestTempTargetStart: TimeInterval? = nil, + latestTempTargetEnd: TimeInterval? = nil, + activeTempTargetEnd: TimeInterval? = nil, + latestOverrideStart: TimeInterval? = nil, + latestOverrideEnd: TimeInterval? = nil, + activeOverrideEnd: TimeInterval? = nil + ) -> Self { + AlarmData( + bgReadings: [], + predictionData: [], + expireDate: nil, + lastLoopTime: nil, + latestOverrideStart: latestOverrideStart, + latestOverrideEnd: latestOverrideEnd, + latestTempTargetStart: latestTempTargetStart, + latestTempTargetEnd: latestTempTargetEnd, + activeOverrideEnd: activeOverrideEnd, + activeTempTargetEnd: activeTempTargetEnd, + 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..dd8ac1175 --- /dev/null +++ b/Tests/AlarmConditions/OverrideEndConditionTests.swift @@ -0,0 +1,143 @@ +// 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.withEnds(latestOverrideEnd: 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.withEnds(latestOverrideEnd: 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.withEnds(activeOverrideEnd: 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.withEnds(activeOverrideEnd: 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.withEnds(activeOverrideEnd: 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.withEnds(activeOverrideEnd: 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.withEnds(activeOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestOverrideEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == 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: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: extendedEnd), now: laterNow)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeOverrideEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Override Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.overrideEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestOverrideStart: start, activeOverrideEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +} diff --git a/Tests/AlarmConditions/TempTargetEndConditionTests.swift b/Tests/AlarmConditions/TempTargetEndConditionTests.swift new file mode 100644 index 000000000..9cc83f223 --- /dev/null +++ b/Tests/AlarmConditions/TempTargetEndConditionTests.swift @@ -0,0 +1,151 @@ +// 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.withEnds(latestTempTargetEnd: 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.withEnds(latestTempTargetEnd: 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.withEnds(activeTempTargetEnd: 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.withEnds(activeTempTargetEnd: 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.withEnds(activeTempTargetEnd: 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.withEnds(activeTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: preData, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + let endNow = Date(timeIntervalSince1970: end + 60) + let endData = AlarmData.withEnds(latestTempTargetEnd: end) + #expect(cond.evaluate(alarm: alarm, data: endData, now: endNow)) + #expect(cond.firedTitle == 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: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let extendedEnd = firstEnd + 30 * 60 + let laterNow = Date(timeIntervalSince1970: extendedEnd - 4 * 60) + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: 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.withEnds(latestTempTargetEnd: previousEnd, activeTempTargetEnd: activeEnd) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == nil) + + #expect(cond.evaluate(alarm: alarm, data: data, now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } + + @Test("a replacement whose end is earlier than a warned end still warns") + func replacementWithEarlierEndWarns() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let firstEnd = now.timeIntervalSince1970 + 25 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: firstEnd), now: now)) + + let replacementEnd = now.timeIntervalSince1970 + 10 * 60 + #expect(cond.evaluate(alarm: alarm, data: .withEnds(activeTempTargetEnd: replacementEnd), now: now)) + #expect(cond.firedTitle == "Temp Target Ending Soon") + } + + @Test("no early warning when the lead time covers the whole event") + func noEarlyWarningForEventShorterThanLead() { + reset() + let now = Date() + let alarm = Alarm.tempTargetEnd(warnBefore: 30) + let start = now.timeIntervalSince1970 - 60 + let end = now.timeIntervalSince1970 + 20 * 60 + let data = AlarmData.withEnds(latestTempTargetStart: start, activeTempTargetEnd: end) + + #expect(!cond.evaluate(alarm: alarm, data: data, now: now)) + } +}