GlucoseNativeConversionTests.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. import CoreData
  2. import Foundation
  3. import Testing
  4. @testable import Trio
  5. /// Golden tests certifying that the native `GlucoseStored` → `BloodGlucose` mapping
  6. /// (`BaseGlucoseStorage.mapToBloodGlucose`) reproduces, byte for byte, the glucose the algorithm used to
  7. /// receive through the old JSON round-trip (`GlucoseStored` → `AlgorithmGlucose` → JSON →
  8. /// `JSONBridge.glucose`). The golden literals below were captured from that old path while it
  9. /// still existed (via a temporary differential run), so a match proves the algorithm still sees
  10. /// identical inputs now that the JSON round-trip is gone.
  11. ///
  12. /// The comparison is field by field (see `expectFieldsEqual`), NOT `==`: `BloodGlucose.==` only
  13. /// compares `dateString`, so a plain array comparison would pass even if
  14. /// `glucose`/`sgv`/`direction`/`id`/`type` differed — exactly the coerced fields this migration
  15. /// must preserve.
  16. ///
  17. /// Fixtures use fixed dates and ids so the mapping is fully deterministic and independent of the
  18. /// wall-clock-relative time-window fetch (which is unchanged by this migration and stays covered
  19. /// by `GlucoseSmoothingTests`).
  20. @Suite("Glucose Native Conversion Tests", .serialized) struct GlucoseNativeConversionTests {
  21. var coreDataStack: CoreDataStack!
  22. var testContext: NSManagedObjectContext!
  23. init() async throws {
  24. coreDataStack = try await CoreDataStack.createForTests()
  25. testContext = coreDataStack.newTaskContext()
  26. }
  27. // MARK: - Golden tests (native mapping vs frozen old-path output)
  28. @Test("CGM readings without smoothing map identically") func testCGMNoSmoothing() async throws {
  29. await insertGlucose(glucose: 120, isManual: false, date: fixedDate(minutesAgo: 0), direction: "Flat", id: uuid(1))
  30. await insertGlucose(glucose: 95, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveDown", id: uuid(2))
  31. await insertGlucose(glucose: 150, isManual: true, date: fixedDate(minutesAgo: 10), direction: nil, id: uuid(3))
  32. try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
  33. BloodGlucose(
  34. id: uuid(1).uuidString,
  35. sgv: 120,
  36. direction: .flat,
  37. date: 1_700_000_000_000,
  38. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  39. type: "sgv"
  40. ),
  41. BloodGlucose(
  42. id: uuid(2).uuidString,
  43. sgv: 95,
  44. direction: .fortyFiveDown,
  45. date: 1_699_999_700_000,
  46. dateString: Date(timeIntervalSince1970: 1_699_999_700),
  47. type: "sgv"
  48. ),
  49. BloodGlucose(
  50. id: uuid(3).uuidString,
  51. date: 1_699_999_400_000,
  52. dateString: Date(timeIntervalSince1970: 1_699_999_400),
  53. glucose: 150,
  54. type: "sgv"
  55. )
  56. ])
  57. }
  58. @Test("Smoothed CGM value is used and rounded identically") func testSmoothedValueUsed() async throws {
  59. await insertGlucose(
  60. glucose: 120,
  61. isManual: false,
  62. date: fixedDate(minutesAgo: 0),
  63. smoothed: Decimal(string: "118.6"),
  64. direction: "Flat",
  65. id: uuid(1)
  66. )
  67. try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
  68. BloodGlucose(
  69. id: uuid(1).uuidString,
  70. sgv: 119,
  71. direction: .flat,
  72. date: 1_700_000_000_000,
  73. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  74. type: "sgv"
  75. )
  76. ])
  77. let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
  78. #expect(native.first?.sgv == 119, "118.6 should round to 119 in the sgv field")
  79. #expect(native.first?.glucose == nil, "CGM readings must not populate the manual `glucose` field")
  80. }
  81. @Test("Zero smoothed value falls back to the raw value") func testSmoothedZeroFallsBackToRaw() async throws {
  82. await insertGlucose(
  83. glucose: 120,
  84. isManual: false,
  85. date: fixedDate(minutesAgo: 0),
  86. smoothed: 0,
  87. direction: "Flat",
  88. id: uuid(1)
  89. )
  90. try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
  91. BloodGlucose(
  92. id: uuid(1).uuidString,
  93. sgv: 120,
  94. direction: .flat,
  95. date: 1_700_000_000_000,
  96. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  97. type: "sgv"
  98. )
  99. ])
  100. let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
  101. #expect(native.first?.sgv == 120, "A zero smoothed value must fall back to the raw 120")
  102. }
  103. @Test("Nil smoothed value falls back to the raw value") func testSmoothedNilFallsBackToRaw() async throws {
  104. await insertGlucose(
  105. glucose: 120,
  106. isManual: false,
  107. date: fixedDate(minutesAgo: 0),
  108. smoothed: nil,
  109. direction: "Flat",
  110. id: uuid(1)
  111. )
  112. try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
  113. BloodGlucose(
  114. id: uuid(1).uuidString,
  115. sgv: 120,
  116. direction: .flat,
  117. date: 1_700_000_000_000,
  118. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  119. type: "sgv"
  120. )
  121. ])
  122. let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
  123. #expect(native.first?.sgv == 120, "A nil smoothed value must fall back to the raw 120")
  124. }
  125. @Test("Manual entries ignore smoothing and populate the glucose field") func testManualIgnoresSmoothing() async throws {
  126. await insertGlucose(
  127. glucose: 150,
  128. isManual: true,
  129. date: fixedDate(minutesAgo: 0),
  130. smoothed: 140,
  131. direction: nil,
  132. id: uuid(1)
  133. )
  134. try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
  135. BloodGlucose(
  136. id: uuid(1).uuidString,
  137. date: 1_700_000_000_000,
  138. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  139. glucose: 150,
  140. type: "sgv"
  141. )
  142. ])
  143. let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
  144. #expect(native.first?.glucose == 150, "Manual entries must use the raw value in the `glucose` field")
  145. #expect(native.first?.sgv == nil, "Manual entries must not populate the `sgv` field")
  146. }
  147. @Test("Direction variants and nil map identically") func testDirectionVariants() async throws {
  148. await insertGlucose(glucose: 110, isManual: false, date: fixedDate(minutesAgo: 0), direction: "TripleUp", id: uuid(1))
  149. await insertGlucose(glucose: 111, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveUp", id: uuid(2))
  150. await insertGlucose(glucose: 112, isManual: false, date: fixedDate(minutesAgo: 10), direction: "NONE", id: uuid(3))
  151. await insertGlucose(glucose: 113, isManual: false, date: fixedDate(minutesAgo: 15), direction: nil, id: uuid(4))
  152. try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
  153. BloodGlucose(
  154. id: uuid(1).uuidString,
  155. sgv: 110,
  156. direction: .tripleUp,
  157. date: 1_700_000_000_000,
  158. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  159. type: "sgv"
  160. ),
  161. BloodGlucose(
  162. id: uuid(2).uuidString,
  163. sgv: 111,
  164. direction: .fortyFiveUp,
  165. date: 1_699_999_700_000,
  166. dateString: Date(timeIntervalSince1970: 1_699_999_700),
  167. type: "sgv"
  168. ),
  169. // `.none` here is BloodGlucose.Direction.none ("NONE"), not Optional.none — spell it out.
  170. BloodGlucose(
  171. id: uuid(3).uuidString,
  172. sgv: 112,
  173. direction: BloodGlucose.Direction.none,
  174. date: 1_699_999_400_000,
  175. dateString: Date(timeIntervalSince1970: 1_699_999_400),
  176. type: "sgv"
  177. ),
  178. BloodGlucose(
  179. id: uuid(4).uuidString,
  180. sgv: 113,
  181. date: 1_699_999_100_000,
  182. dateString: Date(timeIntervalSince1970: 1_699_999_100),
  183. type: "sgv"
  184. )
  185. ])
  186. }
  187. @Test("Sub-millisecond dates are truncated identically") func testMillisecondTruncation() async throws {
  188. // A date with sub-millisecond precision: the old path round-trips it through an ISO8601
  189. // fractional-seconds string (millisecond precision), so both fields must be ms-truncated.
  190. await insertGlucose(
  191. glucose: 100,
  192. isManual: false,
  193. date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456),
  194. direction: "Flat",
  195. id: uuid(1)
  196. )
  197. try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
  198. BloodGlucose(
  199. id: uuid(1).uuidString,
  200. sgv: 100,
  201. direction: .flat,
  202. date: 1_700_000_000_123,
  203. dateString: Date(timeIntervalSince1970: 1_700_000_000.123),
  204. type: "sgv"
  205. )
  206. ])
  207. }
  208. @Test("A descending multi-entry sequence maps identically") func testDescendingSequence() async throws {
  209. for i in 0 ..< 6 {
  210. await insertGlucose(
  211. glucose: Int16(100 + i),
  212. isManual: false,
  213. date: fixedDate(minutesAgo: Double(i) * 5),
  214. direction: "Flat",
  215. id: uuid(i + 1)
  216. )
  217. }
  218. try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
  219. BloodGlucose(
  220. id: uuid(1).uuidString,
  221. sgv: 100,
  222. direction: .flat,
  223. date: 1_700_000_000_000,
  224. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  225. type: "sgv"
  226. ),
  227. BloodGlucose(
  228. id: uuid(2).uuidString,
  229. sgv: 101,
  230. direction: .flat,
  231. date: 1_699_999_700_000,
  232. dateString: Date(timeIntervalSince1970: 1_699_999_700),
  233. type: "sgv"
  234. ),
  235. BloodGlucose(
  236. id: uuid(3).uuidString,
  237. sgv: 102,
  238. direction: .flat,
  239. date: 1_699_999_400_000,
  240. dateString: Date(timeIntervalSince1970: 1_699_999_400),
  241. type: "sgv"
  242. ),
  243. BloodGlucose(
  244. id: uuid(4).uuidString,
  245. sgv: 103,
  246. direction: .flat,
  247. date: 1_699_999_100_000,
  248. dateString: Date(timeIntervalSince1970: 1_699_999_100),
  249. type: "sgv"
  250. ),
  251. BloodGlucose(
  252. id: uuid(5).uuidString,
  253. sgv: 104,
  254. direction: .flat,
  255. date: 1_699_998_800_000,
  256. dateString: Date(timeIntervalSince1970: 1_699_998_800),
  257. type: "sgv"
  258. ),
  259. BloodGlucose(
  260. id: uuid(6).uuidString,
  261. sgv: 105,
  262. direction: .flat,
  263. date: 1_699_998_500_000,
  264. dateString: Date(timeIntervalSince1970: 1_699_998_500),
  265. type: "sgv"
  266. )
  267. ])
  268. }
  269. @Test("Smoothing rounding boundaries map identically") func testRoundingBoundaries() async throws {
  270. await insertGlucose(
  271. glucose: 100,
  272. isManual: false,
  273. date: fixedDate(minutesAgo: 0),
  274. smoothed: Decimal(string: "118.5"),
  275. id: uuid(1)
  276. )
  277. await insertGlucose(
  278. glucose: 100,
  279. isManual: false,
  280. date: fixedDate(minutesAgo: 5),
  281. smoothed: Decimal(string: "118.4"),
  282. id: uuid(2)
  283. )
  284. await insertGlucose(
  285. glucose: 100,
  286. isManual: false,
  287. date: fixedDate(minutesAgo: 10),
  288. smoothed: Decimal(string: "119.5"),
  289. id: uuid(3)
  290. )
  291. try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
  292. BloodGlucose(
  293. id: uuid(1).uuidString,
  294. sgv: 119,
  295. date: 1_700_000_000_000,
  296. dateString: Date(timeIntervalSince1970: 1_700_000_000),
  297. type: "sgv"
  298. ),
  299. BloodGlucose(
  300. id: uuid(2).uuidString,
  301. sgv: 118,
  302. date: 1_699_999_700_000,
  303. dateString: Date(timeIntervalSince1970: 1_699_999_700),
  304. type: "sgv"
  305. ),
  306. BloodGlucose(
  307. id: uuid(3).uuidString,
  308. sgv: 120,
  309. date: 1_699_999_400_000,
  310. dateString: Date(timeIntervalSince1970: 1_699_999_400),
  311. type: "sgv"
  312. )
  313. ])
  314. let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
  315. #expect(native.map(\.sgv) == [119, 118, 120], ".plain scale-0 rounding: 118.5→119, 118.4→118, 119.5→120")
  316. }
  317. // MARK: - Comparison helpers
  318. /// Asserts the native mapping reproduces the frozen golden `BloodGlucose` values. The goldens
  319. /// were captured from the old `AlgorithmGlucose` → JSON → `JSONBridge.glucose` path (see the
  320. /// differential run in this migration's history), so a match proves the algorithm still receives
  321. /// identical glucose after the JSON round-trip was removed.
  322. private func assertNativeMatchesGolden(shouldSmoothGlucose: Bool, _ golden: [BloodGlucose]) async throws {
  323. let native = try await nativeBloodGlucose(shouldSmoothGlucose: shouldSmoothGlucose)
  324. #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
  325. for (index, pair) in zip(native, golden).enumerated() {
  326. expectFieldsEqual(pair.0, pair.1, entry: index)
  327. }
  328. }
  329. /// Field-by-field comparison. We can't use `==`: `BloodGlucose.==` only compares `dateString`,
  330. /// so a direct comparison would pass even if `sgv`/`glucose`/`direction`/`id`/`type` differed —
  331. /// exactly the coerced fields we must pin.
  332. ///
  333. /// `dateString` is compared at millisecond resolution rather than as an exact `Date`. The mapping
  334. /// keeps the reading's full-precision date in memory, but only millisecond precision is ever
  335. /// observable — it's what `date` pins and what the value serializes to — and Core Data's `Double`
  336. /// round-trip perturbs sub-millisecond bits anyway, so an exact `Date` comparison would be flaky.
  337. private func expectFieldsEqual(_ actual: BloodGlucose, _ expected: BloodGlucose, entry index: Int) {
  338. #expect(actual.id == expected.id, "entry \(index): id \(actual.id) != \(expected.id)")
  339. #expect(actual.legacyId == expected.legacyId, "entry \(index): legacyId")
  340. #expect(
  341. actual.sgv == expected.sgv,
  342. "entry \(index): sgv \(actual.sgv.map(String.init) ?? "nil") != \(expected.sgv.map(String.init) ?? "nil")"
  343. )
  344. #expect(
  345. actual.glucose == expected.glucose,
  346. "entry \(index): glucose \(actual.glucose.map(String.init) ?? "nil") != \(expected.glucose.map(String.init) ?? "nil")"
  347. )
  348. #expect(actual.mbg == expected.mbg, "entry \(index): mbg")
  349. #expect(
  350. actual.direction == expected.direction,
  351. "entry \(index): direction \(actual.direction?.rawValue ?? "nil") != \(expected.direction?.rawValue ?? "nil")"
  352. )
  353. #expect(actual.date == expected.date, "entry \(index): date \(actual.date) != \(expected.date)")
  354. #expect(
  355. Self.millisecondString(actual.dateString) == Self.millisecondString(expected.dateString),
  356. "entry \(index): dateString \(Self.millisecondString(actual.dateString)) != \(Self.millisecondString(expected.dateString))"
  357. )
  358. #expect(actual.type == expected.type, "entry \(index): type \(actual.type ?? "nil") != \(expected.type ?? "nil")")
  359. #expect(actual.unfiltered == expected.unfiltered, "entry \(index): unfiltered")
  360. #expect(actual.filtered == expected.filtered, "entry \(index): filtered")
  361. #expect(actual.noise == expected.noise, "entry \(index): noise")
  362. #expect(actual.activationDate == expected.activationDate, "entry \(index): activationDate")
  363. #expect(actual.sessionStartDate == expected.sessionStartDate, "entry \(index): sessionStartDate")
  364. #expect(actual.transmitterID == expected.transmitterID, "entry \(index): transmitterID")
  365. }
  366. private static func millisecondString(_ date: Date) -> String {
  367. Formatter.iso8601withFractionalSeconds.string(from: date)
  368. }
  369. private func nativeBloodGlucose(shouldSmoothGlucose: Bool) async throws -> [BloodGlucose] {
  370. try await testContext.perform {
  371. try self.fetchRowsNewestFirst().map {
  372. BaseGlucoseStorage.mapToBloodGlucose(
  373. $0,
  374. shouldSmoothGlucose: shouldSmoothGlucose,
  375. roundingBehavior: Self.roundingBehavior
  376. )
  377. }
  378. }
  379. }
  380. /// Must be called from within `testContext.perform`.
  381. private func fetchRowsNewestFirst() throws -> [GlucoseStored] {
  382. let request = GlucoseStored.fetchRequest()
  383. request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
  384. return try testContext.fetch(request)
  385. }
  386. private static let roundingBehavior = NSDecimalNumberHandler(
  387. roundingMode: .plain,
  388. scale: 0,
  389. raiseOnExactness: false,
  390. raiseOnOverflow: false,
  391. raiseOnUnderflow: false,
  392. raiseOnDivideByZero: false
  393. )
  394. // MARK: - Fixture helpers
  395. private func insertGlucose(
  396. glucose: Int16,
  397. isManual: Bool,
  398. date: Date,
  399. smoothed: Decimal? = nil,
  400. direction: String? = nil,
  401. id: UUID
  402. ) async {
  403. await testContext.perform {
  404. let object = GlucoseStored(context: self.testContext)
  405. object.glucose = glucose
  406. object.isManual = isManual
  407. object.date = date
  408. object.smoothedGlucose = smoothed.map { NSDecimalNumber(decimal: $0) }
  409. object.direction = direction
  410. object.id = id
  411. try! self.testContext.save()
  412. }
  413. }
  414. /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
  415. private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
  416. Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
  417. }
  418. private func uuid(_ n: Int) -> UUID {
  419. UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
  420. }
  421. }