GlucoseSmoothingTests.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334
  1. import CoreData
  2. import Foundation
  3. import LoopKitUI
  4. import Swinject
  5. import Testing
  6. @testable import Trio
  7. @Suite("Glucose Smoothing Tests", .serialized) struct GlucoseSmoothingTests: Injectable {
  8. let resolver: Resolver
  9. var coreDataStack: CoreDataStack!
  10. var testContext: NSManagedObjectContext!
  11. var fetchGlucoseManager: BaseFetchGlucoseManager!
  12. var openAPS: OpenAPS!
  13. init() async throws {
  14. coreDataStack = try await CoreDataStack.createForTests()
  15. testContext = coreDataStack.newTaskContext()
  16. let assembler = Assembler([
  17. StorageAssembly(),
  18. ServiceAssembly(),
  19. APSAssembly(),
  20. NetworkAssembly(),
  21. UIAssembly(),
  22. SecurityAssembly(),
  23. TestAssembly(testContext: testContext)
  24. ])
  25. resolver = assembler.resolver
  26. injectServices(resolver)
  27. fetchGlucoseManager = resolver.resolve(FetchGlucoseManager.self)! as? BaseFetchGlucoseManager
  28. let fileStorage = resolver.resolve(FileStorage.self)!
  29. openAPS = OpenAPS(storage: fileStorage, tddStorage: MockTDDStorage())
  30. }
  31. // MARK: - Exponential Smoothing Tests
  32. @Test(
  33. "Exponential smoothing writes smoothed glucose for CGM values when enough data exists"
  34. ) func testExponentialSmoothingStoresSmoothedValues() async throws {
  35. let glucoseValues: [Int16] = [100, 105, 110, 115, 120, 125]
  36. await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
  37. await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
  38. let fetchedAscending = try await fetchAndSortGlucose()
  39. // We expect at least the most recent few values to get smoothed values written.
  40. // The Kotlin/port writes to data[i] for i in 0..<limit, where data is newest-first.
  41. // With 6 values:
  42. // - recordCount = 6
  43. // - validWindowCount starts at 5, no gap => remains 5
  44. // - smoothing produces blended.count == 5
  45. // - apply limit = min(5, 6) = 5 => most recent 5 entries get smoothedGlucose
  46. //
  47. // In ascending order, "most recent 5" are indices 1...5. Oldest (index 0) is not guaranteed to be updated.
  48. #expect(fetchedAscending.count == 6)
  49. let smoothedValues = fetchedAscending.compactMap { $0.smoothedGlucose?.decimalValue }
  50. #expect(smoothedValues.count >= 5, "Expected at least 5 smoothed values to be stored.")
  51. for (i, value) in smoothedValues.enumerated() {
  52. #expect(value >= 39, "Smoothed glucose at index \(i) should be clamped to at least 39, got \(value).")
  53. #expect(
  54. value == value.rounded(toPlaces: 0),
  55. "Smoothed glucose at index \(i) should be rounded to an integer, got \(value)."
  56. )
  57. }
  58. }
  59. @Test("Exponential smoothing does not smooth manual glucose entries") func testExponentialSmoothingIgnoresManual() async throws {
  60. // GIVEN: Mixed manual + CGM values
  61. await createGlucoseSequence(values: [100, 105, 110, 115, 120].map(Int16.init), interval: 5 * 60, isManual: false)
  62. await createGlucose(glucose: 130, smoothed: nil, isManual: true, date: Date().addingTimeInterval(6 * 5 * 60))
  63. // WHEN
  64. await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
  65. // THEN
  66. let allAscending = try await fetchAndSortGlucose()
  67. let manual = allAscending.first(where: { $0.isManual })
  68. #expect(manual != nil, "Expected a manual glucose entry.")
  69. #expect(manual?.smoothedGlucose == nil, "Manual entries must not be smoothed/stored.")
  70. }
  71. @Test(
  72. "Exponential smoothing clamps smoothed glucose to >= 39 and rounds to integer"
  73. ) func testExponentialSmoothingClampAndRounding() async throws {
  74. // GIVEN
  75. let glucoseValues: [Int16] = [40, 39, 41, 42, 43, 44]
  76. await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
  77. // WHEN
  78. await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
  79. // THEN
  80. let fetchedAscending = try await fetchAndSortGlucose()
  81. let smoothedValues = fetchedAscending
  82. .compactMap { $0.smoothedGlucose?.decimalValue }
  83. .filter { $0 > 0 }
  84. #expect(!smoothedValues.isEmpty, "Expected at least one smoothed glucose value to be stored.")
  85. for (index, smoothed) in smoothedValues.enumerated() {
  86. #expect(
  87. smoothed >= 39,
  88. "Smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
  89. )
  90. #expect(
  91. smoothed == smoothed.rounded(toPlaces: 0),
  92. "Smoothed glucose must be an integer value, got \(smoothed) at index \(index)."
  93. )
  94. }
  95. }
  96. @Test(
  97. "Exponential smoothing stops at gaps >= 12 minutes and only updates the most recent window"
  98. ) func testExponentialSmoothingGapStopsWindow() async throws {
  99. let now = Date()
  100. var dates: [Date] = []
  101. var values: [Int16] = []
  102. // Older contiguous block (should remain untouched)
  103. for i in 0 ..< 10 {
  104. dates.append(now.addingTimeInterval(Double(i) * 5 * 60))
  105. values.append(Int16(100 + i * 5))
  106. }
  107. // GAP (15 minutes)
  108. let gapStart = now.addingTimeInterval(Double(10) * 5 * 60 + 15 * 60)
  109. // Recent block (too small -> fallback applies only here)
  110. for i in 0 ..< 3 {
  111. dates.append(gapStart.addingTimeInterval(Double(i) * 5 * 60))
  112. values.append(Int16(200 + i * 5))
  113. }
  114. await createGlucoseSequence(values: values, dates: dates, isManual: false)
  115. await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
  116. let ascending = try await fetchAndSortGlucose()
  117. #expect(ascending.count == values.count)
  118. // Split into:
  119. // - older block (before gap)
  120. // - recent block (after gap)
  121. let olderBlock = ascending.prefix(10)
  122. let recentBlock = ascending.suffix(3)
  123. // --- ASSERT 1: Older values should NOT be overwritten ---
  124. for (index, obj) in olderBlock.enumerated() {
  125. #expect(
  126. obj.smoothedGlucose == nil,
  127. "Older value at index \(index) should remain untouched (no fallback overwrite)."
  128. )
  129. }
  130. // --- ASSERT 2: Recent values should be filled by fallback ---
  131. for (index, obj) in recentBlock.enumerated() {
  132. guard let smoothed = obj.smoothedGlucose?.decimalValue else {
  133. #expect(false, "Recent value at index \(index) should have smoothedGlucose set.")
  134. continue
  135. }
  136. #expect(
  137. smoothed >= 39,
  138. "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed)."
  139. )
  140. #expect(
  141. smoothed == smoothed.rounded(toPlaces: 0),
  142. "Fallback smoothed glucose must be rounded to integer, got \(smoothed)."
  143. )
  144. }
  145. }
  146. @Test(
  147. "Exponential smoothing treats 38 mg/dL as xDrip error and clamps stored smoothed glucose"
  148. ) func testExponentialSmoothingXDrip38StopsWindow() async throws {
  149. // GIVEN
  150. let values: [Int16] = [100, 105, 110, 38, 120, 125]
  151. await createGlucoseSequence(values: values, interval: 5 * 60, isManual: false)
  152. // WHEN
  153. await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
  154. // THEN
  155. let ascending = try await fetchAndSortGlucose()
  156. #expect(ascending.count == 6)
  157. let smoothedValues = ascending
  158. .compactMap { $0.smoothedGlucose?.decimalValue }
  159. .filter { $0 > 0 }
  160. #expect(
  161. !smoothedValues.isEmpty,
  162. "Expected at least one smoothed glucose value to be stored."
  163. )
  164. for (index, smoothed) in smoothedValues.enumerated() {
  165. #expect(
  166. smoothed >= 39,
  167. "Smoothed glucose must be clamped to >= 39 even around xDrip 38, got \(smoothed) at index \(index)."
  168. )
  169. #expect(
  170. smoothed == smoothed.rounded(toPlaces: 0),
  171. "Smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
  172. )
  173. }
  174. }
  175. // MARK: - OpenAPS Glucose Selection Tests
  176. @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
  177. await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
  178. let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
  179. #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
  180. #expect(
  181. algorithmInput.first?.glucose == 140,
  182. "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
  183. )
  184. }
  185. @Test("Algorithm uses raw glucose when smoothing is disabled") func testAlgorithmUsesRawGlucose() async throws {
  186. await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
  187. let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: false)
  188. #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
  189. #expect(
  190. algorithmInput.first?.glucose == 150,
  191. "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
  192. )
  193. }
  194. @Test("Algorithm falls back to raw glucose if smoothed value is missing") func testAlgorithmFallbackToRawGlucose() async throws {
  195. await createGlucose(glucose: 150, smoothed: nil, isManual: false, date: Date())
  196. let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
  197. #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
  198. #expect(
  199. algorithmInput.first?.glucose == 150,
  200. "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
  201. )
  202. }
  203. @Test("Algorithm ignores smoothed value for manual glucose entries") func testAlgorithmIgnoresSmoothedManualGlucose() async throws {
  204. await createGlucose(glucose: 150, smoothed: 140, isManual: true, date: Date())
  205. let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
  206. #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
  207. #expect(
  208. algorithmInput.first?.glucose == 150,
  209. "Algorithm should have ignored smoothing for a manual entry and used the raw value (150), but used \(algorithmInput.first?.glucose ?? 0)."
  210. )
  211. }
  212. // MARK: - Helpers
  213. private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
  214. let jsonString = try await openAPS.fetchAndProcessGlucose(
  215. context: testContext,
  216. shouldSmoothGlucose: smoothGlucose,
  217. fetchLimit: 10
  218. )
  219. let data = jsonString.data(using: .utf8)!
  220. let decoder = JSONDecoder()
  221. decoder.dateDecodingStrategy = .custom { decoder in
  222. let container = try decoder.singleValueContainer()
  223. let dateDouble = try container.decode(Double.self)
  224. return Date(timeIntervalSince1970: dateDouble / 1000)
  225. }
  226. return try decoder.decode([AlgorithmGlucose].self, from: data)
  227. }
  228. private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {
  229. await testContext.perform {
  230. let object = GlucoseStored(context: self.testContext)
  231. object.date = date
  232. object.glucose = glucose
  233. object.smoothedGlucose = smoothed as NSDecimalNumber?
  234. object.isManual = isManual
  235. object.id = UUID()
  236. try! self.testContext.save()
  237. }
  238. }
  239. private func createGlucoseSequence(values: [Int16], dates: [Date], isManual: Bool) async {
  240. precondition(values.count == dates.count)
  241. await testContext.perform {
  242. for (i, value) in values.enumerated() {
  243. let object = GlucoseStored(context: self.testContext)
  244. object.date = dates[i]
  245. object.glucose = value
  246. object.smoothedGlucose = nil
  247. object.isManual = isManual
  248. object.id = UUID()
  249. }
  250. try! self.testContext.save()
  251. }
  252. }
  253. private func createGlucoseSequence(values: [Int16], interval: TimeInterval, isManual: Bool) async {
  254. let now = Date()
  255. let dates = values.indices.map { now.addingTimeInterval(Double($0) * interval) }
  256. await createGlucoseSequence(values: values, dates: dates, isManual: isManual)
  257. }
  258. private func fetchAndSortGlucose() async throws -> [GlucoseStored] {
  259. try await coreDataStack.fetchEntitiesAsync(
  260. ofType: GlucoseStored.self,
  261. onContext: testContext,
  262. predicate: .all,
  263. key: "date",
  264. ascending: true
  265. ) as? [GlucoseStored] ?? []
  266. }
  267. }