PumpHistoryNativeConversionTests.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. import CoreData
  2. import Foundation
  3. import Testing
  4. @testable import Trio
  5. /// Golden tests certifying that the native `PumpEventStored` → `[PumpHistoryEvent]` mapping
  6. /// (`OpenAPS.nativePumpHistory` / `PumpEventStored.toPumpHistoryEvents`) reproduces, field for
  7. /// field, the pump history the algorithm used to receive through the old JSON round-trip
  8. /// (`PumpEventStored` → `[PumpEventDTO]` → JSON → `JSONBridge.pumpHistory`) — with one deliberate
  9. /// change: a nil `tempType` becomes `temp = nil` instead of throwing during decode.
  10. @Suite("Pump History Native Conversion Tests", .serialized) struct PumpHistoryNativeConversionTests {
  11. var coreDataStack: CoreDataStack!
  12. var testContext: NSManagedObjectContext!
  13. init() async throws {
  14. coreDataStack = try await CoreDataStack.createForTests()
  15. testContext = coreDataStack.newTaskContext()
  16. }
  17. // MARK: - Golden tests (native mapping vs frozen old-path output)
  18. @Test("A bolus maps identically") func testBolus() async throws {
  19. let ids = [
  20. try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 2.5, isSMB: true, isExternal: false)
  21. ]
  22. try await assertNativeMatchesGolden(ids, golden: [
  23. PumpHistoryEvent(
  24. id: uuid(1),
  25. type: .bolus,
  26. timestamp: fixedDate(0),
  27. amount: dec("2.5"),
  28. duration: 0,
  29. isSMB: true,
  30. isExternal: false
  31. )
  32. ])
  33. }
  34. @Test("An external, non-SMB bolus maps identically") func testExternalBolus() async throws {
  35. let ids = [
  36. try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 0.88, isSMB: false, isExternal: true)
  37. ]
  38. // 0.88 stored as a Core Data Decimal round-trips through the lossy Double hop to
  39. // 0.8800000000000001; the golden freezes that coerced value.
  40. try await assertNativeMatchesGolden(ids, golden: [
  41. PumpHistoryEvent(
  42. id: uuid(1),
  43. type: .bolus,
  44. timestamp: fixedDate(0),
  45. amount: dec("0.8800000000000001"),
  46. duration: 0,
  47. isSMB: false,
  48. isExternal: true
  49. )
  50. ])
  51. }
  52. @Test("A temp basal emits a duration entry then a rate entry, both identical") func testTempBasal() async throws {
  53. let ids = [
  54. try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: "absolute")
  55. ]
  56. let native = try await nativeEvents(ids)
  57. // Exactly two entries, ordered duration-then-rate.
  58. #expect(native.count == 2)
  59. #expect(native.first?.type == .tempBasalDuration)
  60. #expect(native.first?.durationMin == 30)
  61. #expect(native.first?.duration == nil, "the duration entry uses durationMin, never duration")
  62. #expect(native.last?.type == .tempBasal)
  63. #expect(native.last?.id == "_\(uuid(1))", "the rate entry id is prefixed with an underscore")
  64. #expect(native.last?.temp == .absolute)
  65. try await assertNativeMatchesGolden(ids, golden: [
  66. PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
  67. PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: .absolute)
  68. ])
  69. }
  70. @Test("A percent temp basal maps identically") func testPercentTempBasal() async throws {
  71. let ids = [
  72. try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 1.5, durationMinutes: 45, tempType: "percent")
  73. ]
  74. try await assertNativeMatchesGolden(ids, golden: [
  75. PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 45),
  76. PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("1.5"), temp: .percent)
  77. ])
  78. }
  79. @Test("A temp basal with a nil rate emits only the duration entry") func testTempBasalNilRate() async throws {
  80. let ids = [
  81. try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: nil, durationMinutes: 30, tempType: "absolute")
  82. ]
  83. try await assertNativeMatchesGolden(ids, golden: [
  84. PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30)
  85. ])
  86. }
  87. @Test("Suspend, resume, rewind and prime map identically") func testStatusEvents() async throws {
  88. let ids = [
  89. try await insertStatusEvent(id: uuid(1), date: fixedDate(0), type: .pumpSuspend),
  90. try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume),
  91. try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .rewind),
  92. try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .prime)
  93. ]
  94. try await assertNativeMatchesGolden(ids, golden: [
  95. PumpHistoryEvent(id: uuid(1), type: .pumpSuspend, timestamp: fixedDate(0)),
  96. PumpHistoryEvent(id: uuid(2), type: .pumpResume, timestamp: fixedDate(1)),
  97. PumpHistoryEvent(id: uuid(3), type: .rewind, timestamp: fixedDate(2)),
  98. PumpHistoryEvent(id: uuid(4), type: .prime, timestamp: fixedDate(3))
  99. ])
  100. }
  101. @Test("A mixed sequence maps identically") func testMixedSequence() async throws {
  102. let ids = [
  103. try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false),
  104. try await insertTempBasal(id: uuid(2), date: fixedDate(1), rate: 0.7, durationMinutes: 30, tempType: "absolute"),
  105. try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .pumpSuspend),
  106. try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .pumpResume),
  107. try await insertBolus(id: uuid(5), date: fixedDate(4), amount: 0.05, isSMB: true, isExternal: false)
  108. ]
  109. try await assertNativeMatchesGolden(ids, golden: [
  110. PumpHistoryEvent(
  111. id: uuid(1),
  112. type: .bolus,
  113. timestamp: fixedDate(0),
  114. amount: dec("1.0"),
  115. duration: 0,
  116. isSMB: true,
  117. isExternal: false
  118. ),
  119. PumpHistoryEvent(id: uuid(2), type: .tempBasalDuration, timestamp: fixedDate(1), durationMin: 30),
  120. PumpHistoryEvent(id: "_\(uuid(2))", type: .tempBasal, timestamp: fixedDate(1), rate: dec("0.7"), temp: .absolute),
  121. PumpHistoryEvent(id: uuid(3), type: .pumpSuspend, timestamp: fixedDate(2)),
  122. PumpHistoryEvent(id: uuid(4), type: .pumpResume, timestamp: fixedDate(3)),
  123. PumpHistoryEvent(
  124. id: uuid(5),
  125. type: .bolus,
  126. timestamp: fixedDate(4),
  127. amount: dec("0.05"),
  128. duration: 0,
  129. isSMB: true,
  130. isExternal: false
  131. )
  132. ])
  133. }
  134. @Test("Orphaned resumes are filtered") func testOrphanedResumeFiltering() async throws {
  135. let bolus = try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false)
  136. let orphanedResume = try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume)
  137. let ids = [bolus, orphanedResume]
  138. try await assertNativeMatchesGolden(ids, orphanedResumes: [orphanedResume], golden: [
  139. PumpHistoryEvent(
  140. id: uuid(1),
  141. type: .bolus,
  142. timestamp: fixedDate(0),
  143. amount: dec("1.0"),
  144. duration: 0,
  145. isSMB: true,
  146. isExternal: false
  147. )
  148. ])
  149. }
  150. @Test("Empty history maps to an empty array") func testEmpty() async throws {
  151. try await assertNativeMatchesGolden([], golden: [])
  152. }
  153. // MARK: - The one deliberate behavioral change
  154. @Test("A nil tempType becomes temp = nil instead of throwing") func testNilTempTypeIsRobust() async throws {
  155. let ids = [
  156. try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: nil)
  157. ]
  158. // The old JSON path emitted the string "unknown" for a nil tempType, which threw while
  159. // decoding. Native emits both entries and leaves temp nil instead.
  160. try await assertNativeMatchesGolden(ids, golden: [
  161. PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
  162. PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: nil)
  163. ])
  164. }
  165. // MARK: - Comparison helpers
  166. /// Asserts the native mapping reproduces the frozen golden `[PumpHistoryEvent]`.
  167. private func assertNativeMatchesGolden(
  168. _ objectIDs: [NSManagedObjectID],
  169. orphanedResumes: [NSManagedObjectID] = [],
  170. golden: [PumpHistoryEvent]
  171. ) async throws {
  172. let native = try await nativeEvents(objectIDs, orphanedResumes: orphanedResumes)
  173. #expect(native.count == golden.count, "native produced \(native.count) events, golden has \(golden.count)")
  174. for (index, pair) in zip(native, golden).enumerated() {
  175. expectFieldsEqual(pair.0, pair.1, event: index)
  176. }
  177. }
  178. private func nativeEvents(
  179. _ objectIDs: [NSManagedObjectID],
  180. orphanedResumes: [NSManagedObjectID] = []
  181. ) async throws -> [PumpHistoryEvent] {
  182. try await testContext.perform {
  183. OpenAPS.nativePumpHistory(objectIDs, orphanedResumes: orphanedResumes, from: self.testContext)
  184. }
  185. }
  186. /// Field-by-field comparison, comparing `timestamp` at millisecond resolution.
  187. private func expectFieldsEqual(_ actual: PumpHistoryEvent, _ expected: PumpHistoryEvent, event index: Int) {
  188. #expect(actual.id == expected.id, "event \(index): id \(actual.id) != \(expected.id)")
  189. #expect(actual.type == expected.type, "event \(index): type \(actual.type) != \(expected.type)")
  190. #expect(
  191. Self.millisecondString(actual.timestamp) == Self.millisecondString(expected.timestamp),
  192. "event \(index): timestamp mismatch"
  193. )
  194. #expect(actual.amount == expected.amount, "event \(index): amount \(desc(actual.amount)) != \(desc(expected.amount))")
  195. #expect(
  196. actual.duration == expected.duration,
  197. "event \(index): duration \(desc(actual.duration)) != \(desc(expected.duration))"
  198. )
  199. #expect(
  200. actual.durationMin == expected.durationMin,
  201. "event \(index): durationMin \(desc(actual.durationMin)) != \(desc(expected.durationMin))"
  202. )
  203. #expect(actual.rate == expected.rate, "event \(index): rate \(desc(actual.rate)) != \(desc(expected.rate))")
  204. #expect(actual.temp == expected.temp, "event \(index): temp \(desc(actual.temp)) != \(desc(expected.temp))")
  205. #expect(actual.isSMB == expected.isSMB, "event \(index): isSMB \(desc(actual.isSMB)) != \(desc(expected.isSMB))")
  206. #expect(
  207. actual.isExternal == expected.isExternal,
  208. "event \(index): isExternal \(desc(actual.isExternal)) != \(desc(expected.isExternal))"
  209. )
  210. }
  211. private func desc<T>(_ value: T?) -> String { value.map { "\($0)" } ?? "nil" }
  212. /// Builds a `Decimal` from its string form, matching `Decimal(algorithmValue:)`.
  213. private func dec(_ string: String) -> Decimal { Decimal(string: string) ?? .zero }
  214. private static func millisecondString(_ date: Date) -> String {
  215. Formatter.iso8601withFractionalSeconds.string(from: date)
  216. }
  217. // MARK: - Fixture helpers
  218. private func insertBolus(
  219. id: String,
  220. date: Date,
  221. amount: Double,
  222. isSMB: Bool,
  223. isExternal: Bool
  224. ) async throws -> NSManagedObjectID {
  225. try await testContext.perform {
  226. let event = PumpEventStored(context: self.testContext)
  227. event.id = id
  228. event.timestamp = date
  229. event.type = PumpEventStored.EventType.bolus.rawValue
  230. let bolus = BolusStored(context: self.testContext)
  231. bolus.amount = NSDecimalNumber(value: amount)
  232. bolus.isSMB = isSMB
  233. bolus.isExternal = isExternal
  234. bolus.pumpEvent = event
  235. try self.testContext.save()
  236. return event.objectID
  237. }
  238. }
  239. private func insertTempBasal(
  240. id: String,
  241. date: Date,
  242. rate: Double?,
  243. durationMinutes: Int,
  244. tempType: String?
  245. ) async throws -> NSManagedObjectID {
  246. try await testContext.perform {
  247. let event = PumpEventStored(context: self.testContext)
  248. event.id = id
  249. event.timestamp = date
  250. event.type = PumpEventStored.EventType.tempBasal.rawValue
  251. let tempBasal = TempBasalStored(context: self.testContext)
  252. tempBasal.rate = rate.map { NSDecimalNumber(value: $0) }
  253. tempBasal.duration = Int16(durationMinutes)
  254. tempBasal.tempType = tempType
  255. tempBasal.pumpEvent = event
  256. try self.testContext.save()
  257. return event.objectID
  258. }
  259. }
  260. private func insertStatusEvent(
  261. id: String,
  262. date: Date,
  263. type: PumpEventStored.EventType
  264. ) async throws -> NSManagedObjectID {
  265. try await testContext.perform {
  266. let event = PumpEventStored(context: self.testContext)
  267. event.id = id
  268. event.timestamp = date
  269. event.type = type.rawValue
  270. try self.testContext.save()
  271. return event.objectID
  272. }
  273. }
  274. /// A fixed base timestamp on whole seconds so fixtures are deterministic.
  275. private func fixedDate(_ minutesAgo: Double) -> Date {
  276. Date(timeIntervalSince1970: 1_700_000_000 - minutesAgo * 60)
  277. }
  278. private func uuid(_ n: Int) -> String {
  279. String(format: "00000000-0000-0000-0000-%012d", n)
  280. }
  281. }