| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- // LoopFollow
- // LowBGConditionTests.swift
- import Foundation
- @testable import LoopFollow
- import Testing
- @Suite(.serialized)
- struct LowBGConditionTests {
- let cond = LowBGCondition()
- /// Builds a forward prediction series at 5-minute spacing.
- private func pred(_ values: [Int], from start: Date = Date()) -> [GlucoseValue] {
- values.enumerated().map { i, v in
- GlucoseValue(sgv: v, date: start.addingTimeInterval(Double(i) * 300))
- }
- }
- /// Builds a recent BG history (oldest .. newest) at 5-minute spacing ending now.
- private func history(_ values: [Int], endingAt now: Date = Date()) -> [GlucoseValue] {
- values.enumerated().map { i, v in
- let offset = Double(values.count - 1 - i) * 300
- return GlucoseValue(sgv: v, date: now.addingTimeInterval(-offset))
- }
- }
- /// Recent readings that are clearly above any low threshold. Used by the
- /// predictive tests so the persistence branch evaluates to `false` and the
- /// result reflects the predictive look-ahead alone.
- private var recentHigh: [GlucoseValue] { history([120, 120, 120]) }
- // MARK: - Loop (single forecast)
- @Test("#loop — predictive low within window fires")
- func loopPredictiveLowFires() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15)
- // ceil(30/5) = 6 points looked at; index 5 dips to 75
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75]))
- #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#loop — forecast low beyond window does not fire")
- func loopPredictiveLowBeyondWindow() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 15, persistentMinutes: 15)
- // ceil(15/5) = 3 points looked at (120, 110, 100); the low only appears later
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 90, 85, 75]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#loop — forecast staying above threshold does not fire")
- func loopForecastAboveThreshold() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([120, 110, 100, 95, 90, 85]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#loop — predictiveMinutes 0 disables look-ahead")
- func loopPredictiveDisabled() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 0, persistentMinutes: 15)
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: pred([60, 60, 60]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- // MARK: - Trio (lowest of four forecasts)
- @Test("#trio — combined forecast fires when one forecast dips low")
- func trioCombinedForecastFires() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30, persistentMinutes: 15)
- // ZT stays high, but the IOB forecast dips to 70 — the per-point minimum
- // must surface that dip so the alarm fires.
- let forecasts: [[Double]] = [
- [150, 150, 150, 150, 150, 150], // ZT
- [120, 110, 100, 90, 80, 70], // IOB
- [130, 125, 120, 118, 116, 115], // COB
- [140, 138, 136, 134, 132, 130], // UAM
- ]
- let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
- #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#trio — combined forecast does not fire when all forecasts stay high")
- func trioCombinedForecastNoFire() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 60, persistentMinutes: 15)
- let forecasts: [[Double]] = [
- [150, 150, 150, 150, 150, 150],
- [120, 110, 100, 95, 92, 90],
- [130, 125, 120, 118, 116, 115],
- [140, 138, 136, 134, 132, 130],
- ]
- let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#trio — deep forecast below display floor still fires (not masked)")
- func trioDeepLowNotMasked() {
- let alarm = Alarm.low(belowBG: 70, predictiveMinutes: 30, persistentMinutes: 15)
- // A forecast dipping to 30 (below the 39 display floor) is passed through
- // raw, so the predictive-low alarm still fires on a genuine deep low.
- let forecasts: [[Double]] = [
- [150, 150, 150, 150],
- [120, 100, 60, 30],
- ]
- let combined = MainViewController.lowestForecast(forecasts: forecasts, start: Date().timeIntervalSince1970)
- let data = AlarmData.withGlucose(readings: recentHigh, prediction: combined)
- #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- // MARK: - Persistent / immediate low (device-agnostic)
- @Test("#persistent — all readings in window low fires")
- func persistentLowFires() {
- let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // window = 3 readings
- let data = AlarmData.withGlucose(readings: history([75, 72, 70]))
- #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#persistent — one reading above threshold does not fire")
- func persistentLowOneAbove() {
- let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15)
- let data = AlarmData.withGlucose(readings: history([75, 95, 70]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#persistent — not enough samples does not fire")
- func persistentNotEnoughSamples() {
- let alarm = Alarm.low(belowBG: 80, persistentMinutes: 15) // needs 3
- let data = AlarmData.withGlucose(readings: history([70, 70]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#no belowBG threshold never fires")
- func noThresholdNoFire() {
- let alarm = Alarm.low(belowBG: nil, predictiveMinutes: 30, persistentMinutes: 15)
- let data = AlarmData.withGlucose(readings: history([40, 40, 40]), prediction: pred([40, 40, 40]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- // MARK: - Immediate low (no persistence, no prediction)
- @Test("#immediate — latest reading at/below threshold fires")
- func immediateLowFires() {
- let alarm = Alarm.low(belowBG: 80)
- let data = AlarmData.withGlucose(readings: history([100, 90, 78]))
- #expect(cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#immediate — latest reading above threshold does not fire")
- func immediateAboveThreshold() {
- let alarm = Alarm.low(belowBG: 80)
- let data = AlarmData.withGlucose(readings: history([85, 83, 82]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- @Test("#immediate — no readings does not fire even with a low forecast")
- func noReadingsNoFire() {
- let alarm = Alarm.low(belowBG: 80, predictiveMinutes: 30)
- let data = AlarmData.withGlucose(readings: [], prediction: pred([60, 60]))
- #expect(!cond.evaluate(alarm: alarm, data: data, now: Date()))
- }
- // MARK: - Full pipeline (shouldFire, including the BG-limit gate)
- @Test("#pipeline — predictive low fires while BG is still above the threshold")
- func predictiveFiresThroughGate() {
- let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0)
- // The scenario predictive low exists for: BG hovering above the
- // threshold while the forecast dips below it.
- let data = AlarmData.withGlucose(
- readings: history([110, 109, 110]),
- prediction: pred([110, 101, 92, 82])
- )
- #expect(cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
- }
- @Test("#pipeline — BG above threshold with a high forecast does not fire")
- func aboveThresholdHighForecastNoFire() {
- let alarm = Alarm.low(belowBG: 108, predictiveMinutes: 30, persistentMinutes: 0)
- let data = AlarmData.withGlucose(
- readings: history([110, 109, 110]),
- prediction: pred([110, 112, 115, 118])
- )
- #expect(!cond.shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
- }
- @Test("#pipeline — BG-limit gate blocks other alarm types")
- func gateAppliesToOtherTypes() {
- var alarm = Alarm(type: .temporary)
- alarm.belowBG = 108
- let data = AlarmData.withGlucose(readings: history([110, 109, 110]))
- #expect(!TemporaryCondition().shouldFire(alarm: alarm, data: data, now: Date(), config: .default))
- }
- }
|