CarbsStorageTests.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import CoreData
  2. import Foundation
  3. import Swinject
  4. import Testing
  5. @testable import Trio
  6. @Suite("CarbsStorage Tests", .serialized) struct CarbsStorageTests: Injectable {
  7. @Injected() var storage: CarbsStorage!
  8. let resolver: Resolver
  9. var coreDataStack: CoreDataStack!
  10. var testContext: NSManagedObjectContext!
  11. init() async throws {
  12. // Create test context
  13. coreDataStack = try await CoreDataStack.createForTests()
  14. testContext = coreDataStack.newTaskContext()
  15. // Create assembler with test assembly
  16. let assembler = Assembler([
  17. StorageAssembly(),
  18. ServiceAssembly(),
  19. APSAssembly(),
  20. NetworkAssembly(),
  21. UIAssembly(),
  22. SecurityAssembly(),
  23. TestAssembly(testContext: testContext)
  24. ])
  25. resolver = assembler.resolver
  26. injectServices(resolver)
  27. }
  28. @Test("Storage is correctly initialized") func testStorageInitialization() {
  29. #expect(storage != nil, "CarbsStorage should be injected")
  30. #expect(storage is BaseCarbsStorage, "Storage should be of type BaseCarbsStorage")
  31. #expect(storage.updatePublisher != nil, "Update publisher should be available")
  32. }
  33. @Test("Store and retrieve carbs entries") func testStoreAndRetrieveCarbs() async throws {
  34. // Given
  35. let testEntries = [
  36. CarbsEntry(
  37. id: UUID().uuidString,
  38. createdAt: Date(),
  39. actualDate: Date(),
  40. carbs: 20,
  41. fat: 0,
  42. protein: 0,
  43. note: "Test meal",
  44. enteredBy: "Test",
  45. isFPU: false,
  46. fpuID: nil
  47. )
  48. ]
  49. // When
  50. try await storage.storeCarbs(testEntries, areFetchedFromRemote: false)
  51. let recentEntries = try await coreDataStack.fetchEntitiesAsync(
  52. ofType: CarbEntryStored.self,
  53. onContext: testContext,
  54. predicate: NSPredicate(format: "TRUEPREDICATE"),
  55. key: "date",
  56. ascending: false
  57. )
  58. guard let recentEntries = recentEntries as? [CarbEntryStored] else {
  59. throw TestError("Failed to get recent entries")
  60. }
  61. // Then
  62. #expect(!recentEntries.isEmpty, "Should have stored entries")
  63. #expect(recentEntries.count == 1, "Should have exactly one entry")
  64. #expect(recentEntries[0].carbs == 20, "Carbs value should match")
  65. #expect(recentEntries[0].fat == 0, "Fat value should match")
  66. #expect(recentEntries[0].protein == 0, "Protein value should match")
  67. #expect(recentEntries[0].note == "Test meal", "Note should match")
  68. }
  69. @Test("Delete carbs entry") func testDeleteCarbsEntry() async throws {
  70. // Given
  71. let testEntry = CarbsEntry(
  72. id: UUID().uuidString,
  73. createdAt: Date(),
  74. actualDate: Date(),
  75. carbs: 30,
  76. fat: nil,
  77. protein: nil,
  78. note: "Delete test",
  79. enteredBy: "Test",
  80. isFPU: false,
  81. fpuID: nil
  82. )
  83. // When
  84. try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
  85. // Get the stored entry's ObjectID
  86. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  87. ofType: CarbEntryStored.self,
  88. onContext: testContext,
  89. predicate: NSPredicate(format: "carbs == 30"),
  90. key: "date",
  91. ascending: false
  92. ) as? [CarbEntryStored]
  93. guard let objectID = storedEntries?.first?.objectID else {
  94. throw TestError("Failed to get stored entry's ObjectID")
  95. }
  96. // Delete the entry
  97. await storage.deleteCarbsEntryStored(objectID)
  98. // Then - verify deletion
  99. let remainingEntries = try await coreDataStack.fetchEntitiesAsync(
  100. ofType: CarbEntryStored.self,
  101. onContext: testContext,
  102. predicate: NSPredicate(format: "carbs == 30"),
  103. key: "date",
  104. ascending: false
  105. ) as? [CarbEntryStored]
  106. #expect(remainingEntries?.isEmpty == true, "Should have no entries after deletion")
  107. }
  108. @Test(
  109. "Store carb entry with fat/protein creates capped, spaced FPU entries (defaults: adjustment=0.5, delay=60m)"
  110. ) func testStoreFatProteinCarbEntryCreatesFPUEntries() async throws {
  111. let fpuID = UUID().uuidString
  112. let baseDate = Date(timeIntervalSince1970: 1_700_000_000)
  113. // Defaults:
  114. // adjustment = 0.5, delay = 60
  115. //
  116. // fat=50g -> 450 kcal
  117. // protein=100g -> 400 kcal
  118. // kcal total = 850
  119. // (kcal/10) = 85
  120. // 85 * 0.5 = 42.5
  121. // Int(42.5) = 42 equivalents -> two FPU entries: 21g each
  122. let mealEntry = CarbsEntry(
  123. id: UUID().uuidString,
  124. createdAt: baseDate,
  125. actualDate: baseDate,
  126. carbs: 30,
  127. fat: 50,
  128. protein: 100,
  129. note: "FPU deterministic default split test",
  130. enteredBy: "Test",
  131. isFPU: false,
  132. fpuID: fpuID
  133. )
  134. try await storage.storeCarbs([mealEntry], areFetchedFromRemote: false)
  135. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  136. ofType: CarbEntryStored.self,
  137. onContext: testContext,
  138. predicate: NSPredicate(format: "fpuID == %@", fpuID),
  139. key: "date",
  140. ascending: true
  141. ) as? [CarbEntryStored]
  142. guard let storedEntries else {
  143. throw TestError("Failed to fetch entries for fpuID")
  144. }
  145. #expect(!storedEntries.isEmpty, "Should have stored entries")
  146. let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
  147. #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
  148. #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
  149. #expect(originalCarbEntry?.fat == 50, "Original fat should match")
  150. #expect(originalCarbEntry?.protein == 100, "Original protein should match")
  151. let fpuEntries = storedEntries.filter { $0.isFPU == true }
  152. #expect(fpuEntries.count == 2, "Expected exactly one FPU entry under default settings")
  153. #expect(Int(fpuEntries[0].carbs) == 21, "Expected 20g carb equivalents under default settings")
  154. for fpuEntry in fpuEntries {
  155. #expect(fpuEntry.fat == 0, "FPU fat must be 0")
  156. #expect(fpuEntry.protein == 0, "FPU protein must be 0")
  157. #expect(fpuEntry.carbs >= 10, "FPU carbs must be >= 10g")
  158. #expect(fpuEntry.carbs <= 33, "FPU carbs must be <= 33g")
  159. #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
  160. }
  161. let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
  162. partialResult + Int(fpuEntry.carbs)
  163. }
  164. #expect(scheduledTotal <= 99, "Scheduled FPU carbs must be capped at 99g")
  165. // Timing: stable assertions
  166. // - first FPU entry must be at least +60m after the *input* timestamp (createdAt/actualDate),
  167. // but storage may choose a different internal baseDate, so don't assert exact equality.
  168. let fpuDates = fpuEntries.compactMap(\.date).sorted()
  169. #expect(fpuDates.count == 2, "FPU entry should have a date")
  170. let firstFpuDate = fpuDates[0]
  171. #expect(
  172. firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
  173. "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
  174. )
  175. #expect(
  176. storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
  177. "All entries should share the same fpuID"
  178. )
  179. }
  180. @Test(
  181. "Store very large fat/protein meal caps FPU equivalents at 99g and splits into 3×33g (defaults: adjustment=0.5, delay=60m)"
  182. ) func testStoreVeryLargeFatProteinMealCapsAndSplits() async throws {
  183. let fpuID = UUID().uuidString
  184. let baseDate = Date(timeIntervalSince1970: 1_700_001_000)
  185. // Defaults:
  186. // adjustment = 0.5, delay = 60
  187. //
  188. // fat=200g -> 1800 kcal
  189. // protein=200g -> 800 kcal
  190. // kcal total = 2600
  191. // (kcal/10) = 260
  192. // 260 * 0.5 = 130
  193. // Int(130) = 130 -> capped to 99 -> split into [33, 33, 33]
  194. let heftyMealEntry = CarbsEntry(
  195. id: UUID().uuidString,
  196. createdAt: baseDate,
  197. actualDate: baseDate,
  198. carbs: 30,
  199. fat: 200,
  200. protein: 200,
  201. note: "Hefty BBQ meal - cap test",
  202. enteredBy: "Test",
  203. isFPU: false,
  204. fpuID: fpuID
  205. )
  206. try await storage.storeCarbs([heftyMealEntry], areFetchedFromRemote: false)
  207. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  208. ofType: CarbEntryStored.self,
  209. onContext: testContext,
  210. predicate: NSPredicate(format: "fpuID == %@", fpuID),
  211. key: "date",
  212. ascending: true
  213. ) as? [CarbEntryStored]
  214. guard let storedEntries else {
  215. throw TestError("Failed to fetch entries for fpuID")
  216. }
  217. #expect(!storedEntries.isEmpty, "Should have stored entries")
  218. let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
  219. #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
  220. #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
  221. #expect(originalCarbEntry?.fat == 200, "Original fat should match")
  222. #expect(originalCarbEntry?.protein == 200, "Original protein should match")
  223. let fpuEntries = storedEntries.filter { $0.isFPU == true }
  224. #expect(fpuEntries.count == 3, "Capped large meal should create exactly 3 FPU entries")
  225. let fpuGrams = fpuEntries.map { Int($0.carbs) }
  226. #expect(fpuGrams == [33, 33, 33], "Expected capped split to be [33, 33, 33]")
  227. let scheduledTotal = fpuEntries.reduce(0) { partialResult, fpuEntry in
  228. partialResult + Int(fpuEntry.carbs)
  229. }
  230. #expect(scheduledTotal == 99, "Total scheduled FPU grams should be exactly 99g after cap")
  231. for fpuEntry in fpuEntries {
  232. #expect(fpuEntry.fat == 0, "FPU entry fat must be 0")
  233. #expect(fpuEntry.protein == 0, "FPU entry protein must be 0")
  234. #expect(fpuEntry.carbs >= 10, "FPU entry carbs must be >= 10g")
  235. #expect(fpuEntry.carbs <= 33, "FPU entry carbs must be <= 33g")
  236. #expect(Double(fpuEntry.carbs).truncatingRemainder(dividingBy: 1) == 0, "FPU carbs must be whole grams")
  237. }
  238. // Timing: stable assertions
  239. let fpuDates = fpuEntries.compactMap(\.date).sorted()
  240. #expect(fpuDates.count == 3, "All FPU entries should have a date")
  241. let firstFpuDate = fpuDates[0]
  242. #expect(
  243. firstFpuDate >= baseDate.addingTimeInterval(60 * 60),
  244. "First FPU entry should not be scheduled earlier than +60 minutes after the input timestamp"
  245. )
  246. for index in 1 ..< fpuDates.count {
  247. let spacingSeconds = fpuDates[index].timeIntervalSince(fpuDates[index - 1])
  248. #expect(Int(spacingSeconds) == 30 * 60, "FPU entries should be spaced +30 minutes apart")
  249. }
  250. #expect(
  251. storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
  252. "All entries should share the same fpuID"
  253. )
  254. }
  255. @Test(
  256. "Store small fat/protein meal drops FPU equivalents when total would be <10g (defaults: adjustment=0.5, delay=60m)"
  257. ) func testStoreSmallFatProteinMealDropsFPUBelowMinimum() async throws {
  258. let fpuID = UUID().uuidString
  259. let baseDate = Date(timeIntervalSince1970: 1_700_002_000)
  260. // Defaults:
  261. // adjustment = 0.5
  262. //
  263. // fat=2g -> 18 kcal
  264. // protein=2g -> 8 kcal
  265. // kcal total = 26
  266. // (kcal/10) = 2.6
  267. // 2.6 * 0.5 = 1.3
  268. // Int(1.3) = 1 (<10) -> should be dropped (no FPU entries)
  269. let smallMealEntry = CarbsEntry(
  270. id: UUID().uuidString,
  271. createdAt: baseDate,
  272. actualDate: baseDate,
  273. carbs: 30,
  274. fat: 2,
  275. protein: 2,
  276. note: "Tiny macros - min threshold test",
  277. enteredBy: "Test",
  278. isFPU: false,
  279. fpuID: fpuID
  280. )
  281. try await storage.storeCarbs([smallMealEntry], areFetchedFromRemote: false)
  282. let storedEntries = try await coreDataStack.fetchEntitiesAsync(
  283. ofType: CarbEntryStored.self,
  284. onContext: testContext,
  285. predicate: NSPredicate(format: "fpuID == %@", fpuID),
  286. key: "date",
  287. ascending: true
  288. ) as? [CarbEntryStored]
  289. guard let storedEntries else {
  290. throw TestError("Failed to fetch entries for fpuID")
  291. }
  292. #expect(!storedEntries.isEmpty, "Should have stored at least the original entry")
  293. let originalCarbEntry = storedEntries.first(where: { $0.isFPU == false })
  294. #expect(originalCarbEntry != nil, "Should have one non-FPU original entry")
  295. #expect(originalCarbEntry?.carbs == 30, "Original carbs should match")
  296. #expect(originalCarbEntry?.fat == 2, "Original fat should match")
  297. #expect(originalCarbEntry?.protein == 2, "Original protein should match")
  298. let fpuEntries = storedEntries.filter { $0.isFPU == true }
  299. #expect(fpuEntries.isEmpty == true, "No FPU entries should be created when equivalents are <10g")
  300. #expect(
  301. storedEntries.allSatisfy { $0.fpuID?.uuidString == fpuID },
  302. "All entries should share the same fpuID"
  303. )
  304. }
  305. @Test("Get carbs not yet uploaded to Nightscout") func testGetCarbsNotYetUploadedToNightscout() async throws {
  306. // Given
  307. let testEntry = CarbsEntry(
  308. id: UUID().uuidString,
  309. createdAt: Date(),
  310. actualDate: Date(),
  311. carbs: 40,
  312. fat: nil,
  313. protein: nil,
  314. note: "NS test",
  315. enteredBy: "Test",
  316. isFPU: false,
  317. fpuID: nil
  318. )
  319. // When
  320. try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
  321. let notUploadedEntries = try await storage.getCarbsNotYetUploadedToNightscout()
  322. // Then
  323. #expect(!notUploadedEntries.isEmpty, "Should have entries not uploaded to NS")
  324. #expect(notUploadedEntries[0].carbs == 40, "Carbs value should match")
  325. }
  326. @Test("Get FPUs not yet uploaded to Nightscout") func testGetFPUsNotYetUploadedToNightscout() async throws {
  327. // Given
  328. let fpuID = UUID().uuidString
  329. let testEntry = CarbsEntry(
  330. id: UUID().uuidString,
  331. createdAt: Date(),
  332. actualDate: Date(),
  333. carbs: 30,
  334. fat: 20,
  335. protein: 10,
  336. note: "FPU test",
  337. enteredBy: "Test",
  338. isFPU: false,
  339. fpuID: fpuID
  340. )
  341. // When
  342. try await storage.storeCarbs([testEntry], areFetchedFromRemote: false)
  343. // First verify all stored entries
  344. let allStoredEntries = try await coreDataStack.fetchEntitiesAsync(
  345. ofType: CarbEntryStored.self,
  346. onContext: testContext,
  347. predicate: NSPredicate(format: "fpuID == %@", fpuID),
  348. key: "date",
  349. ascending: true
  350. ) as? [CarbEntryStored]
  351. // Then verify the stored entries
  352. #expect(allStoredEntries?.isEmpty == false, "Should have stored entries")
  353. #expect(allStoredEntries?.count ?? 0 > 1, "Should have multiple entries due to FPU splitting")
  354. // Original carb-non-fpu entry should be stored with original fat and protein values and isFPU set to false
  355. let carbNonFpuEntry = allStoredEntries?.first(where: { $0.isFPU == false })
  356. #expect(carbNonFpuEntry != nil, "Should have one carb non-fpu entry")
  357. #expect(carbNonFpuEntry?.carbs == 30, "Original carbs should match")
  358. #expect(carbNonFpuEntry?.protein == 10, "Original carbs should match")
  359. #expect(carbNonFpuEntry?.fat == 20, "Original carbs should match")
  360. // Additional carb-fpu entries should be created for fat/protein with isFPU set to true and the carbs set to the amount of each carbEquivalent
  361. let carbFpuEntry = allStoredEntries?.filter { $0.isFPU == true }
  362. #expect(carbFpuEntry?.isEmpty == false, "Should have additional carb-fpu entries")
  363. // Now test the Nightscout upload function
  364. let notUploadedFPUs = try await storage.getFPUsNotYetUploadedToNightscout()
  365. // Then verify Nightscout entries
  366. #expect(!notUploadedFPUs.isEmpty, "Should have FPUs not uploaded to NS")
  367. let fpu = notUploadedFPUs[0]
  368. #expect(fpu.carbs ?? 0 < 30, "Original carbs value should match")
  369. #expect(fpu.protein == 0, "Protein value should match")
  370. #expect(fpu.fat == 0, "Fat value should match")
  371. // Verify all entries share the same fpuID
  372. #expect(
  373. allStoredEntries?.allSatisfy { $0.fpuID?.uuidString == fpuID } == true,
  374. "All entries should share the same fpuID"
  375. )
  376. }
  377. }