FutureCarbsConditionTests.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. // LoopFollow
  2. // FutureCarbsConditionTests.swift
  3. import Foundation
  4. @testable import LoopFollow
  5. import Testing
  6. @Suite(.serialized)
  7. struct FutureCarbsConditionTests {
  8. let cond = FutureCarbsCondition()
  9. private func resetPending() {
  10. Storage.shared.pendingFutureCarbs.value = []
  11. }
  12. private func carb(minutesFromNow offset: Double, grams: Double = 20, relativeTo now: Date = .init()) -> CarbSample {
  13. CarbSample(grams: grams, date: now.addingTimeInterval(offset * 60))
  14. }
  15. // MARK: - 1. Tracking — future carb within lookahead gets tracked
  16. @Test("#tracking — future carb within lookahead gets tracked")
  17. func futureWithinLookaheadTracked() {
  18. resetPending()
  19. let now = Date()
  20. let alarm = Alarm.futureCarbs(threshold: 45, delta: 5)
  21. let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)])
  22. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  23. #expect(!result)
  24. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  25. }
  26. // MARK: - 2. Firing — pending carb whose time arrives fires
  27. @Test("#firing — pending carb whose time arrives fires")
  28. func pendingCarbFires() {
  29. resetPending()
  30. let now = Date()
  31. let pastDate = now.addingTimeInterval(-60) // 1 min ago
  32. Storage.shared.pendingFutureCarbs.value = [
  33. PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  34. ]
  35. let alarm = Alarm.futureCarbs()
  36. let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)])
  37. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  38. #expect(result)
  39. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  40. }
  41. // MARK: - 3. Deleted carb — no fire, removed from pending
  42. @Test("#deleted carb — no fire, removed from pending")
  43. func deletedCarbNoFire() {
  44. resetPending()
  45. let now = Date()
  46. let pastDate = now.addingTimeInterval(-60)
  47. Storage.shared.pendingFutureCarbs.value = [
  48. PendingFutureCarb(carbDate: pastDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  49. ]
  50. let alarm = Alarm.futureCarbs()
  51. let data = AlarmData.withCarbs([]) // carb was deleted
  52. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  53. #expect(!result)
  54. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  55. }
  56. // MARK: - 4. Beyond lookahead — tracked but does not fire
  57. @Test("#beyond lookahead — tracked but does not fire")
  58. func beyondLookaheadTrackedButNoFire() {
  59. resetPending()
  60. let now = Date()
  61. let alarm = Alarm.futureCarbs(threshold: 45)
  62. let data = AlarmData.withCarbs([carb(minutesFromNow: 60, grams: 20, relativeTo: now)])
  63. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  64. #expect(!result)
  65. // Carb is tracked (to prevent re-observation with fresh observedAt)
  66. // but will never fire because original distance > lookahead
  67. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  68. }
  69. // MARK: - 5. Below min grams — carb ignored
  70. @Test("#below min grams — carb ignored")
  71. func belowMinGramsIgnored() {
  72. resetPending()
  73. let now = Date()
  74. let alarm = Alarm.futureCarbs(delta: 5)
  75. let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 3, relativeTo: now)])
  76. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  77. #expect(!result)
  78. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  79. }
  80. // MARK: - 6. Past carb — not tracked
  81. @Test("#past carb — not tracked")
  82. func pastCarbNotTracked() {
  83. resetPending()
  84. let now = Date()
  85. let alarm = Alarm.futureCarbs()
  86. let data = AlarmData.withCarbs([carb(minutesFromNow: -5, grams: 20, relativeTo: now)])
  87. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  88. #expect(!result)
  89. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  90. }
  91. // MARK: - 7. Stale cleanup — entry observed > 2h ago is removed
  92. @Test("#stale cleanup — entry observed > 2h ago is removed")
  93. func staleCleanup() {
  94. resetPending()
  95. let now = Date()
  96. let futureDate = now.addingTimeInterval(300) // still in the future
  97. Storage.shared.pendingFutureCarbs.value = [
  98. PendingFutureCarb(carbDate: futureDate.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970),
  99. ]
  100. let alarm = Alarm.futureCarbs()
  101. let data = AlarmData.withCarbs([])
  102. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  103. #expect(!result)
  104. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  105. }
  106. // MARK: - 8. Multiple carbs — only one fires per tick
  107. @Test("#multiple carbs — only one fires per tick")
  108. func multipleOnlyOnePerTick() {
  109. resetPending()
  110. let now = Date()
  111. let past1 = now.addingTimeInterval(-60)
  112. let past2 = now.addingTimeInterval(-120)
  113. Storage.shared.pendingFutureCarbs.value = [
  114. PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  115. PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  116. ]
  117. let alarm = Alarm.futureCarbs()
  118. let data = AlarmData.withCarbs([
  119. CarbSample(grams: 20, date: past1),
  120. CarbSample(grams: 30, date: past2),
  121. ])
  122. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  123. #expect(result)
  124. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  125. }
  126. // MARK: - 9. Second tick fires second carb
  127. @Test("#second tick fires second carb")
  128. func secondTickFiresSecond() {
  129. resetPending()
  130. let now = Date()
  131. let past1 = now.addingTimeInterval(-60)
  132. let past2 = now.addingTimeInterval(-120)
  133. Storage.shared.pendingFutureCarbs.value = [
  134. PendingFutureCarb(carbDate: past1.timeIntervalSince1970, grams: 20, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  135. PendingFutureCarb(carbDate: past2.timeIntervalSince1970, grams: 30, observedAt: now.addingTimeInterval(-600).timeIntervalSince1970),
  136. ]
  137. let alarm = Alarm.futureCarbs()
  138. let data = AlarmData.withCarbs([
  139. CarbSample(grams: 20, date: past1),
  140. CarbSample(grams: 30, date: past2),
  141. ])
  142. // First tick
  143. let result1 = cond.evaluate(alarm: alarm, data: data, now: now)
  144. #expect(result1)
  145. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  146. // Second tick
  147. let result2 = cond.evaluate(alarm: alarm, data: data, now: now)
  148. #expect(result2)
  149. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  150. }
  151. // MARK: - 10. Duplicate carb not double-tracked
  152. @Test("#duplicate carb not double-tracked")
  153. func duplicateNotDoubleTracked() {
  154. resetPending()
  155. let now = Date()
  156. let alarm = Alarm.futureCarbs()
  157. let data = AlarmData.withCarbs([carb(minutesFromNow: 10, grams: 20, relativeTo: now)])
  158. _ = cond.evaluate(alarm: alarm, data: data, now: now)
  159. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  160. _ = cond.evaluate(alarm: alarm, data: data, now: now)
  161. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  162. }
  163. // MARK: - 11. Sliding window — carb outside lookahead never fires
  164. @Test("#sliding window — carb outside lookahead never fires")
  165. func slidingWindowNeverFires() {
  166. resetPending()
  167. let t0 = Date()
  168. let alarm = Alarm.futureCarbs(threshold: 10) // 10-minute lookahead
  169. let carbDate = t0.addingTimeInterval(15 * 60) // 15 min in future
  170. let carbSample = CarbSample(grams: 20, date: carbDate)
  171. // Tick at T+0: carb is 15 min away, outside 10-min window but tracked
  172. let data = AlarmData.withCarbs([carbSample])
  173. let r0 = cond.evaluate(alarm: alarm, data: data, now: t0)
  174. #expect(!r0)
  175. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  176. // Tick at T+5min: carb is now 10 min away (inside window), but
  177. // original distance was 15 min — must NOT fire
  178. let t1 = t0.addingTimeInterval(5 * 60)
  179. let r1 = cond.evaluate(alarm: alarm, data: data, now: t1)
  180. #expect(!r1)
  181. // Tick at T+15min: carb is due — still must NOT fire
  182. let t2 = t0.addingTimeInterval(15 * 60)
  183. let r2 = cond.evaluate(alarm: alarm, data: data, now: t2)
  184. #expect(!r2)
  185. // Entry should be removed (due, outside original window)
  186. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  187. }
  188. // MARK: - 12. Due entry outside original window removed without firing
  189. @Test("#due entry outside original window removed without firing")
  190. func dueOutsideWindowRemovedNoFire() {
  191. resetPending()
  192. let now = Date()
  193. let pastDate = now.addingTimeInterval(-60) // 1 min ago
  194. // Entry was observed 20 min before its carb date (outside 10-min window)
  195. Storage.shared.pendingFutureCarbs.value = [
  196. PendingFutureCarb(
  197. carbDate: pastDate.timeIntervalSince1970,
  198. grams: 20,
  199. observedAt: pastDate.timeIntervalSince1970 - 20 * 60
  200. ),
  201. ]
  202. let alarm = Alarm.futureCarbs(threshold: 10)
  203. let data = AlarmData.withCarbs([CarbSample(grams: 20, date: pastDate)])
  204. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  205. #expect(!result)
  206. #expect(Storage.shared.pendingFutureCarbs.value.isEmpty)
  207. }
  208. // MARK: - 13. Stale entry with existing carb is not evicted
  209. @Test("#stale entry with existing carb is not evicted")
  210. func staleWithExistingCarbNotEvicted() {
  211. resetPending()
  212. let now = Date()
  213. let futureDate = now.addingTimeInterval(300) // 5 min in the future
  214. // Entry observed 3 hours ago, but carb still exists in recentCarbs
  215. Storage.shared.pendingFutureCarbs.value = [
  216. PendingFutureCarb(
  217. carbDate: futureDate.timeIntervalSince1970,
  218. grams: 20,
  219. observedAt: now.addingTimeInterval(-3 * 3600).timeIntervalSince1970
  220. ),
  221. ]
  222. let alarm = Alarm.futureCarbs()
  223. let data = AlarmData.withCarbs([CarbSample(grams: 20, date: futureDate)])
  224. let result = cond.evaluate(alarm: alarm, data: data, now: now)
  225. #expect(!result)
  226. // Entry must survive — carb still exists, don't evict
  227. #expect(Storage.shared.pendingFutureCarbs.value.count == 1)
  228. }
  229. }