Explorar el Código

Shared BolusSafetyValidator for APNS and Shortcut boluses

Extracts the safety checks previously inlined in the APNS remote-bolus
path into a BolusSafetyValidator service, and applies the same checks
to Shortcut-driven boluses: max bolus, max IOB, and a 20% recent-bolus
threshold.

APNS passes the payload timestamp as the lookback start so the recent-
bolus check still covers the interval between the command being sent
and now. Shortcuts omit the lookback start, so the check uses a
6-minute rolling window (recentBolusWindowMinutes).

Closes #531
Jonas Björkert hace 1 mes
padre
commit
364a4059b0

+ 24 - 0
Trio.xcodeproj/project.pbxproj

@@ -259,6 +259,7 @@
 		3BD9687C2D8DDD4600899469 /* SlideButton in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687B2D8DDD4600899469 /* SlideButton */; };
 		3BD9687F2D8DDD8800899469 /* CryptoSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 3BD9687E2D8DDD8800899469 /* CryptoSwift */; };
 		3BF85FE32E427312000D7351 /* IOBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BF85FE12E427312000D7351 /* IOBService.swift */; };
+		B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */; };
 		3E28F2AB2EB5337F00FB9EEB /* ConnectIQ in Frameworks */ = {isa = PBXBuildFile; productRef = 3E28F2AA2EB5337F00FB9EEB /* ConnectIQ */; };
 		3E54EF2C2E476DA40006F54D /* MedtrumKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; };
 		3E54EF2D2E476DA40006F54D /* MedtrumKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
@@ -413,6 +414,7 @@
 		BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0582D66189700B95AED /* TestAssembly.swift */; };
 		BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */; };
 		BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */; };
+		B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */; };
 		BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */; };
 		BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */; };
 		BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */; };
@@ -1100,6 +1102,7 @@
 		3BDEA2DC60EDE0A3CA54DC73 /* TargetsEditorProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TargetsEditorProvider.swift; sourceTree = "<group>"; };
 		3BF768BD6264FF7D71D66767 /* NightscoutConfigProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NightscoutConfigProvider.swift; sourceTree = "<group>"; };
 		3BF85FE12E427312000D7351 /* IOBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOBService.swift; sourceTree = "<group>"; };
+		B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidator.swift; sourceTree = "<group>"; };
 		3E54EF2B2E476DA40006F54D /* MedtrumKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MedtrumKit.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusDisplayThreshold.swift; sourceTree = "<group>"; };
 		3F60E97100041040446F44E7 /* PumpConfigStateModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PumpConfigStateModel.swift; sourceTree = "<group>"; };
@@ -1249,6 +1252,7 @@
 		BD8FC0582D66189700B95AED /* TestAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestAssembly.swift; sourceTree = "<group>"; };
 		BD8FC05A2D6618AF00B95AED /* DeterminationStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeterminationStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC05D2D6618CE00B95AED /* BolusCalculatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusCalculatorTests.swift; sourceTree = "<group>"; };
+		B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BolusSafetyValidatorTests.swift; sourceTree = "<group>"; };
 		BD8FC05F2D6619DB00B95AED /* CarbsStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0612D6619E600B95AED /* OverrideStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideStorageTests.swift; sourceTree = "<group>"; };
 		BD8FC0632D6619EF00B95AED /* TempTargetStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TempTargetStorageTests.swift; sourceTree = "<group>"; };
@@ -2069,6 +2073,7 @@
 				DDA9AC072D67291600E6F1A9 /* AppVersionChecker */,
 				CEB434E128B8F9BC00B70274 /* Bluetooth */,
 				BD7DB88C2D2C49FF003D3155 /* BolusCalculator */,
+				B015AFE22E500000000D7351 /* BolusSafety */,
 				3862CC2C2743F9DC00BF832C /* Calendar */,
 				E592A37E2CEEC046009A472C /* ContactImage */,
 				F90692A8274B7A980037068D /* HealthKit */,
@@ -2652,6 +2657,7 @@
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
+				B015AFE62E500000000D7351 /* BolusSafetyTests */,
 				BD8FC0552D66187700B95AED /* CoreDataTests */,
 				38FCF3F125E9028E0078B0D1 /* Info.plist */,
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
@@ -2686,6 +2692,14 @@
 			path = IOB;
 			sourceTree = "<group>";
 		};
