CarbsNativeConversionTests.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. import CoreData
  2. import Foundation
  3. import Testing
  4. @testable import Trio
  5. /// Golden tests certifying that the native `CarbEntryStored` → `CarbsEntry` mapping
  6. /// (`BaseCarbsStorage.mapToCarbsEntry`) reproduces, field for field, the carbs the algorithm used to receive
  7. /// through the old JSON round-trip (`CarbEntryStored` → JSON → `JSONBridge.carbs`) — with one
  8. /// deliberate, documented change: `id`.
  9. ///
  10. /// The old encode-side keys didn't line up with the `CarbsEntry` decode-side `CodingKeys`, so a
  11. /// couple of fields silently dropped to nil crossing the boundary. The golden literals below were
  12. /// captured from that old path while it still existed (via a temporary differential run), so a match
  13. /// proves the algorithm still sees identical inputs now that the JSON round-trip is gone:
  14. /// - `id` was encoded under "id" but decoded from "_id" → **always nil** in the old path. We now
  15. /// populate it from Core Data (harmless to the algorithm, which never reads `id`); the goldens
  16. /// below therefore carry the real id and `testIdIsNowPopulatedFromCoreData` pins the difference.
  17. /// - `note` was encoded under "note" but decoded from "notes" → **always nil**. We preserve that.
  18. ///
  19. /// The comparison is field by field (see `expectFieldsEqual`), NOT `==`: `CarbsEntry.==` only
  20. /// compares `createdAt`, so a plain array comparison would pass even if
  21. /// `id`/`carbs`/`fat`/`protein`/`note`/`isFPU`/`actualDate` differed — exactly the coerced fields
  22. /// this migration must preserve.
  23. ///
  24. /// Fixtures use fixed dates and ids so the mapping is fully deterministic.
  25. @Suite("Carbs Native Conversion Tests", .serialized) struct CarbsNativeConversionTests {
  26. var coreDataStack: CoreDataStack!
  27. var testContext: NSManagedObjectContext!
  28. init() async throws {
  29. coreDataStack = try await CoreDataStack.createForTests()
  30. testContext = coreDataStack.newTaskContext()
  31. }
  32. // MARK: - Golden tests (native mapping vs frozen old-path output)
  33. @Test("A basic carb entry maps identically (id now populated)") func testBasicEntry() async throws {
  34. await insertCarb(carbs: 30, isFPU: false, date: fixedDate(minutesAgo: 0), note: "breakfast", id: uuid(1))
  35. try await assertNativeMatchesGolden([
  36. CarbsEntry(
  37. id: uuid(1).uuidString,
  38. createdAt: Date(timeIntervalSince1970: 1_700_000_000),
  39. actualDate: Date(timeIntervalSince1970: 1_700_000_000),
  40. carbs: 30,
  41. fat: 0,
  42. protein: 0,
  43. note: nil, // old path decoded "note" as "notes" → nil; preserved
  44. enteredBy: CarbsEntry.local,
  45. isFPU: false,
  46. fpuID: nil
  47. )
  48. ])
  49. }
  50. @Test("Fractional carbs/fat/protein keep clean decimal values") func testFractionalDecimals() async throws {
  51. // 33.33 as a Double is 33.32999999999999488; the old JSON round-trip recovered the clean
  52. // 33.33 (JSONEncoder writes the shortest round-trippable string). `Decimal(Double)` would
  53. // leak the binary expansion, so `algorithmDecimal` must reproduce the clean value.
  54. await insertCarb(carbs: 33.33, isFPU: true, date: fixedDate(minutesAgo: 0), fat: 5.5, protein: 3.2, id: uuid(1))
  55. await insertCarb(carbs: 12.5, isFPU: false, date: fixedDate(minutesAgo: 5), id: uuid(2))
  56. try await assertNativeMatchesGolden([
  57. CarbsEntry(
  58. id: uuid(1).uuidString,
  59. createdAt: Date(timeIntervalSince1970: 1_700_000_000),
  60. actualDate: Date(timeIntervalSince1970: 1_700_000_000),
  61. carbs: Decimal(string: "33.33")!,
  62. fat: Decimal(string: "5.5")!,
  63. protein: Decimal(string: "3.2")!,
  64. note: nil,
  65. enteredBy: CarbsEntry.local,
  66. isFPU: true,
  67. fpuID: nil
  68. ),
  69. CarbsEntry(
  70. id: uuid(2).uuidString,
  71. createdAt: Date(timeIntervalSince1970: 1_699_999_700),
  72. actualDate: Date(timeIntervalSince1970: 1_699_999_700),
  73. carbs: Decimal(string: "12.5")!,
  74. fat: 0,
  75. protein: 0,
  76. note: nil,
  77. enteredBy: CarbsEntry.local,
  78. isFPU: false,
  79. fpuID: nil
  80. )
  81. ])
  82. let native = try await nativeCarbsEntries()
  83. #expect(native.first?.carbs == Decimal(string: "33.33")!, "33.33 must stay clean, not the Double expansion")
  84. }
  85. @Test("A zero-carb entry (nil stored id) maps identically") func testZeroCarbNilId() async throws {
  86. // The old path always produced id == nil, so a stored entry with no id is indistinguishable
  87. // there. Natively, a nil stored id still maps to a nil `CarbsEntry.id`.
  88. await insertCarb(carbs: 0, isFPU: false, date: fixedDate(minutesAgo: 0), id: nil)
  89. try await assertNativeMatchesGolden([
  90. CarbsEntry(
  91. id: nil,
  92. createdAt: Date(timeIntervalSince1970: 1_700_000_000),
  93. actualDate: Date(timeIntervalSince1970: 1_700_000_000),
  94. carbs: 0,
  95. fat: 0,
  96. protein: 0,
  97. note: nil,
  98. enteredBy: CarbsEntry.local,
  99. isFPU: false,
  100. fpuID: nil
  101. )
  102. ])
  103. }
  104. @Test("A multi-entry sequence maps identically") func testMultiEntrySequence() async throws {
  105. for i in 0 ..< 5 {
  106. await insertCarb(
  107. carbs: Double(10 * (i + 1)),
  108. isFPU: false,
  109. date: fixedDate(minutesAgo: Double(i) * 5),
  110. id: uuid(i + 1)
  111. )
  112. }
  113. try await assertNativeMatchesGolden((0 ..< 5).map { i in
  114. CarbsEntry(
  115. id: uuid(i + 1).uuidString,
  116. createdAt: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
  117. actualDate: Date(timeIntervalSince1970: 1_700_000_000 - Double(i) * 300),
  118. carbs: Decimal(10 * (i + 1)),
  119. fat: 0,
  120. protein: 0,
  121. note: nil,
  122. enteredBy: CarbsEntry.local,
  123. isFPU: false,
  124. fpuID: nil
  125. )
  126. })
  127. }
  128. @Test("Sub-millisecond dates are truncated to millisecond resolution") func testMillisecondTruncation() async throws {
  129. // The old path round-tripped the stored date through an ISO8601 fractional-seconds string
  130. // (millisecond precision). The native path keeps the full-precision `Date`, but only
  131. // millisecond precision is ever observable, so the comparison is at ms resolution.
  132. await insertCarb(carbs: 25, isFPU: false, date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456), id: uuid(1))
  133. try await assertNativeMatchesGolden([
  134. CarbsEntry(
  135. id: uuid(1).uuidString,
  136. createdAt: Date(timeIntervalSince1970: 1_700_000_000.123),
  137. actualDate: Date(timeIntervalSince1970: 1_700_000_000.123),
  138. carbs: 25,
  139. fat: 0,
  140. protein: 0,
  141. note: nil,
  142. enteredBy: CarbsEntry.local,
  143. isFPU: false,
  144. fpuID: nil
  145. )
  146. ])
  147. }
  148. // MARK: - The one deliberate behavioral change
  149. @Test("id is now populated from Core Data (old path always produced nil)") func testIdIsNowPopulatedFromCoreData() async throws {
  150. await insertCarb(carbs: 40, isFPU: false, date: fixedDate(minutesAgo: 0), id: uuid(7))
  151. let native = try await nativeCarbsEntries()
  152. // Old JSON path: `id` was encoded under "id" but decoded from "_id", so this was always nil.
  153. // The native mapping carries the real Core Data id instead.
  154. #expect(native.first?.id == uuid(7).uuidString, "native mapping must carry the Core Data id")
  155. }
  156. // MARK: - Additional (synthetic) carbs entry
  157. @Test("The synthetic additional-carbs entry matches the old spliced dictionary") func testAdditionalCarbsEntry() {
  158. let date = Date(timeIntervalSince1970: 1_700_000_000)
  159. let id = uuid(99).uuidString
  160. let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 15, date: date, id: id)
  161. // Old path spliced a dictionary that decoded to: id=nil (encoded "id", decoded "_id"),
  162. // note=nil, fat=0, protein=0, isFPU=false, enteredBy="Trio", both dates == the passed date.
  163. // The only intended difference is `id`, which we now carry (see `additionalCarbsEntry`).
  164. let expected = CarbsEntry(
  165. id: id,
  166. createdAt: date,
  167. actualDate: date,
  168. carbs: 15,
  169. fat: 0,
  170. protein: 0,
  171. note: nil,
  172. enteredBy: CarbsEntry.local,
  173. isFPU: false,
  174. fpuID: nil
  175. )
  176. expectFieldsEqual(entry, expected, entry: 0)
  177. }
  178. @Test("A zero additional-carbs entry (the normal loop case) is well formed") func testAdditionalCarbsZero() {
  179. // In the normal determine-basal loop `additionalCarbs` is `simulatedCarbsAmount ?? 0`, so a
  180. // carbs=0 entry is always appended. MealHistory/AutosensGenerator drop carbs <= 0, so it is
  181. // inert, but we still reproduce the old shape exactly.
  182. let date = Date(timeIntervalSince1970: 1_700_000_000)
  183. let entry = BaseCarbsStorage.additionalCarbsEntry(carbs: 0, date: date, id: uuid(1).uuidString)
  184. #expect(entry.carbs == 0)
  185. #expect(entry.isFPU == false)
  186. #expect(entry.enteredBy == CarbsEntry.local)
  187. }
  188. // MARK: - Comparison helpers
  189. /// Asserts the native mapping reproduces the frozen golden `CarbsEntry` values. The goldens were
  190. /// captured from the old `CarbEntryStored` → JSON → `JSONBridge.carbs` path (see the differential
  191. /// run in this migration's history), except `id`, which the old path always dropped to nil and
  192. /// which we now populate from Core Data.
  193. private func assertNativeMatchesGolden(_ golden: [CarbsEntry]) async throws {
  194. let native = try await nativeCarbsEntries()
  195. #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
  196. for (index, pair) in zip(native, golden).enumerated() {
  197. expectFieldsEqual(pair.0, pair.1, entry: index)
  198. }
  199. }
  200. /// Field-by-field comparison. We can't use `==`: `CarbsEntry.==` only compares `createdAt`, so a
  201. /// direct comparison would pass even if `id`/`carbs`/`fat`/`protein`/`note`/`isFPU`/`actualDate`
  202. /// differed — exactly the coerced fields we must pin.
  203. ///
  204. /// `createdAt`/`actualDate` are compared at millisecond resolution rather than as exact `Date`s:
  205. /// the mapping keeps the reading's full-precision date, but only millisecond precision is ever
  206. /// observable (it's what the old ISO8601 round-trip preserved) and Core Data's `Double` storage
  207. /// perturbs sub-millisecond bits anyway, so an exact `Date` comparison would be flaky.
  208. private func expectFieldsEqual(_ actual: CarbsEntry, _ expected: CarbsEntry, entry index: Int) {
  209. #expect(actual.id == expected.id, "entry \(index): id \(actual.id ?? "nil") != \(expected.id ?? "nil")")
  210. #expect(
  211. Self.millisecondString(actual.createdAt) == Self.millisecondString(expected.createdAt),
  212. "entry \(index): createdAt \(Self.millisecondString(actual.createdAt)) != \(Self.millisecondString(expected.createdAt))"
  213. )
  214. #expect(
  215. actual.actualDate.map(Self.millisecondString) == expected.actualDate.map(Self.millisecondString),
  216. "entry \(index): actualDate mismatch"
  217. )
  218. #expect(actual.carbs == expected.carbs, "entry \(index): carbs \(actual.carbs) != \(expected.carbs)")
  219. #expect(
  220. actual.fat == expected.fat,
  221. "entry \(index): fat \(String(describing: actual.fat)) != \(String(describing: expected.fat))"
  222. )
  223. #expect(
  224. actual.protein == expected.protein,
  225. "entry \(index): protein \(String(describing: actual.protein)) != \(String(describing: expected.protein))"
  226. )
  227. #expect(actual.note == expected.note, "entry \(index): note \(actual.note ?? "nil") != \(expected.note ?? "nil")")
  228. #expect(
  229. actual.enteredBy == expected.enteredBy,
  230. "entry \(index): enteredBy \(actual.enteredBy ?? "nil") != \(expected.enteredBy ?? "nil")"
  231. )
  232. #expect(
  233. actual.isFPU == expected.isFPU,
  234. "entry \(index): isFPU \(String(describing: actual.isFPU)) != \(String(describing: expected.isFPU))"
  235. )
  236. #expect(actual.fpuID == expected.fpuID, "entry \(index): fpuID \(actual.fpuID ?? "nil") != \(expected.fpuID ?? "nil")")
  237. }
  238. private static func millisecondString(_ date: Date) -> String {
  239. Formatter.iso8601withFractionalSeconds.string(from: date)
  240. }
  241. private func nativeCarbsEntries() async throws -> [CarbsEntry] {
  242. try await testContext.perform {
  243. try self.fetchRowsNewestFirst().map { BaseCarbsStorage.mapToCarbsEntry($0) }
  244. }
  245. }
  246. /// Must be called from within `testContext.perform`. Mirrors the `date`-descending order the
  247. /// production fetch uses (`BaseCarbsStorage.getCarbsForAlgorithm`).
  248. private func fetchRowsNewestFirst() throws -> [CarbEntryStored] {
  249. let request = CarbEntryStored.fetchRequest()
  250. request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
  251. return try testContext.fetch(request)
  252. }
  253. // MARK: - Fixture helpers
  254. private func insertCarb(
  255. carbs: Double,
  256. isFPU: Bool,
  257. date: Date,
  258. fat: Double = 0,
  259. protein: Double = 0,
  260. note: String? = nil,
  261. id: UUID?,
  262. fpuID: UUID? = nil
  263. ) async {
  264. await testContext.perform {
  265. let object = CarbEntryStored(context: self.testContext)
  266. object.carbs = carbs
  267. object.isFPU = isFPU
  268. object.date = date
  269. object.fat = fat
  270. object.protein = protein
  271. object.note = note
  272. object.id = id
  273. object.fpuID = fpuID
  274. try! self.testContext.save()
  275. }
  276. }
  277. /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
  278. private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
  279. Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
  280. }
  281. private func uuid(_ n: Int) -> UUID {
  282. UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
  283. }
  284. }