JSONImporterTests.swift 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. //
  2. // JSONImporterTests.swift
  3. // Trio
  4. //
  5. // Created by Cengiz Deniz on 21.04.25.
  6. //
  7. import CoreData
  8. import Foundation
  9. import Swinject
  10. import Testing
  11. @testable import Trio
  12. class BundleReference {}
  13. @Suite("JSON Importer Tests", .serialized) struct JSONImporterTests: Injectable {
  14. var coreDataStack: CoreDataStack!
  15. var context: NSManagedObjectContext!
  16. var importer: JSONImporter!
  17. @Injected() var determinationStorage: DeterminationStorage!
  18. init() async throws {
  19. // In-memory Core Data for tests
  20. coreDataStack = try await CoreDataStack.createForTests()
  21. context = coreDataStack.newTaskContext()
  22. importer = JSONImporter(context: context, coreDataStack: coreDataStack)
  23. }
  24. @Test("Import glucose history with value checks") func testImportGlucoseHistoryDetails() async throws {
  25. let testBundle = Bundle(for: BundleReference.self)
  26. let path = testBundle.path(forResource: "glucose", ofType: "json")!
  27. let url = URL(filePath: path)
  28. let now = Date("2025-04-28T19:32:52.000Z")!
  29. try await importer.importGlucoseHistory(url: url, now: now)
  30. // run the import againt to check our deduplication logic
  31. try await importer.importGlucoseHistory(url: url, now: now)
  32. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  33. ofType: GlucoseStored.self,
  34. onContext: context,
  35. predicate: NSPredicate(format: "TRUEPREDICATE"),
  36. key: "date",
  37. ascending: false
  38. ) as? [GlucoseStored] ?? []
  39. #expect(allReadings.count == 274)
  40. #expect(allReadings.first?.glucose == 115)
  41. #expect(allReadings.first?.date == Date("2025-04-28T19:32:51.727Z"))
  42. #expect(allReadings.last?.glucose == 127)
  43. #expect(allReadings.last?.date == Date("2025-04-27T19:37:50.327Z"))
  44. let manualCount = allReadings.filter({ $0.isManual }).count
  45. #expect(manualCount == 1)
  46. }
  47. @Test("Skip importing old glucose values") func testSkipImportOldGlucoseValues() async throws {
  48. let testBundle = Bundle(for: BundleReference.self)
  49. let path = testBundle.path(forResource: "glucose", ofType: "json")!
  50. let url = URL(filePath: path)
  51. // more than 24 hours in the future from the most recent entry
  52. let now = Date("2025-04-29T19:32:52.000Z")!
  53. try await importer.importGlucoseHistory(url: url, now: now)
  54. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  55. ofType: GlucoseStored.self,
  56. onContext: context,
  57. predicate: NSPredicate(format: "TRUEPREDICATE"),
  58. key: "date",
  59. ascending: false
  60. ) as? [GlucoseStored] ?? []
  61. #expect(allReadings.isEmpty)
  62. }
  63. @Test("Import pump history with value checks") func testImportPumpHistoryDetails() async throws {
  64. let testBundle = Bundle(for: BundleReference.self)
  65. let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
  66. let url = URL(filePath: path)
  67. let now = Date("2025-04-29T01:33:58.000Z")!
  68. try await importer.importPumpHistory(url: url, now: now)
  69. // test out deduplication logic
  70. try await importer.importPumpHistory(url: url, now: now)
  71. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  72. ofType: PumpEventStored.self,
  73. onContext: context,
  74. predicate: NSPredicate(format: "TRUEPREDICATE"),
  75. key: "timestamp",
  76. ascending: false
  77. ) as? [PumpEventStored] ?? []
  78. let objectIds = allReadings.map(\.objectID)
  79. let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, from: context)
  80. var bolusTotal = 0.0
  81. var bolusCount = 0
  82. var smbCount = 0
  83. var rateTotal = 0.0
  84. var tempBasalCount = 0
  85. var durationTotal = 0
  86. var suspendCount = 0
  87. var resumeCount = 0
  88. for event in parsedHistory {
  89. switch event {
  90. case let .bolus(bolus):
  91. bolusTotal += bolus.amount
  92. bolusCount += 1
  93. if bolus.isSMB {
  94. smbCount += 1
  95. }
  96. case let .tempBasal(tempBasal):
  97. rateTotal += tempBasal.rate
  98. tempBasalCount += 1
  99. case let .tempBasalDuration(tempBasalDuration):
  100. durationTotal += tempBasalDuration.duration
  101. case .suspend:
  102. suspendCount += 1
  103. case .resume:
  104. resumeCount += 1
  105. default:
  106. fatalError("unhandled pump event")
  107. }
  108. }
  109. // see the scripts/pump-history-stats.py file for where these come from
  110. #expect(parsedHistory.count == 77)
  111. #expect(bolusCount == 23)
  112. #expect(smbCount == 21)
  113. #expect(bolusTotal.isApproximatelyEqual(to: 8.1, epsilon: 0.01))
  114. #expect(tempBasalCount == 26)
  115. #expect(rateTotal.isApproximatelyEqual(to: 20.08, epsilon: 0.001))
  116. #expect(durationTotal == 900)
  117. #expect(suspendCount == 1)
  118. #expect(resumeCount == 1)
  119. }
  120. @Test("Skipping old pump history entries") func testSkipOldPumpHistoryEntries() async throws {
  121. let testBundle = Bundle(for: BundleReference.self)
  122. let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
  123. let url = URL(filePath: path)
  124. let now = Date("2025-04-30T01:33:58.000Z")!
  125. try await importer.importPumpHistory(url: url, now: now)
  126. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  127. ofType: PumpEventStored.self,
  128. onContext: context,
  129. predicate: NSPredicate(format: "TRUEPREDICATE"),
  130. key: "timestamp",
  131. ascending: false
  132. ) as? [PumpEventStored] ?? []
  133. #expect(allReadings.isEmpty)
  134. }
  135. @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {
  136. let testBundle = Bundle(for: BundleReference.self)
  137. let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
  138. let url = URL(filePath: path)
  139. let now = Date("2025-04-28T19:32:52.000Z")!
  140. try await importer.importCarbHistory(url: url, now: now)
  141. // run the import againt to check our deduplication logic
  142. try await importer.importCarbHistory(url: url, now: now)
  143. let allCarbEntries = try await coreDataStack.fetchEntitiesAsync(
  144. ofType: CarbEntryStored.self,
  145. onContext: context,
  146. predicate: NSPredicate(format: "TRUEPREDICATE"),
  147. key: "date",
  148. ascending: false
  149. ) as? [CarbEntryStored] ?? []
  150. #expect(allCarbEntries.count == 8)
  151. #expect(allCarbEntries.first?.carbs == 10)
  152. #expect(allCarbEntries.first?.note == "Snack 🍪")
  153. #expect(allCarbEntries.first?.date == Date("2025-04-28T18:36:06.968Z"))
  154. #expect(allCarbEntries.last?.carbs == 25)
  155. #expect(allCarbEntries.last?.date == Date("2025-04-28T05:03:43.332Z"))
  156. }
  157. @Test("Skip importing old carb entries") func testSkipImportOldCarbEntries() async throws {
  158. let testBundle = Bundle(for: BundleReference.self)
  159. let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
  160. let url = URL(filePath: path)
  161. // more than 24 hours in the future from the most recent entry
  162. let now = Date("2025-04-29T19:32:52.000Z")!
  163. try await importer.importCarbHistory(url: url, now: now)
  164. let allCarbEntries = try await coreDataStack.fetchEntitiesAsync(
  165. ofType: CarbEntryStored.self,
  166. onContext: context,
  167. predicate: NSPredicate(format: "TRUEPREDICATE"),
  168. key: "date",
  169. ascending: false
  170. ) as? [CarbEntryStored] ?? []
  171. #expect(allCarbEntries.isEmpty)
  172. }
  173. @Test("Import determination data with value checks") func testImportDeterminationDetails() async throws {
  174. let testBundle = Bundle(for: BundleReference.self)
  175. let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
  176. let enactedUrl = URL(filePath: enactedPath)
  177. let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
  178. let suggestedUrl = URL(filePath: suggestedPath)
  179. let now = Date("2025-04-28T20:50:00.000Z")!
  180. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  181. // run the import againt to check our deduplication logic
  182. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  183. let determinations = try await coreDataStack.fetchEntitiesAsync(
  184. ofType: OrefDetermination.self,
  185. onContext: context,
  186. predicate: NSPredicate(format: "TRUEPREDICATE"),
  187. key: "deliverAt",
  188. ascending: false
  189. ) as? [OrefDetermination] ?? []
  190. #expect(determinations.count == 1) // single determination, as enacted.deliverAt and suggested.deliverAt match
  191. let determination = determinations.first!
  192. #expect(determination.deliverAt == Date("2025-04-28T19:41:43.564Z"))
  193. #expect(determination.timestamp == Date("2025-04-28T19:41:48.453Z"))
  194. #expect(determination.enacted == true)
  195. #expect(determination.reason?.starts(with: "Autosens ratio: 0.99") == true)
  196. #expect(determination.insulinReq == Decimal(string: "0.29").map(NSDecimalNumber.init))
  197. #expect(determination.eventualBG! == NSDecimalNumber(160))
  198. #expect(determination.sensitivityRatio == Decimal(string: "0.9863849810728643").map(NSDecimalNumber.init))
  199. #expect(determination.rate == Decimal(string: "0").map(NSDecimalNumber.init))
  200. #expect(determination.duration == NSDecimalNumber(60))
  201. #expect(determination.iob == Decimal(string: "1.249").map(NSDecimalNumber.init))
  202. #expect(determination.cob == 34)
  203. #expect(determination.temp == "absolute")
  204. #expect(determination.glucose == NSDecimalNumber(85))
  205. #expect(determination.reservoir == Decimal(string: "3735928559").map(NSDecimalNumber.init))
  206. #expect(determination.insulinSensitivity == Decimal(string: "4.6").map(NSDecimalNumber.init))
  207. #expect(determination.currentTarget == Decimal(string: "94").map(NSDecimalNumber.init))
  208. #expect(determination.insulinForManualBolus == Decimal(string: "0.8").map(NSDecimalNumber.init))
  209. #expect(determination.manualBolusErrorString == Decimal(string: "0").map(NSDecimalNumber.init))
  210. #expect(determination.minDelta == NSDecimalNumber(5))
  211. #expect(determination.expectedDelta == Decimal(string: "-5.9").map(NSDecimalNumber.init))
  212. #expect(determination.threshold == Decimal(string: "3.7").map(NSDecimalNumber.init))
  213. #expect(determination.carbRatio == nil) // not present in JSON
  214. // TODO: fix forecast testing
  215. // let forecastHierarchy = try await determinationStorage.fetchForecastHierarchy(for: determination.objectID, in: context)
  216. //
  217. // for entry in forecastHierarchy {
  218. // let (_, forecast, values) = await determinationStorage.fetchForecastObjects(for: entry, in: context)
  219. //
  220. // // Basic checks
  221. // #expect(forecast != nil)
  222. // #expect(!values.isEmpty)
  223. //
  224. // if let forecast = forecast {
  225. // let sortedValues = values.sorted { $0.index < $1.index }
  226. // let prefix = sortedValues.prefix(5).compactMap(\.value)
  227. // let type = forecast.type?.lowercased()
  228. //
  229. // switch type {
  230. // case "zt":
  231. // #expect(prefix == [85, 78, 71, 64, 58])
  232. // case "iob":
  233. // #expect(prefix == [85, 89, 92, 95, 97])
  234. // case "uam":
  235. // #expect(prefix == [85, 89, 93, 96, 99])
  236. // case "cob":
  237. // #expect(prefix == [85, 90, 94, 99, 103])
  238. // default:
  239. // break // Skip unknown forecast types silently
  240. // }
  241. // }
  242. // }
  243. }
  244. }
  245. extension Double {
  246. func isApproximatelyEqual(to other: Double, epsilon: Double?) -> Bool {
  247. // If no epsilon provided, require exact match
  248. guard let epsilon = epsilon else {
  249. return self == other
  250. }
  251. // Handle exact equality
  252. if self == other {
  253. return true
  254. }
  255. // Handle infinity and NaN
  256. if isInfinite || other.isInfinite || isNaN || other.isNaN {
  257. return self == other
  258. }
  259. // For values, use simple absolute difference
  260. return abs(self - other) <= epsilon
  261. }
  262. }