+		B015AFE22E500000000D7351 /* BolusSafety */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE12E500000000D7351 /* BolusSafetyValidator.swift */,
+			);
+			path = BolusSafety;
+			sourceTree = "<group>";
+		};
 		4E8C7B59F8065047ECE20965 /* View */ = {
 			isa = PBXGroup;
 			children = (
@@ -3026,6 +3040,14 @@
 			path = BolusCalculatorTests;
 			sourceTree = "<group>";
 		};
+		B015AFE62E500000000D7351 /* BolusSafetyTests */ = {
+			isa = PBXGroup;
+			children = (
+				B015AFE42E500000000D7351 /* BolusSafetyValidatorTests.swift */,
+			);
+			path = BolusSafetyTests;
+			sourceTree = "<group>";
+		};
 		BDA25F1A2D26BCE800035F34 /* Views */ = {
 			isa = PBXGroup;
 			children = (
@@ -4452,6 +4474,7 @@
 				3811DEEA25CA063400A708ED /* SyncAccess.swift in Sources */,
 				190EBCC829FF13AA00BA767D /* UserInterfaceSettingsStateModel.swift in Sources */,
 				3BF85FE32E427312000D7351 /* IOBService.swift in Sources */,
+				B015AFE32E500000000D7351 /* BolusSafetyValidator.swift in Sources */,
 				DDF847EA2C5DABAC0049BB3B /* WatchConfigGarminView.swift in Sources */,
 				38BF021F25E7F0DE00579895 /* DeviceDataManager.swift in Sources */,
 				BD4E1A7A2D3681B700D21626 /* GlucoseTargetSetup.swift in Sources */,
@@ -4816,6 +4839,7 @@
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
+				B015AFE52E500000000D7351 /* BolusSafetyValidatorTests.swift in Sources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 1 - 0
Trio/Sources/Assemblies/ServiceAssembly.swift

@@ -29,5 +29,6 @@ final class ServiceAssembly: Assembly {
             }
         }
         container.register(IOBService.self) { r in BaseIOBService(resolver: r) }
+        container.register(BolusSafetyValidator.self) { r in BaseBolusSafetyValidator(resolver: r) }
     }
 }

+ 106 - 0
Trio/Sources/Services/BolusSafety/BolusSafetyValidator.swift

@@ -0,0 +1,106 @@
+import CoreData
+import Foundation
+import Swinject
+
+/// Shared safety checks applied to any bolus command originating outside the main bolus UI
+/// (remote notifications, Shortcuts, etc.). Keeps validation logic consistent across call sites.
+protocol BolusSafetyValidator {
+    /// - Parameter lookbackStart: start of the window used for the recent-bolus 20% check.
+    ///   Defaults to `now - BolusSafetyEvaluator.recentBolusWindowMinutes`. Callers that know when the
+    ///   command was originally issued (e.g. APNS payload timestamp) should pass that instead so the
+    ///   check covers any bolus since the command was sent.
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal
+}
+
+extension BolusSafetyValidator {
+    func validate(bolusAmount: Decimal) async throws -> BolusSafetyResult {
+        try await validate(bolusAmount: bolusAmount, lookbackStart: nil)
+    }
+}
+
+enum BolusSafetyResult: Equatable {
+    case allowed
+    case rejected(BolusSafetyRejection)
+}
+
+enum BolusSafetyRejection: Equatable {
+    case exceedsMaxBolus(maxBolus: Decimal)
+    case iobUnavailable
+    case exceedsMaxIOB(currentIOB: Decimal, maxIOB: Decimal)
+    case recentBolusWithinWindow(totalRecent: Decimal)
+}
+
+struct BolusSafetyInputs: Equatable {
+    let maxBolus: Decimal
+    let maxIOB: Decimal
+    let currentIOB: Decimal?
+    /// Sum of bolus amounts delivered within the recent-bolus window (see `BolusSafetyEvaluator.recentBolusWindowMinutes`).
+    let totalRecentBolus: Decimal
+}
+
+enum BolusSafetyEvaluator {
+    static let recentBolusThreshold: Decimal = 0.2
+    static let recentBolusWindowMinutes: Int = 6
+
+    static func evaluate(bolusAmount: Decimal, inputs: BolusSafetyInputs) -> BolusSafetyResult {
+        if bolusAmount > inputs.maxBolus {
+            return .rejected(.exceedsMaxBolus(maxBolus: inputs.maxBolus))
+        }
+        guard let currentIOB = inputs.currentIOB else {
+            return .rejected(.iobUnavailable)
+        }
+        if (currentIOB + bolusAmount) > inputs.maxIOB {
+            return .rejected(.exceedsMaxIOB(currentIOB: currentIOB, maxIOB: inputs.maxIOB))
+        }
+        if inputs.totalRecentBolus >= bolusAmount * recentBolusThreshold {
+            return .rejected(.recentBolusWithinWindow(totalRecent: inputs.totalRecentBolus))
+        }
+        return .allowed
+    }
+}
+
+final class BaseBolusSafetyValidator: BolusSafetyValidator, Injectable {
+    @Injected() private var settingsManager: SettingsManager!
+    @Injected() private var iobService: IOBService!
+
+    private let fetchContext: NSManagedObjectContext
+
+    init(resolver: Resolver) {
+        fetchContext = CoreDataStack.shared.newTaskContext()
+        injectServices(resolver)
+    }
+
+    func validate(bolusAmount: Decimal, lookbackStart: Date?) async throws -> BolusSafetyResult {
+        let windowStart = lookbackStart
+            ?? Date().addingTimeInterval(-Double(BolusSafetyEvaluator.recentBolusWindowMinutes * 60))
+        let inputs = BolusSafetyInputs(
+            maxBolus: settingsManager.pumpSettings.maxBolus,
+            maxIOB: settingsManager.preferences.maxIOB,
+            currentIOB: iobService.currentIOB,
+            totalRecentBolus: try await fetchTotalRecentBolusAmount(since: windowStart)
+        )
+        return BolusSafetyEvaluator.evaluate(bolusAmount: bolusAmount, inputs: inputs)
+    }
+
+    func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
+        let predicate = NSPredicate(
+            format: "type == %@ AND timestamp > %@",
+            PumpEventStored.EventType.bolus.rawValue,
+            date as NSDate
+        )
+        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
+            ofType: PumpEventStored.self,
+            onContext: fetchContext,
+            predicate: predicate,
+            key: "timestamp",
+            ascending: true,
+            fetchLimit: nil,
+            propertiesToFetch: ["bolus.amount"]
+        )
+        guard let bolusDictionaries = results as? [[String: Any]] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
+        }
+        return bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
+    }
+}

+ 22 - 45
Trio/Sources/Services/RemoteControl/TrioRemoteControl+Bolus.swift

@@ -2,42 +2,22 @@ import Foundation
 import HealthKit
 
 extension TrioRemoteControl {
-    internal func handleBolusCommand(_ payload: CommandPayload) async throws {
+    func handleBolusCommand(_ payload: CommandPayload) async throws {
         guard let bolusAmount = payload.bolusAmount else {
             await logError("Command rejected: bolus amount is missing or invalid.", payload: payload)
             return
         }
 
-        let maxBolus = await TrioApp.resolver.resolve(SettingsManager.self)?.pumpSettings.maxBolus ?? Decimal(0)
-
-        if bolusAmount > maxBolus {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units).",
-                payload: payload
-            )
-            return
-        }
-
-        let maxIOB = settings.preferences.maxIOB
-        guard let currentIOB = iobService.currentIOB else {
-            throw CoreDataError.fetchError(function: #function, file: #file)
-        }
-        if (currentIOB + bolusAmount) > maxIOB {
-            await logError(
-                "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units.",
-                payload: payload
-            )
-            return
-        }
-
-        let totalRecentBolusAmount =
-            try await fetchTotalRecentBolusAmount(since: Date(timeIntervalSince1970: payload.timestamp))
+        let validation = try await bolusSafetyValidator.validate(
+            bolusAmount: bolusAmount,
+            lookbackStart: Date(timeIntervalSince1970: payload.timestamp)
+        )
 
-        if totalRecentBolusAmount >= bolusAmount * 0.2 {
-            await logError(
-                "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent.",
-                payload: payload
-            )
+        switch validation {
+        case .allowed:
+            break
+        case let .rejected(reason):
+            await logError(reason.remoteCommandMessage(bolusAmount: bolusAmount), payload: payload)
             return
         }
 
@@ -79,22 +59,19 @@ extension TrioRemoteControl {
                 }
             }
     }
+}
 
-    private func fetchTotalRecentBolusAmount(since date: Date) async throws -> Decimal {
-        let predicate = NSPredicate(
-            format: "type == %@ AND timestamp > %@",
-            PumpEventStored.EventType.bolus.rawValue,
-            date as NSDate
-        )
-        let results: Any = try await CoreDataStack.shared.fetchEntitiesAsync(
-            ofType: PumpEventStored.self, onContext: pumpHistoryFetchContext, predicate: predicate, key: "timestamp",
-            ascending: true, fetchLimit: nil, propertiesToFetch: ["bolus.amount"]
-        )
-        guard let bolusDictionaries = results as? [[String: Any]] else {
-            await logError("Failed to cast fetched bolus events. Fetched entities type: \(type(of: results))")
-            throw CoreDataError.fetchError(function: #function, file: #file)
+private extension BolusSafetyRejection {
+    func remoteCommandMessage(bolusAmount: Decimal) -> String {
+        switch self {
+        case let .exceedsMaxBolus(maxBolus):
+            return "Command rejected: bolus amount (\(bolusAmount) units) exceeds the maximum allowed (\(maxBolus) units)."
+        case .iobUnavailable:
+            return "Command rejected: current IOB is not available."
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return "Command rejected: bolus amount (\(bolusAmount) units) would exceed max IOB (\(maxIOB) units). Current IOB: \(currentIOB) units."
+        case .recentBolusWithinWindow:
+            return "Command rejected: boluses totaling more than 20% of the requested amount have been delivered since the command was sent."
         }
-        let totalAmount = bolusDictionaries.compactMap { ($0["bolus.amount"] as? NSNumber)?.decimalValue }.reduce(0, +)
-        return totalAmount
     }
 }

+ 1 - 3
Trio/Sources/Services/RemoteControl/TrioRemoteControl.swift

@@ -10,15 +10,13 @@ class TrioRemoteControl: Injectable {
     @Injected() internal var nightscoutManager: NightscoutManager!
     @Injected() internal var overrideStorage: OverrideStorage!
     @Injected() internal var settings: SettingsManager!
-    @Injected() internal var iobService: IOBService!
+    @Injected() internal var bolusSafetyValidator: BolusSafetyValidator!
 
     private let timeWindow: TimeInterval = 600
 
-    internal let pumpHistoryFetchContext: NSManagedObjectContext
     internal let viewContext: NSManagedObjectContext
 
     private init() {
-        pumpHistoryFetchContext = CoreDataStack.shared.newTaskContext()
         viewContext = CoreDataStack.shared.persistentContainer.viewContext
         injectServices(TrioApp.resolver)
     }

+ 1 - 0
Trio/Sources/Shortcuts/BaseIntentsRequest.swift

@@ -17,6 +17,7 @@ import Swinject
     @Injected() var liveActivityManager: LiveActivityManager!
     @Injected() var pumpHistoryStorage: PumpHistoryStorage!
     @Injected() var iobService: IOBService!
+    @Injected() var bolusSafetyValidator: BolusSafetyValidator!
 
     let resolver: Resolver
 

+ 36 - 9
Trio/Sources/Shortcuts/Bolus/BolusIntentRequest.swift

@@ -4,25 +4,25 @@ import Foundation
 
 final class BolusIntentRequest: BaseIntentsRequest {
     func bolus(_ bolusAmount: Double) async throws -> String {
-        var bolusQuantity: Decimal = 0
         switch settingsManager.settings.bolusShortcut {
-        // Block boluses if they are disabled
         case .notAllowed:
             return String(
                 localized:
                 "Bolusing via Shortcuts is disabled in Trio settings."
             )
 
-        // Block any bolus attempted if it is larger than the max bolus in settings
         case .limitBolusMax:
-            if Decimal(bolusAmount) > settingsManager.pumpSettings.maxBolus {
-                return String(
-                    localized:
-                    "The bolus cannot be larger than the pump setting max bolus (\(settingsManager.pumpSettings.maxBolus.description))."
+            let requestedAmount = Decimal(bolusAmount)
+            let validation = try await bolusSafetyValidator.validate(bolusAmount: requestedAmount)
+
+            if case let .rejected(reason) = validation {
+                return reason.shortcutMessage(
+                    requestedAmount: requestedAmount,
+                    pumpMaxBolus: settingsManager.pumpSettings.maxBolus
                 )
-            } else {
-                bolusQuantity = apsManager.roundBolus(amount: Decimal(bolusAmount))
             }
+
+            let bolusQuantity = apsManager.roundBolus(amount: requestedAmount)
             await apsManager.enactBolus(amount: Double(bolusQuantity), isSMB: false, callback: nil)
             return String(
                 localized:
@@ -52,3 +52,30 @@ final class BolusIntentRequest: BaseIntentsRequest {
         }
     }
 }
+
+private extension BolusSafetyRejection {
+    func shortcutMessage(requestedAmount: Decimal, pumpMaxBolus: Decimal) -> String {
+        switch self {
+        case .exceedsMaxBolus:
+            return String(
+                localized:
+                "The bolus cannot be larger than the pump setting max bolus (\(pumpMaxBolus.description))."
+            )
+        case .iobUnavailable:
+            return String(
+                localized:
+                "Bolus blocked: current IOB is not available."
+            )
+        case let .exceedsMaxIOB(currentIOB, maxIOB):
+            return String(
+                localized:
+                "Bolus blocked: a \(requestedAmount.formatted()) U bolus would exceed max IOB (\(maxIOB.formatted()) U). Current IOB: \(currentIOB.formatted()) U."
+            )
+        case .recentBolusWithinWindow:
+            return String(
+                localized:
+                "Bolus blocked: a significant bolus was delivered within the last \(BolusSafetyEvaluator.recentBolusWindowMinutes) minutes."
+            )
+        }
+    }
+}

+ 113 - 0
TrioTests/BolusSafetyTests/BolusSafetyValidatorTests.swift

@@ -0,0 +1,113 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Bolus Safety Validator Tests") struct BolusSafetyValidatorTests: Injectable {
+    @Injected() var validator: BolusSafetyValidator!
+    let resolver = TrioApp().resolver
+
+    init() {
+        injectServices(resolver)
+    }
+
+    @Test("Validator resolves from the service container") func testValidatorResolves() {
+        #expect(validator != nil, "BolusSafetyValidator should be registered in ServiceAssembly")
+        #expect(validator is BaseBolusSafetyValidator, "Validator should be of type BaseBolusSafetyValidator")
+    }
+
+    @Test("Allows bolus when all inputs are within limits") func testAllowed() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 1,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Rejects when amount exceeds max bolus") func testExceedsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Rejects when current IOB is unavailable") func testIOBUnavailable() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 1, inputs: inputs)
+        #expect(result == .rejected(.iobUnavailable))
+    }
+
+    @Test("Rejects when amount would exceed max IOB") func testExceedsMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 2.5, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxIOB(currentIOB: 3, maxIOB: 5)))
+    }
+
+    @Test("Rejects when recent bolus totals >= 20% of requested amount") func testRecentBolusWithinWindow() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 1.0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs)
+        #expect(result == .rejected(.recentBolusWithinWindow(totalRecent: 1.0)))
+    }
+
+    @Test("Allows when recent bolus total is below 20% threshold") func testRecentBolusBelowThreshold() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0.99
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Max bolus check runs before IOB check") func testCheckOrdering() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: nil,
+            totalRecentBolus: 0
+        )
+        let result = BolusSafetyEvaluator.evaluate(bolusAmount: 6, inputs: inputs)
+        #expect(result == .rejected(.exceedsMaxBolus(maxBolus: 5)))
+    }
+
+    @Test("Equal-to-max-bolus is allowed") func testEqualsMaxBolus() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 5,
+            maxIOB: 10,
+            currentIOB: 0,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 5, inputs: inputs) == .allowed)
+    }
+
+    @Test("Current IOB plus amount equal to max IOB is allowed") func testEqualToMaxIOB() {
+        let inputs = BolusSafetyInputs(
+            maxBolus: 10,
+            maxIOB: 5,
+            currentIOB: 3,
+            totalRecentBolus: 0
+        )
+        #expect(BolusSafetyEvaluator.evaluate(bolusAmount: 2, inputs: inputs) == .allowed)
+    }
+}