diff --git a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift index 8e60bcb9d..11dd7f027 100644 --- a/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/AlarmCondition.swift @@ -22,7 +22,8 @@ extension AlarmCondition { func passesBGLimits(alarm: Alarm, data: AlarmData) -> Bool { let bgReading = data.bgReadings.last?.sgv let haveBG = (bgReading ?? 0) > 0 - let bgValue = Double(bgReading ?? 0) + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let bgValue = normalize(Double(bgReading ?? 0)) // ──────────────────────────────────── // 1. BG-based alarms always need data @@ -46,7 +47,7 @@ extension AlarmCondition { // BG-based alarms already bailed out above. guard haveBG else { return false } - switch (alarm.belowBG, alarm.aboveBG) { + switch (alarm.belowBG.map(normalize), alarm.aboveBG.map(normalize)) { case let (lo?, hi?): return lo < hi ? (bgValue <= lo || bgValue >= hi) // fire outside band : (hi <= bgValue && bgValue <= lo) // fire inside band diff --git a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift index 6ba8b9b47..8884e6e96 100644 --- a/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/HighBGCondition.swift @@ -14,8 +14,11 @@ struct HighBGCondition: AlarmCondition { // ──────────────────────────────── guard let high = alarm.aboveBG else { return false } + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let highLimit = normalize(high) + func isHigh(_ g: GlucoseValue) -> Bool { - g.sgv > 0 && Double(g.sgv) >= high + g.sgv > 0 && normalize(Double(g.sgv)) >= highLimit } // we already know from `passesBGLimits` that the **latest** reading is ≥ high, diff --git a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift index cb4610f74..eb5e7a029 100644 --- a/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/LowBGCondition.swift @@ -22,8 +22,11 @@ struct LowBGCondition: AlarmCondition { guard let belowBG = alarm.belowBG else { return false } guard let latest = data.bgReadings.last, latest.sgv > 0 else { return false } + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let lowLimit = normalize(belowBG) + func isLow(_ g: GlucoseValue) -> Bool { - g.sgv > 0 && Double(g.sgv) <= belowBG + g.sgv > 0 && normalize(Double(g.sgv)) <= lowLimit } // ──────────────────────────────── diff --git a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift index 53bbeabc4..fcd9a4a48 100644 --- a/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift +++ b/LoopFollow/Alarm/AlarmCondition/MissedBolusCondition.swift @@ -37,8 +37,12 @@ struct MissedBolusCondition: AlarmCondition { guard carb.grams > minCarbGr else { return false } // – ignore if BG is low + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision if let latestBG = data.bgReadings.last, - Double(latestBG.sgv) <= minBG { return false } + normalize(Double(latestBG.sgv)) <= normalize(minBG) + { + return false + } // ──────────────────────────────── // 2. already alerted for this carb? diff --git a/LoopFollow/Charts/BGChartModel.swift b/LoopFollow/Charts/BGChartModel.swift index 8acc2859c..3f2cdc74d 100644 --- a/LoopFollow/Charts/BGChartModel.swift +++ b/LoopFollow/Charts/BGChartModel.swift @@ -261,12 +261,10 @@ final class BGChartModel: ObservableObject { } private func colorFor(_ sgv: Int, thresholds: (low: Double, high: Double)) -> Color { - if Double(sgv) >= thresholds.high { - return .yellow - } else if Double(sgv) <= thresholds.low { - return .red - } else { - return .green + switch UnitSettingsStore.shared.glucoseBand(for: Double(sgv), thresholds: thresholds) { + case .high: return .yellow + case .low: return .red + case .inRange: return .green } } diff --git a/LoopFollow/Contact/ContactColorMode.swift b/LoopFollow/Contact/ContactColorMode.swift index 7d146040c..25ec4bc8a 100644 --- a/LoopFollow/Contact/ContactColorMode.swift +++ b/LoopFollow/Contact/ContactColorMode.swift @@ -16,21 +16,16 @@ enum ContactColorMode: String, Codable, CaseIterable { } } - /// Returns the appropriate text color based on the mode and BG value + /// Returns the appropriate text color based on the mode and BG value (in mg/dL). func textColor(for bgValue: Double, staticColor: UIColor) -> UIColor { switch self { case .staticColor: return staticColor case .dynamic: - let highLine = Storage.shared.highLine.value - let lowLine = Storage.shared.lowLine.value - - if bgValue >= highLine { - return .systemYellow - } else if bgValue <= lowLine { - return .systemRed - } else { - return .systemGreen + switch UnitSettingsStore.shared.glucoseBand(for: bgValue) { + case .high: return .systemYellow + case .low: return .systemRed + case .inRange: return .systemGreen } } } diff --git a/LoopFollow/Controllers/Stats.swift b/LoopFollow/Controllers/Stats.swift index 15447db9d..29358dc33 100644 --- a/LoopFollow/Controllers/Stats.swift +++ b/LoopFollow/Controllers/Stats.swift @@ -27,17 +27,13 @@ class StatsData { coefficientOfVariation = 0.0 let thresholds = UnitSettingsStore.shared.effectiveThresholds() - let lowThreshold = thresholds.low - let highThreshold = thresholds.high for i in 0 ..< bgData.count { // Set low/range/high counts for pie chart and %'s - if Double(bgData[i].sgv) < lowThreshold { - countLow += 1 - } else if Double(bgData[i].sgv) > highThreshold { - countHigh += 1 - } else { - countRange += 1 + switch UnitSettingsStore.shared.glucoseBand(for: Double(bgData[i].sgv), thresholds: thresholds) { + case .low: countLow += 1 + case .high: countHigh += 1 + case .inRange: countRange += 1 } // set total bg for average diff --git a/LoopFollow/Helpers/Localizer.swift b/LoopFollow/Helpers/Localizer.swift index b615a4cf6..5e60ddbdb 100644 --- a/LoopFollow/Helpers/Localizer.swift +++ b/LoopFollow/Helpers/Localizer.swift @@ -74,6 +74,23 @@ final class UnitSettingsStore { } } + /// Rounds a mg/dL value to the display precision of the current unit, so colors, + /// stats and alarms match the number shown. Apply to both the reading and the threshold. + func normalizeToDisplayPrecision(_ mgdl: Double) -> Double { + switch glucoseUnit { + case .mgdL: + return mgdl + case .mmolL: + return GlucoseConversion.toDisplayedMmol(mgdl) + } + } + + /// Band of a mg/dL reading against the selected range (or `thresholds` when given). + func glucoseBand(for mgdl: Double, thresholds: (low: Double, high: Double)? = nil) -> GlucoseBand { + let range = thresholds ?? effectiveThresholds() + return GlucoseBand.of(mgdl, low: range.low, high: range.high, mmol: glucoseUnit == .mmolL) + } + var glycemicMetricMode: GlycemicMetricMode { get { Storage.shared.showGMI.value ? .gmi : .ehba1c diff --git a/LoopFollow/Helpers/Views/BGPicker.swift b/LoopFollow/Helpers/Views/BGPicker.swift index 86c99a706..0a7c9fd20 100644 --- a/LoopFollow/Helpers/Views/BGPicker.swift +++ b/LoopFollow/Helpers/Views/BGPicker.swift @@ -17,9 +17,10 @@ struct BGPicker: View { private var allValues: [Double] { if unit == .millimolesPerLiter { let step = 0.1 - let lower = ceil((range.lowerBound / 18) / step) * step - let upper = floor((range.upperBound / 18) / step) * step - return stride(from: lower, through: upper, by: step).map { $0 * 18 } + let factor = GlucoseConversion.mmolToMgDl + let lower = ceil((range.lowerBound / factor) / step) * step + let upper = floor((range.upperBound / factor) / step) * step + return stride(from: lower, through: upper, by: step).map { $0 * factor } } else { return Array(stride(from: range.lowerBound, through: range.upperBound, diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..aede837a4 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -5,6 +5,7 @@ #if !targetEnvironment(macCatalyst) @preconcurrency import ActivityKit +import Combine import Foundation import os import UIKit @@ -42,6 +43,16 @@ final class LiveActivityManager { ) startPushToStartTokenObservation() startActivityUpdatesObservation() + + Publishers.Merge( + Storage.shared.lowLine.$value.map { _ in () }, + Storage.shared.highLine.$value.map { _ in () } + ) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.refreshFromCurrentState(reason: "thresholds changed") + } + .store(in: &cancellables) } // MARK: - Push-to-start observation @@ -182,10 +193,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) seq += 1 @@ -430,6 +438,7 @@ final class LiveActivityManager { /// and arrives within a few seconds of the first request. private static let pushToStartTokenRetryDelay: TimeInterval = 10 + private var cancellables = Set() private(set) var current: Activity? private var stateObserverTask: Task? private var updateTask: Task? @@ -624,10 +633,7 @@ final class LiveActivityManager { let provider = StorageCurrentGlucoseStateProvider() if let snapshot = GlucoseSnapshotBuilder.build(from: provider) { - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() LAAppGroupSettings.setDisplayName( Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? "LoopFollow", show: Storage.shared.showDisplayName.value @@ -904,6 +910,12 @@ final class LiveActivityManager { } } + /// Publishes the range for the selected time-in-range mode, matching the chart and BG header. + private func writeThresholds() { + let thresholds = UnitSettingsStore.shared.effectiveThresholds() + LAAppGroupSettings.setThresholds(lowMgdl: thresholds.low, highMgdl: thresholds.high) + } + private func performRefresh(reason: String) { let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { @@ -928,12 +940,10 @@ final class LiveActivityManager { let forceRefreshNeeded = timeSinceLastUpdate >= 5 * 60 // Capture dedup result BEFORE saving so the store comparison is valid. let snapshotUnchanged = GlucoseSnapshotStore.shared.load() == snapshot + let thresholdsChanged = LAAppGroupSettings.thresholdsMgdl() != UnitSettingsStore.shared.effectiveThresholds() // Store + Watch: always update, independent of LA state. - LAAppGroupSettings.setThresholds( - lowMgdl: Storage.shared.lowLine.value, - highMgdl: Storage.shared.highLine.value, - ) + writeThresholds() GlucoseSnapshotStore.shared.save(snapshot) // WatchConnectivityManager.shared.send(snapshot: snapshot) @@ -946,7 +956,7 @@ final class LiveActivityManager { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — dismissedByUser=true reason=\(reason)") return } - guard !snapshotUnchanged || forceRefreshNeeded else { return } + guard !snapshotUnchanged || thresholdsChanged || forceRefreshNeeded else { return } guard ActivityAuthorizationInfo().areActivitiesEnabled else { LogManager.shared.log(category: .general, message: "[LA] refresh: LA update skipped — areActivitiesEnabled=false reason=\(reason)") return diff --git a/LoopFollow/Stats/GRI/GRICalculator.swift b/LoopFollow/Stats/GRI/GRICalculator.swift index 1d84e17cc..35c45c2fc 100644 --- a/LoopFollow/Stats/GRI/GRICalculator.swift +++ b/LoopFollow/Stats/GRI/GRICalculator.swift @@ -25,16 +25,23 @@ class GRICalculator { var highCount = 0 var vHighCount = 0 + // Compare at display precision so readings that show the same value share a band. + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let vLowLimit = normalize(vLowThreshold) + let lowLimit = normalize(lowThreshold) + let highLimit = normalize(highThreshold) + let vHighLimit = normalize(vHighThreshold) + for reading in bgData { - let glucose = Double(reading.sgv) + let glucose = normalize(Double(reading.sgv)) - if glucose < vLowThreshold { + if glucose < vLowLimit { vLowCount += 1 - } else if glucose < lowThreshold { + } else if glucose < lowLimit { lowCount += 1 - } else if glucose > vHighThreshold { + } else if glucose > vHighLimit { vHighCount += 1 - } else if glucose > highThreshold { + } else if glucose > highLimit { highCount += 1 } } diff --git a/LoopFollow/Stats/TIR/TIRCalculator.swift b/LoopFollow/Stats/TIR/TIRCalculator.swift index b387b5702..0c1ed1ff5 100644 --- a/LoopFollow/Stats/TIR/TIRCalculator.swift +++ b/LoopFollow/Stats/TIR/TIRCalculator.swift @@ -91,7 +91,7 @@ class TIRCalculator { return tirPoints } - private static func calculatePercentages(readings: [Double], + static func calculatePercentages(readings: [Double], veryLowThreshold: Double, lowThreshold: Double, highThreshold: Double, @@ -108,14 +108,22 @@ class TIRCalculator { var highCount = 0 var veryHighCount = 0 - for glucose in readings { - if glucose < veryLowThreshold { + // Compare at display precision so readings that show the same value share a band. + let normalize = UnitSettingsStore.shared.normalizeToDisplayPrecision + let veryLowLimit = normalize(veryLowThreshold) + let lowLimit = normalize(lowThreshold) + let highLimit = normalize(highThreshold) + let veryHighLimit = normalize(veryHighThreshold) + + for reading in readings { + let glucose = normalize(reading) + if glucose < veryLowLimit { veryLowCount += 1 - } else if glucose < lowThreshold { + } else if glucose < lowLimit { lowCount += 1 - } else if glucose > veryHighThreshold { + } else if glucose > veryHighLimit { veryHighCount += 1 - } else if glucose > highThreshold { + } else if glucose > highLimit { highCount += 1 } else { inRangeCount += 1 diff --git a/LoopFollow/ViewControllers/MainViewController.swift b/LoopFollow/ViewControllers/MainViewController.swift index 06c5e1f76..61a84ff12 100644 --- a/LoopFollow/ViewControllers/MainViewController.swift +++ b/LoopFollow/ViewControllers/MainViewController.swift @@ -269,7 +269,9 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { Storage.shared.units.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.useIFCC.$value.map { _ in () }.eraseToAnyPublisher(), Storage.shared.showGMI.$value.map { _ in () }.eraseToAnyPublisher(), - Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher() + Storage.shared.showStdDev.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.lowLine.$value.map { _ in () }.eraseToAnyPublisher(), + Storage.shared.highLine.$value.map { _ in () }.eraseToAnyPublisher() ) .receive(on: DispatchQueue.main) .sink { [weak self] _ in @@ -282,6 +284,8 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { .sink { [weak self] _ in self?.updateBGGraphSettings() self?.updateBGGraph() + LiveActivityManager.shared.refreshFromCurrentState(reason: "range mode changed") + self?.updateStats() } .store(in: &cancellables) @@ -828,13 +832,10 @@ class MainViewController: UIViewController, UNUserNotificationCenterDelegate { if bgData.count > 0 { let latestBG = bgData[bgData.count - 1].sgv if Storage.shared.colorBGText.value { - let thresholds = UnitSettingsStore.shared.effectiveThresholds() - if Double(latestBG) >= thresholds.high { - Observable.shared.bgTextColor.value = .yellow - } else if Double(latestBG) <= thresholds.low { - Observable.shared.bgTextColor.value = .red - } else { - Observable.shared.bgTextColor.value = .green + switch UnitSettingsStore.shared.glucoseBand(for: Double(latestBG)) { + case .high: Observable.shared.bgTextColor.value = .yellow + case .low: Observable.shared.bgTextColor.value = .red + case .inRange: Observable.shared.bgTextColor.value = .green } } else { Observable.shared.bgTextColor.value = .primary diff --git a/LoopFollowLAExtension/LoopFollowLiveActivity.swift b/LoopFollowLAExtension/LoopFollowLiveActivity.swift index 6d438531e..ca3312588 100644 --- a/LoopFollowLAExtension/LoopFollowLiveActivity.swift +++ b/LoopFollowLAExtension/LoopFollowLiveActivity.swift @@ -773,36 +773,39 @@ private enum LAFormat { // MARK: - Threshold-driven colors private enum LAColors { + private static func band(for snapshot: GlucoseSnapshot, low: Double, high: Double) -> GlucoseBand { + GlucoseBand.of(snapshot.glucose, low: low, high: high, mmol: snapshot.unit == .mmol) + } + static func backgroundTint(for snapshot: GlucoseSnapshot) -> Color { let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() let low = t.low let high = t.high - if mgdl < low { - let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / (low - 54.0)) + switch band(for: snapshot, low: low, high: high) { + case .low: + let raw = 0.48 + (0.85 - 0.48) * ((low - mgdl) / max(low - 54.0, 1)) let opacity = min(max(raw, 0.48), 0.85) return Color(uiColor: UIColor.systemRed).opacity(opacity) - } else if mgdl > high { - let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / (324.0 - high)) + case .high: + let raw = 0.44 + (0.85 - 0.44) * ((mgdl - high) / max(324.0 - high, 1)) let opacity = min(max(raw, 0.44), 0.85) return Color(uiColor: UIColor.systemOrange).opacity(opacity) - } else { + case .inRange: return Color(uiColor: UIColor.systemGreen).opacity(0.36) } } static func keyline(for snapshot: GlucoseSnapshot) -> Color { - let mgdl = snapshot.glucose let t = LAAppGroupSettings.thresholdsMgdl() - let low = t.low - let high = t.high - if mgdl < low { + switch band(for: snapshot, low: t.low, high: t.high) { + case .low: return Color(uiColor: UIColor.systemRed) - } else if mgdl > high { + case .high: return Color(uiColor: UIColor.systemOrange) - } else { + case .inRange: return Color(uiColor: UIColor.systemGreen) } } diff --git a/Shared/GlucoseConversion.swift b/Shared/GlucoseConversion.swift index dab205bfa..7fac1fb06 100644 --- a/Shared/GlucoseConversion.swift +++ b/Shared/GlucoseConversion.swift @@ -10,4 +10,32 @@ enum GlucoseConversion { static func toMmol(_ mgdl: Double) -> Double { mgdl * mgDlToMmolL } + + /// Converts mg/dL to mmol/L rounded to the one-decimal precision used for display. + static func toDisplayedMmol(_ mgdl: Double) -> Double { + (toMmol(mgdl) * 10).rounded() / 10 + } +} + +/// Which side of the low/high lines a reading falls on. +enum GlucoseBand { + case low + case inRange + case high + + /// Classifies a mg/dL reading at the precision shown to the user, so the result + /// always matches the displayed number. A reading on a line is in range. + static func of(_ mgdl: Double, low: Double, high: Double, mmol: Bool) -> GlucoseBand { + let value = mmol ? GlucoseConversion.toDisplayedMmol(mgdl) : mgdl + let lowLimit = mmol ? GlucoseConversion.toDisplayedMmol(low) : low + let highLimit = mmol ? GlucoseConversion.toDisplayedMmol(high) : high + + if value < lowLimit { + return .low + } + if value > highLimit { + return .high + } + return .inRange + } } diff --git a/Tests/AlarmConditions/BGDisplayPrecisionTests.swift b/Tests/AlarmConditions/BGDisplayPrecisionTests.swift new file mode 100644 index 000000000..18bb4f0fc --- /dev/null +++ b/Tests/AlarmConditions/BGDisplayPrecisionTests.swift @@ -0,0 +1,94 @@ +// LoopFollow +// BGDisplayPrecisionTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +/// In mmol/L, two mg/dL readings that display the same value must get the same +/// alarm result, and a threshold picked as X.X mmol/L must fire for a reading +/// that displays as X.X, regardless of which conversion factor stored it. +@Suite(.serialized) +struct BGDisplayPrecisionTests { + private func withMmol(_ body: () -> Void) { + let previous = Storage.shared.units.value + Storage.shared.units.value = GlucoseDisplayUnit.mmolL.rawValue + defer { Storage.shared.units.value = previous } + body() + } + + private func highAlarm(aboveBG: Double) -> Alarm { + var alarm = Alarm(type: .high) + alarm.aboveBG = aboveBG + return alarm + } + + private func fires(_ alarm: Alarm, sgv: Int) -> Bool { + let data = AlarmData.withReadings([sgv]) + if alarm.type == .high { + return HighBGCondition().evaluate(alarm: alarm, data: data, now: Date()) + } + return LowBGCondition().evaluate(alarm: alarm, data: data, now: Date()) + } + + @Test("high 10.0 mmol/L fires for both mg/dL readings that display 10.0") + func highBoundaryDisplaysAsLimit() { + withMmol { + // 10.0 mmol/L stored with the shared factor and with the old x18 picker factor. + for stored in [10.0 * GlucoseConversion.mmolToMgDl, 10.0 * 18] { + let alarm = highAlarm(aboveBG: stored) + #expect(!fires(alarm, sgv: 179)) + #expect(fires(alarm, sgv: 180)) + #expect(fires(alarm, sgv: 181)) + #expect(fires(alarm, sgv: 182)) + } + } + } + + @Test("high 7.8 mmol/L fires at 140, which displays 7.8, even when stored as 140.4") + func highLegacyPickerValue() { + withMmol { + let alarm = highAlarm(aboveBG: 7.8 * 18) + #expect(!fires(alarm, sgv: 139)) + #expect(fires(alarm, sgv: 140)) + #expect(fires(alarm, sgv: 141)) + } + } + + @Test("low 3.9 mmol/L fires for both mg/dL readings that display 3.9") + func lowBoundaryDisplaysAsLimit() { + withMmol { + for stored in [3.9 * GlucoseConversion.mmolToMgDl, 3.9 * 18] { + let alarm = Alarm.low(belowBG: stored) + #expect(fires(alarm, sgv: 69)) + #expect(fires(alarm, sgv: 70)) + #expect(fires(alarm, sgv: 71)) + #expect(!fires(alarm, sgv: 72)) + } + } + } + + @Test("mg/dL keeps exact integer comparison") + func mgdlUnchanged() { + let previous = Storage.shared.units.value + Storage.shared.units.value = GlucoseDisplayUnit.mgdL.rawValue + defer { Storage.shared.units.value = previous } + + #expect(fires(highAlarm(aboveBG: 180), sgv: 180)) + #expect(!fires(highAlarm(aboveBG: 180), sgv: 179)) + #expect(fires(Alarm.low(belowBG: 70), sgv: 70)) + #expect(!fires(Alarm.low(belowBG: 70), sgv: 71)) + } + + @Test("every mmol/L picker value round-trips to the value that was picked") + func pickerValuesRoundTrip() { + withMmol { + for tenths in 20 ... 400 { + let picked = Double(tenths) / 10 + let stored = picked * GlucoseConversion.mmolToMgDl + let normalized = UnitSettingsStore.shared.normalizeToDisplayPrecision(stored) + #expect(Int((normalized * 10).rounded()) == tenths) + } + } + } +} diff --git a/Tests/GlucoseBandTests.swift b/Tests/GlucoseBandTests.swift new file mode 100644 index 000000000..89a2319aa --- /dev/null +++ b/Tests/GlucoseBandTests.swift @@ -0,0 +1,35 @@ +// LoopFollow +// GlucoseBandTests.swift + +@testable import LoopFollow +import Testing + +struct GlucoseBandTests { + @Test("mg/dL: a reading on a line is in range") + func mgdlBoundaries() { + #expect(GlucoseBand.of(69, low: 70, high: 180, mmol: false) == .low) + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: false) == .inRange) + #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: false) == .high) + } + + @Test("mmol/L: readings that display the same value get the same band") + func mmolSameDisplayedValue() { + // 70 and 71 mg/dL both display 3.9; 180 and 181 both display 10.0. + #expect(GlucoseBand.of(69, low: 70, high: 180, mmol: true) == .low) // 3.8 + #expect(GlucoseBand.of(70, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(71, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(180, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(181, low: 70, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(182, low: 70, high: 180, mmol: true) == .high) // 10.1 + } + + @Test("mmol/L: a line stored with the old x18 picker factor behaves like the picked value") + func mmolLegacyPickerLine() { + // 3.9 mmol/L picked with x18 is 70.2 mg/dL; 10.0 mmol/L is 180.0. + #expect(GlucoseBand.of(69, low: 70.2, high: 180, mmol: true) == .low) + #expect(GlucoseBand.of(70, low: 70.2, high: 180, mmol: true) == .inRange) + #expect(GlucoseBand.of(71, low: 70.2, high: 180, mmol: true) == .inRange) + } +} diff --git a/Tests/StatsBoundaryTests.swift b/Tests/StatsBoundaryTests.swift new file mode 100644 index 000000000..ffb0dfd43 --- /dev/null +++ b/Tests/StatsBoundaryTests.swift @@ -0,0 +1,70 @@ +// LoopFollow +// StatsBoundaryTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +/// TIR and GRI use inclusive bands (70-180 is in range), compared at the display +/// precision of the current unit. +@Suite(.serialized) +struct StatsBoundaryTests { + private func withUnit(_ unit: GlucoseDisplayUnit, _ body: () -> Void) { + let previous = Storage.shared.units.value + Storage.shared.units.value = unit.rawValue + defer { Storage.shared.units.value = previous } + body() + } + + private let readings: [Double] = [53, 54, 69, 70, 71, 180, 181, 182, 250, 251] + + private func tir() -> (veryLow: Double, low: Double, inRange: Double, high: Double, veryHigh: Double) { + TIRCalculator.calculatePercentages( + readings: readings, + veryLowThreshold: 54, + lowThreshold: 70, + highThreshold: 180, + veryHighThreshold: 250 + ) + } + + @Test("TIR mg/dL: 70 and 180 are in range, 54 and 250 are not extreme") + func tirMgdl() { + withUnit(.mgdL) { + let p = tir() + #expect(p.veryLow == 10) // 53 + #expect(p.low == 20) // 54, 69 + #expect(p.inRange == 30) // 70, 71, 180 + #expect(p.high == 30) // 181, 182, 250 + #expect(p.veryHigh == 10) // 251 + } + } + + @Test("TIR mmol/L: readings that display the same value share a band") + func tirMmol() { + withUnit(.mmolL) { + let p = tir() + #expect(p.veryLow == 10) // 53 -> 2.9 + #expect(p.low == 20) // 54 -> 3.0, 69 -> 3.8 + #expect(p.inRange == 40) // 70, 71 -> 3.9; 180, 181 -> 10.0 + #expect(p.high == 30) // 182 -> 10.1; 250, 251 -> 13.9 + #expect(p.veryHigh == 0) + } + } + + @Test("GRI: on-a-line readings are not counted as low or high; display precision applies") + func gri() { + let data = [71, 181].map { ShareGlucoseData(sgv: $0, date: 0, direction: nil) } + + withUnit(.mgdL) { + let result = GRICalculator.calculate(bgData: data) + #expect(result.hypoComponent == 0) + #expect(result.hyperComponent == 25) // 181 is above 180: 0.5 x 50% + } + withUnit(.mmolL) { + let result = GRICalculator.calculate(bgData: data) + #expect(result.hypoComponent == 0) + #expect(result.hyperComponent == 0) // 181 displays 10.0 + } + } +}