JSONImporterTests.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  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. init() async throws {
  18. // In-memory Core Data for tests
  19. coreDataStack = try await CoreDataStack.createForTests()
  20. context = coreDataStack.newTaskContext()
  21. importer = JSONImporter(context: context, coreDataStack: coreDataStack)
  22. }
  23. @Test("Import glucose history with value checks") func testImportGlucoseHistoryDetails() async throws {
  24. let testBundle = Bundle(for: BundleReference.self)
  25. let path = testBundle.path(forResource: "glucose", ofType: "json")!
  26. let url = URL(filePath: path)
  27. let now = Date("2025-04-28T19:32:52.000Z")!
  28. try await importer.importGlucoseHistory(url: url, now: now)
  29. // run the import againt to check our deduplication logic
  30. try await importer.importGlucoseHistory(url: url, now: now)
  31. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  32. ofType: GlucoseStored.self,
  33. onContext: context,
  34. predicate: NSPredicate(format: "TRUEPREDICATE"),
  35. key: "date",
  36. ascending: false
  37. ) as? [GlucoseStored] ?? []
  38. #expect(allReadings.count == 274)
  39. #expect(allReadings.first?.glucose == 115)
  40. #expect(allReadings.first?.date == Date("2025-04-28T19:32:51.727Z"))
  41. #expect(allReadings.last?.glucose == 127)
  42. #expect(allReadings.last?.date == Date("2025-04-27T19:37:50.327Z"))
  43. let manualCount = allReadings.filter({ $0.isManual }).count
  44. #expect(manualCount == 1)
  45. }
  46. @Test("Skip importing old glucose values") func testSkipImportOldGlucoseValues() async throws {
  47. let testBundle = Bundle(for: BundleReference.self)
  48. let path = testBundle.path(forResource: "glucose", ofType: "json")!
  49. let url = URL(filePath: path)
  50. // more than 24 hours in the future from the most recent entry
  51. let now = Date("2025-04-29T19:32:52.000Z")!
  52. try await importer.importGlucoseHistory(url: url, now: now)
  53. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  54. ofType: GlucoseStored.self,
  55. onContext: context,
  56. predicate: NSPredicate(format: "TRUEPREDICATE"),
  57. key: "date",
  58. ascending: false
  59. ) as? [GlucoseStored] ?? []
  60. #expect(allReadings.isEmpty)
  61. }
  62. @Test("Import pump history with value checks") func testImportPumpHistoryDetails() async throws {
  63. let testBundle = Bundle(for: BundleReference.self)
  64. let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
  65. let url = URL(filePath: path)
  66. let now = Date("2025-04-29T01:33:58.000Z")!
  67. try await importer.importPumpHistory(url: url, now: now)
  68. // test out deduplication logic
  69. try await importer.importPumpHistory(url: url, now: now)
  70. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  71. ofType: PumpEventStored.self,
  72. onContext: context,
  73. predicate: NSPredicate(format: "TRUEPREDICATE"),
  74. key: "timestamp",
  75. ascending: false
  76. ) as? [PumpEventStored] ?? []
  77. let objectIds = allReadings.map(\.objectID)
  78. let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
  79. var bolusTotal = 0.0
  80. var bolusCount = 0
  81. var smbCount = 0
  82. var rateTotal = 0.0
  83. var tempBasalCount = 0
  84. var durationTotal = 0
  85. var suspendCount = 0
  86. var resumeCount = 0
  87. for event in parsedHistory {
  88. switch event.type {
  89. case .bolus:
  90. bolusTotal += event.amount.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
  91. bolusCount += 1
  92. if event.isSMB == true {
  93. smbCount += 1
  94. }
  95. case .tempBasal:
  96. rateTotal += event.rate.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
  97. tempBasalCount += 1
  98. case .tempBasalDuration:
  99. durationTotal += event.durationMin ?? 0
  100. case .pumpSuspend:
  101. suspendCount += 1
  102. case .pumpResume:
  103. resumeCount += 1
  104. default:
  105. fatalError("unhandled pump event")
  106. }
  107. }
  108. // see the scripts/pump-history-stats.py file for where these come from
  109. #expect(parsedHistory.count == 77)
  110. #expect(bolusCount == 23)
  111. #expect(smbCount == 21)
  112. #expect(bolusTotal.isApproximatelyEqual(to: 8.1, epsilon: 0.01))
  113. #expect(tempBasalCount == 26)
  114. #expect(rateTotal.isApproximatelyEqual(to: 20.08, epsilon: 0.001))
  115. #expect(durationTotal == 900)
  116. #expect(suspendCount == 1)
  117. #expect(resumeCount == 1)
  118. }
  119. @Test("Skipping old pump history entries") func testSkipOldPumpHistoryEntries() async throws {
  120. let testBundle = Bundle(for: BundleReference.self)
  121. let path = testBundle.path(forResource: "pumphistory-24h-zoned", ofType: "json")!
  122. let url = URL(filePath: path)
  123. let now = Date("2025-04-30T01:33:58.000Z")!
  124. try await importer.importPumpHistory(url: url, now: now)
  125. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  126. ofType: PumpEventStored.self,
  127. onContext: context,
  128. predicate: NSPredicate(format: "TRUEPREDICATE"),
  129. key: "timestamp",
  130. ascending: false
  131. ) as? [PumpEventStored] ?? []
  132. #expect(allReadings.isEmpty)
  133. }
  134. @Test("Import pump history with external insulin") func testImportPumpHistoryWithExternalInsulin() async throws {
  135. let testBundle = Bundle(for: BundleReference.self)
  136. let path = testBundle.path(forResource: "pumphistory-with-external", ofType: "json")!
  137. let url = URL(filePath: path)
  138. let now = Date("2025-05-04T04:37:44.654Z")!
  139. try await importer.importPumpHistory(url: url, now: now)
  140. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  141. ofType: PumpEventStored.self,
  142. onContext: context,
  143. predicate: NSPredicate(format: "TRUEPREDICATE"),
  144. key: "timestamp",
  145. ascending: false
  146. ) as? [PumpEventStored] ?? []
  147. let objectIds = allReadings.map(\.objectID)
  148. let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
  149. #expect(parsedHistory.count == 1)
  150. let bolus = parsedHistory.first
  151. #expect(bolus?.type == .bolus)
  152. #expect(bolus?.isExternal == true)
  153. let amount = bolus?.amount.map { ($0 as NSDecimalNumber).doubleValue }
  154. #expect(amount?.isApproximatelyEqual(to: 0.88, epsilon: 0.01) == true)
  155. }
  156. @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {
  157. let testBundle = Bundle(for: BundleReference.self)
  158. let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
  159. let url = URL(filePath: path)
  160. let now = Date("2025-04-28T19:32:52.000Z")!
  161. try await importer.importCarbHistory(url: url, now: now)
  162. // run the import againt to check our deduplication logic
  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.count == 8)
  172. #expect(allCarbEntries.first?.carbs == 10)
  173. #expect(allCarbEntries.first?.note == "Snack 🍪")
  174. #expect(allCarbEntries.first?.date == Date("2025-04-28T18:36:06.968Z"))
  175. #expect(allCarbEntries.last?.carbs == 25)
  176. #expect(allCarbEntries.last?.date == Date("2025-04-28T05:03:43.332Z"))
  177. }
  178. @Test("Skip importing old carb entries") func testSkipImportOldCarbEntries() async throws {
  179. let testBundle = Bundle(for: BundleReference.self)
  180. let path = testBundle.path(forResource: "carbhistory", ofType: "json")!
  181. let url = URL(filePath: path)
  182. // more than 24 hours in the future from the most recent entry
  183. let now = Date("2025-04-29T19:32:52.000Z")!
  184. try await importer.importCarbHistory(url: url, now: now)
  185. let allCarbEntries = try await coreDataStack.fetchEntitiesAsync(
  186. ofType: CarbEntryStored.self,
  187. onContext: context,
  188. predicate: NSPredicate(format: "TRUEPREDICATE"),
  189. key: "date",
  190. ascending: false
  191. ) as? [CarbEntryStored] ?? []
  192. #expect(allCarbEntries.isEmpty)
  193. }
  194. @Test("Import determination data with value checks") func testImportDeterminationDetails() async throws {
  195. let testBundle = Bundle(for: BundleReference.self)
  196. let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
  197. let enactedUrl = URL(filePath: enactedPath)
  198. let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
  199. let suggestedUrl = URL(filePath: suggestedPath)
  200. let now = Date("2025-04-28T20:50:00.000Z")!
  201. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  202. // run the import againt to check our deduplication logic
  203. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  204. let determinations = try await coreDataStack.fetchEntitiesAsync(
  205. ofType: OrefDetermination.self,
  206. onContext: context,
  207. predicate: NSPredicate(format: "TRUEPREDICATE"),
  208. key: "deliverAt",
  209. ascending: false
  210. ) as? [OrefDetermination] ?? []
  211. #expect(determinations.count == 1) // single determination, as enacted.deliverAt and suggested.deliverAt match
  212. let determination = determinations.first!
  213. #expect(determination.deliverAt == Date("2025-04-28T19:41:43.564Z"))
  214. #expect(determination.timestamp == Date("2025-04-28T19:41:48.453Z"))
  215. #expect(determination.enacted == true)
  216. #expect(determination.reason?.starts(with: "Autosens ratio: 0.99") == true)
  217. #expect(determination.insulinReq == Decimal(string: "0.29").map(NSDecimalNumber.init))
  218. #expect(determination.eventualBG! == NSDecimalNumber(160))
  219. #expect(determination.sensitivityRatio == Decimal(string: "0.9863849810728643").map(NSDecimalNumber.init))
  220. #expect(determination.rate == Decimal(string: "0").map(NSDecimalNumber.init))
  221. #expect(determination.duration == NSDecimalNumber(60))
  222. #expect(determination.iob == Decimal(string: "1.249").map(NSDecimalNumber.init))
  223. #expect(determination.cob == 34)
  224. #expect(determination.temp == "absolute")
  225. #expect(determination.glucose == NSDecimalNumber(85))
  226. #expect(determination.reservoir == Decimal(string: "3735928559").map(NSDecimalNumber.init))
  227. #expect(determination.insulinSensitivity == Decimal(string: "4.6").map(NSDecimalNumber.init))
  228. #expect(determination.currentTarget == Decimal(string: "94").map(NSDecimalNumber.init))
  229. #expect(determination.minDelta == NSDecimalNumber(5))
  230. #expect(determination.expectedDelta == Decimal(string: "-5.9").map(NSDecimalNumber.init))
  231. #expect(determination.threshold == Decimal(string: "3.7").map(NSDecimalNumber.init))
  232. #expect(determination.carbRatio == nil) // not present in JSON
  233. let forecasts = try await coreDataStack.fetchEntitiesAsync(
  234. ofType: Forecast.self,
  235. onContext: context,
  236. predicate: NSPredicate(format: "orefDetermination = %@", determination.objectID),
  237. key: "type",
  238. ascending: true,
  239. relationshipKeyPathsForPrefetching: ["forecastValues"]
  240. )
  241. var forecastHierarchy: [(forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
  242. await context.perform {
  243. if let forecasts = forecasts as? [Forecast] {
  244. for forecast in forecasts {
  245. // Use the helper property that already sorts by index
  246. let sortedValues = forecast.forecastValuesArray
  247. forecastHierarchy.append((
  248. forecastID: forecast.objectID,
  249. forecastValueIDs: sortedValues.map(\.objectID)
  250. ))
  251. }
  252. }
  253. for entry in forecastHierarchy {
  254. var forecastValueTuple: (Forecast?, [ForecastValue]) = (nil, [])
  255. var forecast: Forecast?
  256. var forecastValues: [ForecastValue] = []
  257. do {
  258. // Fetch the forecast object
  259. forecast = try context.existingObject(with: entry.forecastID) as? Forecast
  260. // Fetch the first 3h of forecast values
  261. for forecastValueID in entry.forecastValueIDs.prefix(36) {
  262. if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
  263. forecastValues.append(forecastValue)
  264. }
  265. }
  266. } catch {
  267. debugPrint(
  268. "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
  269. )
  270. }
  271. forecastValueTuple = (forecast, forecastValues)
  272. // Basic checks
  273. #expect(forecastValueTuple.0 != nil)
  274. #expect(forecastValueTuple.1.isNotEmpty == true)
  275. if let forecast = forecastValueTuple.0 {
  276. let sortedValues = forecastValueTuple.1.sorted { $0.index < $1.index }
  277. let prefix = sortedValues.prefix(5).compactMap(\.value)
  278. let type = forecast.type?.lowercased()
  279. switch type {
  280. case "zt":
  281. #expect(prefix == [85, 78, 71, 64, 58])
  282. case "iob":
  283. #expect(prefix == [85, 89, 92, 95, 97])
  284. case "uam":
  285. #expect(prefix == [85, 89, 93, 96, 99])
  286. case "cob":
  287. #expect(prefix == [85, 90, 94, 99, 103])
  288. default:
  289. break // Skip unknown forecast types silently
  290. }
  291. }
  292. }
  293. }
  294. }
  295. @Test("Skip importing old determinations") func testSkipImportOldDeterminationData() async throws {
  296. let testBundle = Bundle(for: BundleReference.self)
  297. let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
  298. let enactedUrl = URL(filePath: enactedPath)
  299. let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
  300. let suggestedUrl = URL(filePath: suggestedPath)
  301. // more than 24 hours in the future from the most recent entry
  302. let now = Date("2025-04-29T22:00:00.000Z")!
  303. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  304. let determinations = try await coreDataStack.fetchEntitiesAsync(
  305. ofType: OrefDetermination.self,
  306. onContext: context,
  307. predicate: NSPredicate(format: "TRUEPREDICATE"),
  308. key: "deliverAt",
  309. ascending: false
  310. ) as? [OrefDetermination] ?? []
  311. #expect(determinations.isEmpty)
  312. }
  313. @Test("Import determination data with suggested newer than enacted") func testImportDeterminationDetailsWithNewerSuggested(
  314. ) async throws {
  315. let testBundle = Bundle(for: BundleReference.self)
  316. let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
  317. let enactedUrl = URL(filePath: enactedPath)
  318. let suggestedPath = testBundle.path(forResource: "newerSuggested", ofType: "json")!
  319. let suggestedUrl = URL(filePath: suggestedPath)
  320. let now = Date("2025-04-28T20:50:00.000Z")!
  321. try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
  322. let determinations = try await coreDataStack.fetchEntitiesAsync(
  323. ofType: OrefDetermination.self,
  324. onContext: context,
  325. predicate: NSPredicate(format: "TRUEPREDICATE"),
  326. key: "deliverAt",
  327. ascending: false
  328. ) as? [OrefDetermination] ?? []
  329. #expect(determinations.count == 2) // two determinations, suggested is more recent than enacted
  330. let suggested = determinations.first(where: { !$0.enacted && $0.deliverAt == $0.timestamp })!
  331. let enacted = determinations.first(where: { $0.enacted })!
  332. #expect(suggested.deliverAt == Date("2025-04-28T19:51:48.453Z"))
  333. #expect(enacted.timestamp == Date("2025-04-28T19:41:48.453Z"))
  334. }
  335. }
  336. extension Double {
  337. func isApproximatelyEqual(to other: Double, epsilon: Double?) -> Bool {
  338. // If no epsilon provided, require exact match
  339. guard let epsilon = epsilon else {
  340. return self == other
  341. }
  342. // Handle exact equality
  343. if self == other {
  344. return true
  345. }
  346. // Handle infinity and NaN
  347. if isInfinite || other.isInfinite || isNaN || other.isNaN {
  348. return self == other
  349. }
  350. // For values, use simple absolute difference
  351. return abs(self - other) <= epsilon
  352. }
  353. }