GlucoseStorageTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. import CoreData
  2. import Foundation
  3. import Swinject
  4. import Testing
  5. @testable import Trio
  6. @Suite("GlucoseStorage Tests", .serialized) struct GlucoseStorageTests: Injectable {
  7. @Injected() var storage: GlucoseStorage!
  8. let resolver: Resolver
  9. var coreDataStack: CoreDataStack!
  10. var testContext: NSManagedObjectContext!
  11. init() async throws {
  12. // Create test context
  13. // As we are only using this single test context to initialize our in-memory DeterminationStorage we need to perform the Unit Tests serialized
  14. coreDataStack = try await CoreDataStack.createForTests()
  15. testContext = coreDataStack.newTaskContext()
  16. // Create assembler with test assembly
  17. let assembler = Assembler([
  18. StorageAssembly(),
  19. ServiceAssembly(),
  20. APSAssembly(),
  21. NetworkAssembly(),
  22. UIAssembly(),
  23. SecurityAssembly(),
  24. TestAssembly(testContext: testContext) // Add our test assembly last to override Storage
  25. ])
  26. resolver = assembler.resolver
  27. injectServices(resolver)
  28. }
  29. @Test("Storage is correctly initialized") func testStorageInitialization() {
  30. // Verify storage exists
  31. #expect(storage != nil, "GlucoseStorage should be injected")
  32. // Verify it's the correct type
  33. #expect(storage is BaseGlucoseStorage, "Storage should be of type BaseGlucoseStorage")
  34. }
  35. @Test("Store and retrieve glucose entries") func testStoreAndRetrieveGlucose() async throws {
  36. // Given
  37. let testGlucose = [
  38. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 126)
  39. ]
  40. // When
  41. try await storage.storeGlucose(testGlucose)
  42. // Then verify stored entries
  43. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  44. ofType: GlucoseStored.self,
  45. onContext: testContext,
  46. predicate: NSPredicate(format: "glucose == 126"),
  47. key: "date",
  48. ascending: false
  49. ) as? [GlucoseStored]
  50. #expect(storedEntries?.isEmpty == false, "Should have stored entries")
  51. #expect(storedEntries?.count == 1, "Should have exactly one entry")
  52. #expect(storedEntries?[0].glucose == 126, "Glucose value should match")
  53. #expect(storedEntries?[0].direction == "Flat", "Direction should match")
  54. }
  55. @Test("Delete glucose entry") func testDeleteGlucose() async throws {
  56. // Given
  57. let testGlucose = [
  58. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 140)
  59. ]
  60. try await storage.storeGlucose(testGlucose)
  61. // Get the stored entry's ObjectID
  62. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  63. ofType: GlucoseStored.self,
  64. onContext: testContext,
  65. predicate: NSPredicate(format: "glucose == 140"),
  66. key: "date",
  67. ascending: false
  68. ) as? [GlucoseStored]
  69. guard let objectID = storedEntries?.first?.objectID else {
  70. throw TestError("Failed to get stored entry's ObjectID")
  71. }
  72. #expect(storedEntries.isNotNilNotEmpty == true, "Should have exactly one (test) entry")
  73. // When
  74. await storage.deleteGlucose(objectID)
  75. // Then verify deletion
  76. let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
  77. ofType: GlucoseStored.self,
  78. onContext: testContext,
  79. predicate: NSPredicate(format: "glucose == 140"),
  80. key: "date",
  81. ascending: false
  82. ) as? [GlucoseStored]
  83. #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
  84. // Finally verify that it stored a copy
  85. let archivedEntries = try await coreDataStack.fetchEntitiesAsync(
  86. ofType: DeletedGlucoseStored.self,
  87. onContext: testContext,
  88. predicate: NSPredicate(format: "glucose == 140"),
  89. key: "date",
  90. ascending: false
  91. ) as? [DeletedGlucoseStored]
  92. #expect(archivedEntries?.isEmpty == false, "Should have archived entries after deletion")
  93. }
  94. @Test("Get glucose not yet uploaded to Nightscout") func testGetGlucoseNotYetUploadedToNightscout() async throws {
  95. // Given
  96. let testGlucose = [
  97. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 160)
  98. ]
  99. try await storage.storeGlucose(testGlucose)
  100. // When
  101. let notUploadedEntries = try await storage.getGlucoseNotYetUploadedToNightscout()
  102. // Then
  103. #expect(!notUploadedEntries.isEmpty, "Should have entries not uploaded to NS")
  104. #expect(notUploadedEntries[0].glucose == 160, "Glucose value should match")
  105. }
  106. @Test("Sub-39 glucose is clamped to 39 on storeGlucose") func testStoreGlucoseClampsBelowMinimum() async throws {
  107. // Given a CGM reading below the 39 mg/dL floor (e.g. LibreTransmitter delivering 23)
  108. let testGlucose = [
  109. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 23)
  110. ]
  111. // When
  112. try await storage.storeGlucose(testGlucose)
  113. // Then the stored row should be clamped to 39, not 23
  114. let clampedEntries = try await coreDataStack.fetchEntitiesAsync(
  115. ofType: GlucoseStored.self,
  116. onContext: testContext,
  117. predicate: NSPredicate(format: "glucose == 39"),
  118. key: "date",
  119. ascending: false
  120. ) as? [GlucoseStored]
  121. #expect(clampedEntries?.count == 1, "Sub-39 glucose should be clamped and stored as 39")
  122. let rawEntries = try await coreDataStack.fetchEntitiesAsync(
  123. ofType: GlucoseStored.self,
  124. onContext: testContext,
  125. predicate: NSPredicate(format: "glucose == 23"),
  126. key: "date",
  127. ascending: false
  128. ) as? [GlucoseStored]
  129. #expect(rawEntries?.isEmpty == true, "Raw sub-39 value must not be persisted")
  130. }
  131. @Test("Sub-39 glucose is clamped to 39 on backfillGlucose") func testBackfillGlucoseClampsBelowMinimum() async throws {
  132. // Given a backfilled CGM reading below the 39 mg/dL floor
  133. let backfillDate = Date().addingTimeInterval(-30 * 60)
  134. let testGlucose = [
  135. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 456, dateString: backfillDate, glucose: 28)
  136. ]
  137. // When
  138. try await storage.backfillGlucose(testGlucose)
  139. // Then the backfilled row should be clamped to 39
  140. let clampedEntries = try await coreDataStack.fetchEntitiesAsync(
  141. ofType: GlucoseStored.self,
  142. onContext: testContext,
  143. predicate: NSPredicate(format: "glucose == 39"),
  144. key: "date",
  145. ascending: false
  146. ) as? [GlucoseStored]
  147. #expect(clampedEntries?.count == 1, "Sub-39 backfilled glucose should be clamped and stored as 39")
  148. }
  149. @Test(
  150. "Test glucose alarms",
  151. .enabled(if: false, "Flaky test, disabled while investigating")
  152. ) func testGlucoseAlarms() async throws {
  153. // Given
  154. let lowGlucose = [
  155. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 55)
  156. ]
  157. let highGlucose = [
  158. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 271)
  159. ]
  160. let normalGlucose = [
  161. BloodGlucose(direction: BloodGlucose.Direction.flat, date: 123, dateString: Date(), glucose: 100)
  162. ]
  163. // When - Test low glucose
  164. try await storage.storeGlucose(lowGlucose)
  165. var storedEntries = try await coreDataStack.fetchEntitiesAsync(
  166. ofType: GlucoseStored.self,
  167. onContext: testContext,
  168. predicate: NSPredicate(format: "glucose == 55"),
  169. key: "date",
  170. ascending: false
  171. ) as? [GlucoseStored]
  172. // Then
  173. #expect(storedEntries?.first?.glucose == 55, "Low glucose value should match")
  174. #expect(storage.alarm == .low, "Should trigger low glucose alarm") // default low limit is 72 mg/dL
  175. // When - Test high glucose
  176. try await storage.storeGlucose(highGlucose)
  177. storedEntries = try await coreDataStack.fetchEntitiesAsync(
  178. ofType: GlucoseStored.self,
  179. onContext: testContext,
  180. predicate: NSPredicate(format: "glucose == 271"),
  181. key: "date",
  182. ascending: false
  183. ) as? [GlucoseStored]
  184. // Then
  185. #expect(storedEntries?.first?.glucose == 271, "High glucose value should match")
  186. #expect(storage.alarm == .high, "Should trigger high glucose alarm") // default high limit is 270 mg/dL
  187. // When - Test normal glucose
  188. try await storage.storeGlucose(normalGlucose)
  189. storedEntries = try await coreDataStack.fetchEntitiesAsync(
  190. ofType: GlucoseStored.self,
  191. onContext: testContext,
  192. predicate: NSPredicate(format: "glucose == 100"),
  193. key: "date",
  194. ascending: false
  195. ) as? [GlucoseStored]
  196. // Then
  197. #expect(storedEntries?.first?.glucose == 100, "Normal glucose value should match")
  198. #expect(storage.alarm == nil, "Should not trigger any alarm")
  199. }
  200. /* Commenting out while we don't have getGlucoseStatus defined
  201. @Test("getGlucoseStatus returns correct deltas for 0/5/15/30m readings") func testGetGlucoseStatusFourPoints() async throws {
  202. let now = Date()
  203. // Prepare 4 readings: at 0, 5, 15, and 30 minutes ago
  204. let specs: [(offset: TimeInterval, value: Int)] = [
  205. (0, 100), // now
  206. (5 * 60, 110), // 5m ago
  207. (15 * 60, 120), // 15m ago
  208. (30 * 60, 130) // 30m ago
  209. ]
  210. // Insert them into CoreData so that our fetch predicate picks them up
  211. for (offset, value) in specs {
  212. await testContext.perform {
  213. let glucoseToStore = GlucoseStored(context: testContext)
  214. glucoseToStore.id = UUID()
  215. glucoseToStore.date = now.addingTimeInterval(-offset)
  216. glucoseToStore.glucose = Int16(value)
  217. }
  218. }
  219. try testContext.save()
  220. // Call the method under test
  221. let status = try await storage.getGlucoseStatus()
  222. #expect(status != nil, "Expected non‐nil status")
  223. // “Now” glucose is the 0m reading
  224. #expect(status!.glucose == 100)
  225. // lastDelta: only the 5m point: (100–110)/5*5 = –10
  226. #expect(status!.delta == -10)
  227. // shortAvgDelta: average of 5m and 15m windows:
  228. // 5m window: (100–110)/5*5 = –10
  229. // 15m window: (100–120)/15*5 ≈ –6.6667 → –6.67
  230. // avg ≈ (–10 + –6.67)/2 = –8.333… → rounded to –8.33
  231. #expect(status!.shortAvgDelta == -8.33)
  232. // longAvgDelta: only the 30m window: (100–130)/30*5 = –5
  233. #expect(status!.longAvgDelta == -5)
  234. }*/
  235. }