Explorar el Código

Test: GlucoseAlertCoordinator breach/retract hysteresis

Extract the threshold comparisons into pure internal statics breached(...)
and shouldRetract(...) (behavior-preserving; the private queue methods stay
thin callers, recoveryMarginMgDL made internal) so the fire/clear math is
unit-testable. Pin exactly-at-threshold breaches, the 5 mg/dL recovery
margin (anti-flap), and urgent-low < low priority ordering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom hace 2 semanas
padre
commit
1e2fc8d41f

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -237,6 +237,7 @@
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; };
 		BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A002 /* AlertMuterTests.swift */; };
+		BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */; };
 		BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */; };
 		BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A008 /* GlucoseAlertTests.swift */; };
 		BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */; };
@@ -1243,6 +1244,7 @@
 		38FCF3F125E9028E0078B0D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorageTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A002 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertCoordinatorTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategoryTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A008 /* GlucoseAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertConfigurationTests.swift; sourceTree = "<group>"; };
@@ -3005,6 +3007,7 @@
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */,
 				BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */,
 				BD11A000000000000000A008 /* GlucoseAlertTests.swift */,
 				BD11A000000000000000A007 /* GlucoseAlertConfigurationTests.swift */,
@@ -5577,6 +5580,7 @@
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */,
 				BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */,
 				BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */,
 				BD11A001000000000000A007 /* GlucoseAlertConfigurationTests.swift in Sources */,

+ 45 - 17
Trio/Sources/Services/Alerts/GlucoseAlertCoordinator.swift

