MealTotalTests.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("MealTotal Tests") struct MealTotalTests {
  5. // Helper methods for testing
  6. func createBasicProfile() -> Profile {
  7. var profile = Profile()
  8. profile.dia = 4
  9. profile.maxMealAbsorptionTime = 6
  10. profile.maxCOB = 120
  11. // profile.carbsAbsorptionRate = 30
  12. profile.min5mCarbImpact = 3
  13. profile.carbRatio = 10
  14. profile.currentBasal = 1.0
  15. // Note: In Swift we need to set sensitivities differently than in JS
  16. profile
  17. .isfProfile = ComputedInsulinSensitivities(
  18. units: .mgdL,
  19. userPreferredUnits: .mgdL,
  20. sensitivities: [ComputedInsulinSensitivityEntry(sensitivity: 40, offset: 0, start: "00:00:00")]
  21. )
  22. return profile
  23. }
  24. func createBasicBasalProfile() -> [BasalProfileEntry] {
  25. [BasalProfileEntry(start: "00:00:00", minutes: 0, rate: 1.0)]
  26. }
  27. func createGlucoseData(baseTime: Date, pattern: [Int]) -> [BloodGlucose] {
  28. var result: [BloodGlucose] = []
  29. for (i, bg) in pattern.enumerated() {
  30. let timestamp = baseTime.addingTimeInterval(TimeInterval(i * 5 * 60))
  31. result.append(BloodGlucose(
  32. sgv: bg,
  33. date: Decimal(timestamp.timeIntervalSince1970 * 1000), // JS uses ms
  34. dateString: timestamp
  35. ))
  36. }
  37. return result.reversed()
  38. }
  39. @Test("should calculate carb absorption correctly") func calculateCarbAbsorption() async throws {
  40. let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  41. let mealTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  42. let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00") // 1 hour after meal
  43. // Create glucose data showing rise after carbs
  44. var bgValues = Array(repeating: 100, count: 13)
  45. for i in 3 ..< 8 {
  46. bgValues[i] = 100 + ((i - 2) * 10) // 100, 110, 120, 130, 140
  47. }
  48. for i in 8 ..< 13 {
  49. bgValues[i] = 150 // plateau
  50. }
  51. let glucoseData = createGlucoseData(baseTime: baseTime, pattern: bgValues)
  52. // Create insulin data - bolus at same time as carbs
  53. let pumpHistory = [
  54. PumpHistoryEvent(
  55. id: UUID().uuidString,
  56. type: .bolus,
  57. timestamp: mealTime,
  58. amount: 3.0
  59. )
  60. ]
  61. // Carb treatment
  62. let treatments = [
  63. MealInput(
  64. timestamp: mealTime,
  65. carbs: 30,
  66. bolus: nil
  67. )
  68. ]
  69. let profile = createBasicProfile()
  70. let basalProfile = createBasicBasalProfile()
  71. let result = try MealTotal.recentCarbs(
  72. treatments: treatments,
  73. pumpHistory: pumpHistory,
  74. profile: profile,
  75. basalProfile: basalProfile,
  76. glucose: glucoseData,
  77. time: testTime
  78. )
  79. // After 1 hour, we should see partial carb absorption
  80. #expect(result != nil)
  81. // at this level JS is rounding, thus the 0.5
  82. #expect(result!.mealCOB.isWithin(0.5, of: 10) == true, "mealCOB: \(result!.mealCOB.description)")
  83. #expect(
  84. result!.currentDeviation == 3.6,
  85. "currentDeviation: \(result!.currentDeviation!.description)"
  86. )
  87. }
  88. @Test("should return result with zero carbs when treatments is empty array") func emptyObjectWhenNoTreatments() async throws {
  89. let time = Date.from(isoString: "2016-06-19T13:00:00-04:00")
  90. let glucoseData = [
  91. BloodGlucose(
  92. sgv: 100,
  93. date: Decimal(time.timeIntervalSince1970 * 1000),
  94. dateString: time
  95. )
  96. ]
  97. let profile = createBasicProfile()
  98. let basalProfile = createBasicBasalProfile()
  99. let result = try MealTotal.recentCarbs(
  100. treatments: [],
  101. pumpHistory: [],
  102. profile: profile,
  103. basalProfile: basalProfile,
  104. glucose: glucoseData,
  105. time: time
  106. )
  107. // With empty treatments, JS returns a full result object
  108. // with zero carbs/COB and sentinel deviation values
  109. #expect(result != nil)
  110. #expect(result?.carbs == 0)
  111. #expect(result?.mealCOB == 0)
  112. #expect(result?.currentDeviation == nil)
  113. #expect(result?.maxDeviation == 0)
  114. #expect(result?.minDeviation == 999)
  115. #expect(result?.slopeFromMaxDeviation == 0)
  116. #expect(result?.slopeFromMinDeviation == 999)
  117. #expect(result?.allDeviations == [])
  118. #expect(result?.lastCarbTime == 0)
  119. }
  120. @Test("should calculate carbs correctly for treatments within the meal window") func calcCarbsWithinMealWindow() async throws {
  121. let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  122. let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
  123. let treatments = [
  124. MealInput(
  125. timestamp: baseTime,
  126. carbs: 20,
  127. bolus: nil
  128. )
  129. ]
  130. // Create glucose pattern with slight rise
  131. let glucoseData = [
  132. BloodGlucose(
  133. sgv: 110,
  134. date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
  135. dateString: baseTime.addingTimeInterval(60 * 60)
  136. ),
  137. BloodGlucose(
  138. sgv: 105,
  139. date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
  140. dateString: baseTime.addingTimeInterval(30 * 60)
  141. ),
  142. BloodGlucose(
  143. sgv: 100,
  144. date: Decimal(baseTime.timeIntervalSince1970 * 1000),
  145. dateString: baseTime
  146. )
  147. ]
  148. let profile = createBasicProfile()
  149. let basalProfile = createBasicBasalProfile()
  150. let result = try MealTotal.recentCarbs(
  151. treatments: treatments,
  152. pumpHistory: [],
  153. profile: profile,
  154. basalProfile: basalProfile,
  155. glucose: glucoseData,
  156. time: testTime
  157. )
  158. #expect(result != nil)
  159. #expect(result!.carbs == 20)
  160. #expect(
  161. result!.currentDeviation!.isWithin(0.02, of: 0.67) == true,
  162. "currentDeviation: \(result!.currentDeviation!.description)"
  163. )
  164. #expect(result!.mealCOB.isWithin(0.25, of: 14) == true, "mealCOB: \(result!.mealCOB.description)")
  165. }
  166. @Test("should ignore treatments outside the meal window") func ignoreTreatmentsOutsideMealWindow() async throws {
  167. let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  168. let treatmentTime = Date.from(isoString: "2016-06-19T06:00:00-04:00") // 6 hours before
  169. let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
  170. let treatments = [
  171. MealInput(
  172. timestamp: treatmentTime,
  173. carbs: 20,
  174. bolus: nil
  175. )
  176. ]
  177. // Create glucose pattern with slight rise
  178. let glucoseData = [
  179. BloodGlucose(
  180. sgv: 110,
  181. date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
  182. dateString: baseTime.addingTimeInterval(60 * 60)
  183. ),
  184. BloodGlucose(
  185. sgv: 105,
  186. date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
  187. dateString: baseTime.addingTimeInterval(30 * 60)
  188. ),
  189. BloodGlucose(
  190. sgv: 100,
  191. date: Decimal(baseTime.timeIntervalSince1970 * 1000),
  192. dateString: baseTime
  193. )
  194. ]
  195. let profile = createBasicProfile()
  196. let basalProfile = createBasicBasalProfile()
  197. let result = try MealTotal.recentCarbs(
  198. treatments: treatments,
  199. pumpHistory: [],
  200. profile: profile,
  201. basalProfile: basalProfile,
  202. glucose: glucoseData,
  203. time: testTime
  204. )
  205. #expect(result != nil)
  206. #expect(result?.carbs == 0)
  207. #expect(result?.mealCOB == 0)
  208. #expect(
  209. result?.currentDeviation!.isWithin(0.02, of: 0.67) == true,
  210. "currentDeviation: \(result!.currentDeviation!.description)"
  211. )
  212. }
  213. @Test("should respect maxMealAbsorptionTime from profile") func respectMaxMealAbsorptionTime() async throws {
  214. let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  215. let treatmentTime = Date.from(isoString: "2016-06-19T10:00:00-04:00") // 2 hours before
  216. let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
  217. let treatments = [
  218. MealInput(
  219. timestamp: treatmentTime,
  220. carbs: 20,
  221. bolus: nil
  222. )
  223. ]
  224. // Create glucose pattern with slight rise
  225. let glucoseData = [
  226. BloodGlucose(
  227. sgv: 110,
  228. date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
  229. dateString: baseTime.addingTimeInterval(60 * 60)
  230. ),
  231. BloodGlucose(
  232. sgv: 105,
  233. date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
  234. dateString: baseTime.addingTimeInterval(30 * 60)
  235. ),
  236. BloodGlucose(
  237. sgv: 100,
  238. date: Decimal(baseTime.timeIntervalSince1970 * 1000),
  239. dateString: baseTime
  240. )
  241. ]
  242. var profile = createBasicProfile()
  243. profile.maxMealAbsorptionTime = 2 // 2 hour window
  244. let basalProfile = createBasicBasalProfile()
  245. let result = try MealTotal.recentCarbs(
  246. treatments: treatments,
  247. pumpHistory: [],
  248. profile: profile,
  249. basalProfile: basalProfile,
  250. glucose: glucoseData,
  251. time: testTime
  252. )
  253. #expect(result != nil)
  254. #expect(result?.carbs == 0)
  255. #expect(result?.mealCOB == 0)
  256. }
  257. @Test("should respect maxCOB from profile") func respectMaxCOB() async throws {
  258. let baseTime = Date.from(isoString: "2016-06-19T12:00:00-04:00")
  259. let testTime = Date.from(isoString: "2016-06-19T13:00:00-04:00")
  260. let treatments = [
  261. MealInput(
  262. timestamp: baseTime,
  263. carbs: 200,
  264. bolus: nil
  265. )
  266. ]
  267. // Create glucose pattern with slight rise
  268. let glucoseData = [
  269. BloodGlucose(
  270. sgv: 110,
  271. date: Decimal(baseTime.addingTimeInterval(60 * 60).timeIntervalSince1970 * 1000),
  272. dateString: baseTime.addingTimeInterval(60 * 60)
  273. ),
  274. BloodGlucose(
  275. sgv: 105,
  276. date: Decimal(baseTime.addingTimeInterval(30 * 60).timeIntervalSince1970 * 1000),
  277. dateString: baseTime.addingTimeInterval(30 * 60)
  278. ),
  279. BloodGlucose(
  280. sgv: 100,
  281. date: Decimal(baseTime.timeIntervalSince1970 * 1000),
  282. dateString: baseTime
  283. )
  284. ]
  285. let profile = createBasicProfile()
  286. let basalProfile = createBasicBasalProfile()
  287. let result = try MealTotal.recentCarbs(
  288. treatments: treatments,
  289. pumpHistory: [],
  290. profile: profile,
  291. basalProfile: basalProfile,
  292. glucose: glucoseData,
  293. time: testTime
  294. )
  295. #expect(result != nil)
  296. #expect(result!.mealCOB <= 120)
  297. }
  298. }