CarbsNativeConversionTests.swift 12 KB

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