MealCobBucketingTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Meal glucose bucketing tests") struct MealCobBucketingTests {
  5. // Default test profile - matches JS exactly
  6. func createDefaultProfile() -> Profile {
  7. var profile = Profile()
  8. profile.dia = 4
  9. profile.maxMealAbsorptionTime = 6
  10. profile.min5mCarbImpact = 3
  11. profile.carbRatio = 10
  12. return profile
  13. }
  14. // Helper to create glucose entry - matches JS structure
  15. func createGlucoseEntry(glucose: Int, timeMs: Double) -> BloodGlucose {
  16. let date = Date(timeIntervalSince1970: timeMs / 1000)
  17. return BloodGlucose(
  18. sgv: glucose,
  19. date: Decimal(timeMs),
  20. dateString: date,
  21. glucose: glucose
  22. )
  23. }
  24. // Note: glucose_data is expected in reverse chronological order (newest first)
  25. // The bucketGlucoseData function maintains this order in its output
  26. @Test(
  27. "should handle normal 5-minute interval data without modification"
  28. ) func shouldHandleNormal5MinuteIntervalDataWithoutModification() async throws {
  29. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  30. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  31. // Create regular 5-minute interval data (chronological order)
  32. var glucose_data = [
  33. createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
  34. createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
  35. createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
  36. createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000)
  37. ]
  38. glucose_data.reverse() // Convert to reverse chronological order
  39. let result = try MealCob.bucketGlucoseForCob(
  40. glucose: glucose_data,
  41. profile: createDefaultProfile(),
  42. mealDate: mealTime,
  43. carbImpactDate: nil
  44. )
  45. // Should return same number of entries
  46. #expect(result.count == 4)
  47. // Values should be unchanged (in reverse chronological order)
  48. #expect(result[0].glucose == 115)
  49. #expect(result[1].glucose == 110)
  50. #expect(result[2].glucose == 105)
  51. #expect(result[3].glucose == 100)
  52. }
  53. @Test("should interpolate missing data when gap > 8 minutes") func shouldInterpolateMissingDataWhenGapGreaterThan8Minutes(
  54. ) async throws {
  55. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  56. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  57. // Create data with a 21-minute gap (chronological order)
  58. var glucose_data = [
  59. createGlucoseEntry(glucose: 99, timeMs: mealTimeMs),
  60. createGlucoseEntry(glucose: 120, timeMs: mealTimeMs + 21 * 60 * 1000) // 21 min gap
  61. ]
  62. glucose_data.reverse() // Convert to reverse chronological order
  63. let result = try MealCob.bucketGlucoseForCob(
  64. glucose: glucose_data,
  65. profile: createDefaultProfile(),
  66. mealDate: mealTime,
  67. carbImpactDate: nil
  68. )
  69. // Should have interpolated 4 additional points (5, 10, 15, 20 minutes)
  70. #expect(result.count == 5)
  71. // Check interpolated values (in reverse chronological order)
  72. #expect(result[0].glucose == 120) // original (newest)
  73. #expect(result[1].glucose == 115) // interpolated
  74. #expect(result[2].glucose == 110) // interpolated
  75. #expect(result[3].glucose == 105) // interpolated
  76. #expect(result[4].glucose == 100) // interpolated
  77. // Check that dates are properly set
  78. #expect(result[1].date == mealTime.addingTimeInterval(16 * 60))
  79. #expect(result[2].date == mealTime.addingTimeInterval(11 * 60))
  80. #expect(result[3].date == mealTime.addingTimeInterval(6 * 60))
  81. #expect(result[4].date == mealTime.addingTimeInterval(1 * 60))
  82. }
  83. @Test("should stop processing after maxMealAbsorptionTime") func shouldStopProcessingAfterMaxMealAbsorptionTime() async throws {
  84. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  85. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  86. // Create data spanning 8 hours (chronological order)
  87. var glucose_data: [BloodGlucose] = []
  88. for i in 0 ... 96 { // 96 * 5 min = 8 hours
  89. glucose_data.append(createGlucoseEntry(
  90. glucose: 100 + i,
  91. timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
  92. ))
  93. }
  94. glucose_data.reverse() // Convert to reverse chronological order
  95. // Set maxMealAbsorptionTime to 2 hours
  96. var profile = createDefaultProfile()
  97. profile.maxMealAbsorptionTime = 2
  98. let result = try MealCob.bucketGlucoseForCob(
  99. glucose: glucose_data,
  100. profile: profile,
  101. mealDate: mealTime,
  102. carbImpactDate: nil
  103. )
  104. // JS test expects 72 entries (not 25 as in original Swift test)
  105. print(result)
  106. #expect(result.count == 72)
  107. // Check specific values to match JS test
  108. #expect(result[0].glucose == 196)
  109. #expect(result[1].glucose == 195)
  110. #expect(result[12].glucose == 178)
  111. #expect(result[24].glucose == 160)
  112. }
  113. @Test("should only process data within 45 minutes in CI mode") func shouldOnlyProcessDataWithin45MinutesInCIMode() async throws {
  114. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  115. let ciTime = Date.from(isoString: "2024-01-01T14:00:00-05:00") // 2 hours after meal
  116. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  117. // Create data spanning 3 hours (chronological order)
  118. var glucose_data: [BloodGlucose] = []
  119. for i in 0 ... 36 { // 36 * 5 min = 3 hours
  120. glucose_data.append(createGlucoseEntry(
  121. glucose: 100 + i,
  122. timeMs: mealTimeMs + Double(i) * 5 * 60 * 1000
  123. ))
  124. }
  125. glucose_data.reverse() // Convert to reverse chronological order
  126. let result = try MealCob.bucketGlucoseForCob(
  127. glucose: glucose_data,
  128. profile: createDefaultProfile(),
  129. mealDate: mealTime,
  130. carbImpactDate: ciTime
  131. )
  132. // JS test shows this captures more than 45 minutes due to the bucketing logic
  133. for entry in result {
  134. let minutesFromCI = abs(ciTime.timeIntervalSince(entry.date)) / 60
  135. #expect(minutesFromCI <= 120) // JS test uses 120, not 45
  136. }
  137. // JS test expects 21 entries
  138. #expect(result.count == 21)
  139. }
  140. @Test("should stop processing when pre-meal BG is found") func shouldStopProcessingWhenPreMealBGIsFound() async throws {
  141. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  142. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  143. // Create data that includes pre-meal values (chronological order)
  144. var glucose_data = [
  145. createGlucoseEntry(glucose: 90, timeMs: mealTimeMs - 10 * 60 * 1000), // 10 min before meal
  146. createGlucoseEntry(glucose: 95, timeMs: mealTimeMs - 5 * 60 * 1000), // 5 min before meal
  147. createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
  148. createGlucoseEntry(glucose: 105, timeMs: mealTimeMs + 5 * 60 * 1000),
  149. createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 10 * 60 * 1000),
  150. createGlucoseEntry(glucose: 115, timeMs: mealTimeMs + 15 * 60 * 1000) // 15 min after
  151. ]
  152. glucose_data.reverse() // Convert to reverse chronological order
  153. let result = try MealCob.bucketGlucoseForCob(
  154. glucose: glucose_data,
  155. profile: createDefaultProfile(),
  156. mealDate: mealTime,
  157. carbImpactDate: nil
  158. )
  159. // JS test expects 5 entries (includes one pre-meal entry due to bug)
  160. #expect(result.count == 5)
  161. // Values should be unchanged (in reverse chronological order)
  162. #expect(result[0].glucose == 115)
  163. #expect(result[1].glucose == 110)
  164. #expect(result[2].glucose == 105)
  165. #expect(result[3].glucose == 100)
  166. #expect(result[4].glucose == 95) // This pre-meal entry is included due to JS bug
  167. }
  168. @Test(
  169. "should average glucose values when readings are very close (≤ 2 minutes)"
  170. ) func shouldAverageGlucoseValuesWhenReadingsAreVeryClose() async throws {
  171. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  172. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  173. // Create data with readings 1 minute apart (chronological order)
  174. var glucose_data = [
  175. createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
  176. createGlucoseEntry(glucose: 102, timeMs: mealTimeMs + 1 * 60 * 1000), // 1 min later
  177. createGlucoseEntry(glucose: 104, timeMs: mealTimeMs + 2 * 60 * 1000), // 2 min later
  178. createGlucoseEntry(glucose: 110, timeMs: mealTimeMs + 5 * 60 * 1000) // 5 min later
  179. ]
  180. glucose_data.reverse() // Convert to reverse chronological order
  181. let result = try MealCob.bucketGlucoseForCob(
  182. glucose: glucose_data,
  183. profile: createDefaultProfile(),
  184. mealDate: mealTime,
  185. carbImpactDate: nil
  186. )
  187. // Close readings should be averaged (in reverse chronological order)
  188. #expect(result.count == 2)
  189. #expect(result[0].glucose == 110)
  190. // JS test shows averaging bug results in 101.5, not 102
  191. #expect(result[1].glucose == 101.5)
  192. }
  193. @Test("should cap interpolation at 240 minutes for very large gaps") func shouldCapInterpolationAt240MinutesForVeryLargeGaps(
  194. ) async throws {
  195. let mealTime = Date.from(isoString: "2024-01-01T12:00:00-05:00")
  196. let mealTimeMs = mealTime.timeIntervalSince1970 * 1000
  197. // Create data with a 6-hour (360 minute) gap (chronological order)
  198. var glucose_data = [
  199. createGlucoseEntry(glucose: 100, timeMs: mealTimeMs),
  200. createGlucoseEntry(glucose: 200, timeMs: mealTimeMs + 360 * 60 * 1000) // 6 hour gap
  201. ]
  202. glucose_data.reverse() // Convert to reverse chronological order
  203. let result = try MealCob.bucketGlucoseForCob(
  204. glucose: glucose_data,
  205. profile: createDefaultProfile(),
  206. mealDate: mealTime,
  207. carbImpactDate: nil
  208. )
  209. // JS test expects 48 entries due to capping at 240 minutes
  210. #expect(result.count == 48)
  211. // Check that interpolation stopped at 240 minutes
  212. let gapMinutes = result[0].date.timeIntervalSince(result[result.count - 1].date) / 60
  213. #expect(gapMinutes == 235)
  214. }
  215. }