LowBGConditionTests.swift 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. // LoopFollow
  2. // LowBGConditionTests.swift
  3. import Foundation
  4. @testable import LoopFollow
  5. import Testing
  6. @Suite(.serialized)
  7. struct LowBGConditionTests {
  8. let cond = LowBGCondition()
  9. /// Builds a forward prediction series at 5-minute spacing.
  10. private func pred(_ values: [Int], from start: Date = Date()) -> [GlucoseValue] {
  11. values.enumerated().map { i, v in
  12. GlucoseValue(sgv: v, date: start.addingTimeInterval(Double(i) * 300))
  13. }
  14. }
  15. /// Builds a recent BG history (oldest .. newest) at 5-minute spacing ending now.
  16. private func history(_ values: [Int], endingAt now: Date = Date()) -> [GlucoseValue] {
  17. values.enumerated().map { i, v in
  18. let offset = Double(values.count - 1 - i) * 300
  19. return GlucoseValue(sgv: v, date: now.addingTimeInterval(-offset))
  20. }
  21. }
  22. /// Recent readings that are clearly above any low threshold. Used by the
  23. /// predictive tests so the persistence branch evaluates to `false` and the
  24. /// result reflects the predictive look-ahead alone.
  25. private var recentHigh: [GlucoseValue] { history([120, 120, 120]) }
  26. // MARK: - Loop (single forecast)
  27. @Test("#loop — predictive low within window fires")
  28. func loopPredictiveLowFires() {
  29. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15)
  30. // ceil(30/5) = 6 points looked at; index 5 dips to 75
  31. let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75]))
  32. #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
  33. }
  34. @Test("#loop — forecast low beyond window does not fire")
  35. func loopPredictiveLowBeyondWindow() {
  36. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15)
  37. // ceil(15/5) = 3 points looked at (120, 110, 100); the low only appears later
  38. let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75]))
  39. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  40. }
  41. @Test("#loop — forecast staying above threshold does not fire")
  42. func loopForecastAboveThreshold() {
  43. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
  44. let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 95, 90, 85]))
  45. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  46. }
  47. @Test("#loop — predictiveMinutes 0 disables look-ahead")
  48. func loopPredictiveDisabled() {
  49. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 0, persistentMinutes: 15)
  50. let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([60, 60, 60]))
  51. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  52. }
  53. // MARK: - Trio (lowest of four forecasts)
  54. @Test("#trio — combined forecast fires when one forecast dips low")
  55. func trioCombinedForecastFires() {
  56. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15)
  57. // ZT stays high, but the IOB forecast dips to 70 — the per-point minimum
  58. // must surface that dip so the alarm fires.
  59. let forecasts: [[Double]] = [
  60. [150, 150, 150, 150, 150, 150], // ZT
  61. [120, 110, 100, 90, 80, 70], // IOB
  62. [130, 125, 120, 118, 116, 115], // COB
  63. [140, 138, 136, 134, 132, 130], // UAM
  64. ]
  65. let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
  66. let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
  67. #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
  68. }
  69. @Test("#trio — combined forecast does not fire when all forecasts stay high")
  70. func trioCombinedForecastNoFire() {
  71. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
  72. let forecasts: [[Double]] = [
  73. [150, 150, 150, 150, 150, 150],
  74. [120, 110, 100, 95, 92, 90],
  75. [130, 125, 120, 118, 116, 115],
  76. [140, 138, 136, 134, 132, 130],
  77. ]
  78. let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
  79. let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
  80. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  81. }
  82. @Test("#trio — deep forecast below display floor still fires (not masked)")
  83. func trioDeepLowNotMasked() {
  84. let alarm = Alarm.low(belowBG: 70, predictiveMinutes: 30, persistentMinutes: 15)
  85. // A forecast dipping to 30 (below the 39 display floor) is passed through
  86. // raw, so the predictive-low alarm still fires on a genuine deep low.
  87. let forecasts: [[Double]] = [
  88. [150, 150, 150, 150],
  89. [120, 100, 60, 30],
  90. ]
  91. let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
  92. let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
  93. #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
  94. }
  95. // MARK: - Persistent / immediate low (device-agnostic)
  96. @Test("#persistent — all readings in window low fires")
  97. func persistentLowFires() {
  98. let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // window = 3 readings
  99. let data = AlarmData.withGlucose(readings: history([75, 72, 70]))
  100. #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
  101. }
  102. @Test("#persistent — one reading above threshold does not fire")
  103. func persistentLowOneAbove() {
  104. let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15)
  105. let data = AlarmData.withGlucose(readings: history([75, 95, 70]))
  106. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  107. }
  108. @Test("#persistent — not enough samples does not fire")
  109. func persistentNotEnoughSamples() {
  110. let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // needs 3
  111. let data = AlarmData.withGlucose(readings: history([70, 70]))
  112. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  113. }
  114. @Test("#no belowBG threshold never fires")
  115. func noThresholdNoFire() {
  116. let alarm = Alarm.low(belowBG: nil, predictiveMinutes: 30, persistentMinutes: 15)
  117. let data = AlarmData.withGlucose(readings: history([40, 40, 40]), prediction: pred([40, 40, 40]))
  118. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  119. }
  120. // MARK: - Immediate low (no persistence, no prediction)
  121. @Test("#immediate — latest reading at/below threshold fires")
  122. func immediateLowFires() {
  123. let alarm = Alarm.low(belowBG: 80)
  124. let data = AlarmData.withGlucose(readings: history([100, 90, 78]))
  125. #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
  126. }
  127. @Test("#immediate — latest reading above threshold does not fire")
  128. func immediateAboveThreshold() {
  129. let alarm = Alarm.low(belowBG: 80)
  130. let data = AlarmData.withGlucose(readings: history([85, 83, 82]))
  131. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  132. }
  133. @Test("#immediate — no readings does not fire even with a low forecast")
  134. func noReadingsNoFire() {
  135. let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30)
  136. let data = AlarmData.withGlucose(readings: [], prediction: pred([60, 60]))
  137. #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
  138. }
  139. // MARK: - Full pipeline (shouldFire, including the BG-limit gate)
  140. @Test("#pipeline — predictive low fires while BG is still above the threshold")
  141. func predictiveFiresThroughGate() {
  142. let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0)
  143. // The scenario predictive low exists for: BG hovering above the
  144. // threshold while the forecast dips below it.
  145. let data = AlarmData.withGlucose(
  146. readings: history([110, 109, 110]),
  147. prediction: pred([110, 101, 92, 82])
  148. )
  149. #expect(cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
  150. }
  151. @Test("#pipeline — BG above threshold with a high forecast does not fire")
  152. func aboveThresholdHighForecastNoFire() {
  153. let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0)
  154. let data = AlarmData.withGlucose(
  155. readings: history([110, 109, 110]),
  156. prediction: pred([110, 112, 115, 118])
  157. )
  158. #expect(!cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
  159. }
  160. @Test("#pipeline — BG-limit gate blocks other alarm types")
  161. func gateAppliesToOtherTypes() {
  162. var alarm = Alarm(type: .temporary)
  163. alarm.belowBG = 108
  164. let data = AlarmData.withGlucose(readings: history([110, 109, 110]))
  165. #expect(!TemporaryCondition().shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
  166. }
  167. }