From 90c5cb188466d7e245b107fbff830f60261462da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 10:00:21 +0200 Subject: [PATCH] Fix Missed Bolus alarm never firing MissedBolusCondition was not in the AlarmManager evaluator list, so the alarm could be created but was never checked. The condition also required the optional BG limit to be set, which it is not by default, and only looked at the last carb entry, which can be a future-dated one. The BG limit is now handled by the shared BG limit check like other alarms, and the most recent carb entry that is not in the future is used. --- .../AlarmCondition/MissedBolusCondition.swift | 14 +-- LoopFollow/Alarm/AlarmManager.swift | 1 + Tests/AlarmConditions/Helpers.swift | 13 ++- .../MissedBolusConditionTests.swift | 108 ++++++++++++++++++ 4 files changed, 125 insertions(+), 11 deletions(-) create mode 100644 Tests/AlarmConditions/MissedBolusConditionTests.swift 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)) + } +}