@@ -36,7 +36,40 @@ final class GlucoseAlertCoordinator: Injectable {
 
     /// 5 mg/dL recovery margin before we retract a fired alert. Prevents flap
     /// at the threshold boundary.
-    private static let recoveryMarginMgDL: Decimal = 5
+    static let recoveryMarginMgDL: Decimal = 5
+
+    /// Pure breach predicate. Low family (low/urgentLow/forecastedLow) breaches
+    /// when the value is at or below threshold; high breaches at or above.
+    /// Extracted for unit testing — the instance evaluators call through here.
+    static func breached(type: GlucoseAlertType, latestMgDL: Decimal, thresholdMgDL: Decimal) -> Bool {
+        switch type {
+        case .forecastedLow,
+             .low,
+             .urgentLow:
+            return latestMgDL <= thresholdMgDL
+        case .high:
+            return latestMgDL >= thresholdMgDL
+        }
+    }
+
+    /// Pure retract predicate. A fired low-family alert retracts once the value
+    /// recovers to threshold + margin; a high alert once it falls to
+    /// threshold - margin. Extracted for unit testing.
+    static func shouldRetract(
+        type: GlucoseAlertType,
+        latestMgDL: Decimal,
+        thresholdMgDL: Decimal,
+        recoveryMarginMgDL: Decimal = recoveryMarginMgDL
+    ) -> Bool {
+        switch type {
+        case .forecastedLow,
+             .low,
+             .urgentLow:
+            return latestMgDL >= thresholdMgDL + recoveryMarginMgDL
+        case .high:
+            return latestMgDL <= thresholdMgDL - recoveryMarginMgDL
+        }
+    }
 
     /// Readings older than this are considered stale and won't drive new
     /// alarms — matches `APSManager`'s loop-input freshness gate (12 min,
@@ -145,16 +178,14 @@ final class GlucoseAlertCoordinator: Injectable {
             return
         }
 
-        let breached: Bool
-        switch alarm.type {
-        case .low,
-             .urgentLow:
-            breached = latestMgDL <= alarm.thresholdMgDL
-        case .high:
-            breached = latestMgDL >= alarm.thresholdMgDL
-        case .forecastedLow:
+        if alarm.type == .forecastedLow {
             return // handled via determinationDidUpdate
         }
+        let breached = Self.breached(
+            type: alarm.type,
+            latestMgDL: latestMgDL,
+            thresholdMgDL: alarm.thresholdMgDL
+        )
 
         if breached {
             fireIfNeeded(alarm, valueMgDL: latestMgDL)
@@ -262,14 +293,11 @@ final class GlucoseAlertCoordinator: Injectable {
     private func shouldRetract(_ alarm: GlucoseAlert, latestMgDL: Decimal) -> Bool {
         dispatchPrecondition(condition: .onQueue(evaluationQueue))
         guard firingAlertIDs.contains(alarm.id) else { return false }
-        switch alarm.type {
-        case .forecastedLow,
-             .low,
-             .urgentLow:
-            return latestMgDL >= alarm.thresholdMgDL + Self.recoveryMarginMgDL
-        case .high:
-            return latestMgDL <= alarm.thresholdMgDL - Self.recoveryMarginMgDL
-        }
+        return Self.shouldRetract(
+            type: alarm.type,
+            latestMgDL: latestMgDL,
+            thresholdMgDL: alarm.thresholdMgDL
+        )
     }
 
     private func alertID(for alarm: GlucoseAlert) -> Alert.Identifier {

+ 52 - 0
TrioTests/GlucoseAlertCoordinatorTests.swift

@@ -0,0 +1,52 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Trio Alerts: GlucoseAlertCoordinator breach/retract") struct GlucoseAlertCoordinatorTests {
+    @Test("low breaches at or below threshold 70") func lowBreach() {
+        #expect(GlucoseAlertCoordinator.breached(type: .low, latestMgDL: 70, thresholdMgDL: 70))
+        #expect(!GlucoseAlertCoordinator.breached(type: .low, latestMgDL: 71, thresholdMgDL: 70))
+    }
+
+    @Test("low retracts only at threshold + margin (70 + 5)") func lowRetract() {
+        #expect(!GlucoseAlertCoordinator.shouldRetract(
+            type: .low, latestMgDL: 74, thresholdMgDL: 70, recoveryMarginMgDL: 5
+        ))
+        #expect(GlucoseAlertCoordinator.shouldRetract(
+            type: .low, latestMgDL: 75, thresholdMgDL: 70, recoveryMarginMgDL: 5
+        ))
+    }
+
+    @Test("urgentLow breaches at or below threshold 54") func urgentLowBreach() {
+        #expect(GlucoseAlertCoordinator.breached(type: .urgentLow, latestMgDL: 54, thresholdMgDL: 54))
+        #expect(!GlucoseAlertCoordinator.breached(type: .urgentLow, latestMgDL: 55, thresholdMgDL: 54))
+    }
+
+    @Test("high breaches at or above threshold 270") func highBreach() {
+        #expect(GlucoseAlertCoordinator.breached(type: .high, latestMgDL: 270, thresholdMgDL: 270))
+        #expect(!GlucoseAlertCoordinator.breached(type: .high, latestMgDL: 269, thresholdMgDL: 270))
+    }
+
+    @Test("high retracts only at threshold - margin (270 - 5)") func highRetract() {
+        #expect(!GlucoseAlertCoordinator.shouldRetract(
+            type: .high, latestMgDL: 266, thresholdMgDL: 270, recoveryMarginMgDL: 5
+        ))
+        #expect(GlucoseAlertCoordinator.shouldRetract(
+            type: .high, latestMgDL: 265, thresholdMgDL: 270, recoveryMarginMgDL: 5
+        ))
+    }
+
+    @Test("type priority order: urgentLow < low < forecastedLow < high") func priorityOrder() {
+        #expect(GlucoseAlertType.urgentLow.priority == 0)
+        #expect(GlucoseAlertType.low.priority == 1)
+        #expect(GlucoseAlertType.forecastedLow.priority == 2)
+        #expect(GlucoseAlertType.high.priority == 3)
+        #expect(GlucoseAlertType.urgentLow.priority < GlucoseAlertType.low.priority)
+    }
+
+    @Test("shouldRetract uses the default 5 mg/dL margin when omitted") func defaultMarginWiring() {
+        #expect(GlucoseAlertCoordinator.shouldRetract(type: .low, latestMgDL: 75, thresholdMgDL: 70))
+        #expect(!GlucoseAlertCoordinator.shouldRetract(type: .low, latestMgDL: 74, thresholdMgDL: 70))
+    }
+}