diff --git a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift index 53bbeabc4..3faf61ce9 100644 --- a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift @@ -5,7 +5,8 @@ import Foundation /// Fires when a carb entry is logged but **no** qualifying bolus is given /// within the user-defined “delay” (after allowing for a pre-bolus window). -/// • Ignores small-carb treatments, tiny boluses, and low-BG scenarios. +/// • Ignores small-carb treatments and tiny boluses; the optional BG limit +/// (`aboveBG`) is enforced by `passesBGLimits`. /// • Triggers once per carb entry. struct MissedBolusCondition: AlarmCondition { static let type: AlarmType = .missedBolus @@ -19,14 +20,13 @@ struct MissedBolusCondition: AlarmCondition { let delayMin = alarm.monitoringWindow, delayMin > 0, let prebolusMin = alarm.predictiveMinutes, let minBolusU = alarm.delta, // ignore bolus ≤ - let minCarbGr = alarm.threshold, // ignore carbs ≤ - let minBG = alarm.aboveBG // ignore BG ≤ + let minCarbGr = alarm.threshold // ignore carbs ≤ else { return false } // ──────────────────────────────── - // 1. get most-recent carb entry + // 1. get most-recent past carb entry // ──────────────────────────────── - guard let carb = data.recentCarbs.last else { return false } + guard let carb = data.recentCarbs.last(where: { $0.date <= now }) else { return false } // – must be at least `delayMin` old, but not older than 60 min guard carb.date > now.addingTimeInterval(-3600), @@ -36,10 +36,6 @@ struct MissedBolusCondition: AlarmCondition { // – ignore tiny carbs guard carb.grams > minCarbGr else { return false } - // – ignore if BG is low - if let latestBG = data.bgReadings.last, - Double(latestBG.sgv) <= minBG { return false } - // ──────────────────────────────── // 2. already alerted for this carb? // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmManager.swift b/LoopFollow/Alarm/AlarmManager.swift index 29c6128d9..5ed8afafa 100644 --- a/LoopFollow/Alarm/AlarmManager.swift +++ b/LoopFollow/Alarm/AlarmManager.swift @@ -34,6 +34,7 @@ class AlarmManager { BatteryCondition.self, BatteryDropCondition.self, FutureCarbsCondition.self, + MissedBolusCondition.self, DBSizeCondition.self, ] ) { diff --git a/Tests/AlarmConditions/Helpers.swift b/Tests/AlarmConditions/Helpers.swift index b4287504e..905330552 100644 --- a/Tests/AlarmConditions/Helpers.swift +++ b/Tests/AlarmConditions/Helpers.swift @@ -29,6 +29,15 @@ extension Alarm { return alarm } + static func missedBolus(delay: Int = 15, prebolus: Int = 15, minBolus: Double = 0.1, minCarbs: Double = 4) -> Self { + var alarm = Alarm(type: .missedBolus) + alarm.monitoringWindow = delay + alarm.predictiveMinutes = prebolus + alarm.delta = minBolus + alarm.threshold = minCarbs + return alarm + } + static func low(belowBG: Double?, predictiveMinutes: Int? = nil, persistentMinutes: Int? = nil) -> Self { var alarm = Alarm(type: .low) alarm.belowBG = belowBG @@ -160,7 +169,7 @@ extension AlarmData { ) } - static func withCarbs(_ carbs: [CarbSample]) -> Self { + static func withCarbs(_ carbs: [CarbSample], boluses: [BolusEntry] = []) -> Self { AlarmData( bgReadings: [], predictionData: [], @@ -176,7 +185,7 @@ extension AlarmData { pumpInsertTime: nil, latestPumpVolume: nil, IOB: nil, - recentBoluses: [], + recentBoluses: boluses, latestBattery: nil, latestBatteryIsCharging: nil, latestPumpBattery: nil, diff --git a/Tests/AlarmConditions/MissedBolusConditionTests.swift b/Tests/AlarmConditions/MissedBolusConditionTests.swift new file mode 100644 index 000000000..942b548ea --- /dev/null +++ b/Tests/AlarmConditions/MissedBolusConditionTests.swift @@ -0,0 +1,108 @@ +// LoopFollow +// MissedBolusConditionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +@Suite(.serialized) +struct MissedBolusConditionTests { + let cond = MissedBolusCondition() + + private func resetNotified() { + Storage.shared.lastMissedBolusNotified.value = nil + } + + private func carb(minutesAgo: Double, grams: Double = 30, relativeTo now: Date) -> CarbSample { + CarbSample(grams: grams, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + private func bolus(minutesAgo: Double, units: Double, relativeTo now: Date) -> BolusEntry { + BolusEntry(units: units, date: now.addingTimeInterval(-minutesAgo * 60)) + } + + @Test("#fires — default alarm, carbs past delay with no bolus") + func firesWithDefaults() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#fires once — same carb entry does not fire twice") + func firesOncePerCarb() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, relativeTo: now)]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now.addingTimeInterval(300))) + } + + @Test("#within delay — carbs newer than delay do not fire") + func withinDelayNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 10, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#bolus given — bolus after carbs suppresses") + func bolusAfterCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#pre-bolus — bolus inside pre-bolus window suppresses") + func preBolusNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 30, units: 2, relativeTo: now)] + ) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small bolus — bolus at or below limit is ignored") + func smallBolusIgnored() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs( + [carb(minutesAgo: 20, relativeTo: now)], + boluses: [bolus(minutesAgo: 18, units: 0.1, relativeTo: now)] + ) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#small carbs — carbs at or below limit do not fire") + func smallCarbsNoFire() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([carb(minutesAgo: 20, grams: 4, relativeTo: now)]) + + #expect(!cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } + + @Test("#future carbs — a future-dated entry does not mask a past one") + func futureCarbDoesNotMaskPast() { + resetNotified() + let now = Date() + let data = AlarmData.withCarbs([ + carb(minutesAgo: 20, relativeTo: now), + carb(minutesAgo: -30, relativeTo: now), + ]) + + #expect(cond.evaluate(alarm: .missedBolus(), data: data, now: now)) + } +}