Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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),
Expand All @@ -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?
// ────────────────────────────────
Expand Down
1 change: 1 addition & 0 deletions LoopFollow/Alarm/AlarmManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class AlarmManager {
BatteryCondition.self,
BatteryDropCondition.self,
FutureCarbsCondition.self,
MissedBolusCondition.self,
DBSizeCondition.self,
]
) {
Expand Down
13 changes: 11 additions & 2 deletions Tests/AlarmConditions/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,7 +169,7 @@ extension AlarmData {
)
}

static func withCarbs(_ carbs: [CarbSample]) -> Self {
static func withCarbs(_ carbs: [CarbSample], boluses: [BolusEntry] = []) -> Self {
AlarmData(
bgReadings: [],
predictionData: [],
Expand All @@ -176,7 +185,7 @@ extension AlarmData {
pumpInsertTime: nil,
latestPumpVolume: nil,
IOB: nil,
recentBoluses: [],
recentBoluses: boluses,
latestBattery: nil,
latestBatteryIsCharging: nil,
latestPumpBattery: nil,
Expand Down
108 changes: 108 additions & 0 deletions Tests/AlarmConditions/MissedBolusConditionTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}