GlucoseAlertConfigurationTests.swift 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Trio Alerts: GlucoseAlertConfiguration.isNight") struct GlucoseAlertConfigurationTests {
  5. /// Fixed UTC calendar so date math is deterministic regardless of host TZ.
  6. private static let utcCalendar: Calendar = {
  7. var cal = Calendar(identifier: .gregorian)
  8. cal.timeZone = TimeZone(identifier: "UTC")!
  9. return cal
  10. }()
  11. /// Build a Date on a fixed day (2026-06-22) via the UTC calendar.
  12. private static func makeDate(hour: Int, minute: Int, second: Int = 0) -> Date {
  13. var components = DateComponents()
  14. components.year = 2026
  15. components.month = 6
  16. components.day = 22
  17. components.hour = hour
  18. components.minute = minute
  19. components.second = second
  20. return utcCalendar.date(from: components)!
  21. }
  22. // MARK: - Default window (day 06:00 / night 22:00, wrap)
  23. @Test("Default: 02:00 is night") func defaultEarlyMorningIsNight() {
  24. let config = GlucoseAlertConfiguration()
  25. #expect(config.isNight(at: Self.makeDate(hour: 2, minute: 0), calendar: Self.utcCalendar))
  26. }
  27. @Test("Default: 10:00 is day") func defaultMorningIsDay() {
  28. let config = GlucoseAlertConfiguration()
  29. #expect(!config.isNight(at: Self.makeDate(hour: 10, minute: 0), calendar: Self.utcCalendar))
  30. }
  31. @Test("Default: 14:00 is day") func defaultAfternoonIsDay() {
  32. let config = GlucoseAlertConfiguration()
  33. #expect(!config.isNight(at: Self.makeDate(hour: 14, minute: 0), calendar: Self.utcCalendar))
  34. }
  35. @Test("Default: 23:00 is night") func defaultLateEveningIsNight() {
  36. let config = GlucoseAlertConfiguration()
  37. #expect(config.isNight(at: Self.makeDate(hour: 23, minute: 0), calendar: Self.utcCalendar))
  38. }
  39. // MARK: - Boundary (default)
  40. @Test("Default boundary: 22:00:00 is night (nightStart inclusive)") func defaultNightStartInclusive() {
  41. let config = GlucoseAlertConfiguration()
  42. #expect(config.isNight(at: Self.makeDate(hour: 22, minute: 0, second: 0), calendar: Self.utcCalendar))
  43. }
  44. @Test("Default boundary: 06:00:00 is day (dayStart exclusive)") func defaultDayStartExclusive() {
  45. let config = GlucoseAlertConfiguration()
  46. #expect(!config.isNight(at: Self.makeDate(hour: 6, minute: 0, second: 0), calendar: Self.utcCalendar))
  47. }
  48. @Test("Default boundary: 05:59:59 is night") func defaultJustBeforeDayStartIsNight() {
  49. let config = GlucoseAlertConfiguration()
  50. #expect(config.isNight(at: Self.makeDate(hour: 5, minute: 59, second: 59), calendar: Self.utcCalendar))
  51. }
  52. @Test("Default boundary: 21:59:59 is day") func defaultJustBeforeNightStartIsDay() {
  53. let config = GlucoseAlertConfiguration()
  54. #expect(!config.isNight(at: Self.makeDate(hour: 21, minute: 59, second: 59), calendar: Self.utcCalendar))
  55. }
  56. // MARK: - Non-wrap config (dayStart 07:00, nightStart 01:00)
  57. @Test("Non-wrap: 03:00 is night") func nonWrapInsideNightIsNight() {
  58. let config = GlucoseAlertConfiguration(
  59. dayStart: TimeOfDay(hour: 7, minute: 0),
  60. nightStart: TimeOfDay(hour: 1, minute: 0)
  61. )
  62. #expect(config.isNight(at: Self.makeDate(hour: 3, minute: 0), calendar: Self.utcCalendar))
  63. }
  64. @Test("Non-wrap: 12:00 is day") func nonWrapMiddayIsDay() {
  65. let config = GlucoseAlertConfiguration(
  66. dayStart: TimeOfDay(hour: 7, minute: 0),
  67. nightStart: TimeOfDay(hour: 1, minute: 0)
  68. )
  69. #expect(!config.isNight(at: Self.makeDate(hour: 12, minute: 0), calendar: Self.utcCalendar))
  70. }
  71. @Test("Non-wrap: 00:30 is day (before nightStart)") func nonWrapBeforeNightStartIsDay() {
  72. let config = GlucoseAlertConfiguration(
  73. dayStart: TimeOfDay(hour: 7, minute: 0),
  74. nightStart: TimeOfDay(hour: 1, minute: 0)
  75. )
  76. #expect(!config.isNight(at: Self.makeDate(hour: 0, minute: 30), calendar: Self.utcCalendar))
  77. }
  78. @Test("Non-wrap boundary: 07:00:00 is day (dayStart exclusive)") func nonWrapDayStartExclusive() {
  79. let config = GlucoseAlertConfiguration(
  80. dayStart: TimeOfDay(hour: 7, minute: 0),
  81. nightStart: TimeOfDay(hour: 1, minute: 0)
  82. )
  83. #expect(!config.isNight(at: Self.makeDate(hour: 7, minute: 0, second: 0), calendar: Self.utcCalendar))
  84. }
  85. @Test("Non-wrap boundary: 01:00:00 is night (nightStart inclusive)") func nonWrapNightStartInclusive() {
  86. let config = GlucoseAlertConfiguration(
  87. dayStart: TimeOfDay(hour: 7, minute: 0),
  88. nightStart: TimeOfDay(hour: 1, minute: 0)
  89. )
  90. #expect(config.isNight(at: Self.makeDate(hour: 1, minute: 0, second: 0), calendar: Self.utcCalendar))
  91. }
  92. // MARK: - Degenerate equal (dayStart == nightStart == 06:00) — proves wrap >= branch
  93. @Test("Equal: 05:00 is night") func equalBeforeIsNight() {
  94. let config = GlucoseAlertConfiguration(
  95. dayStart: TimeOfDay(hour: 6, minute: 0),
  96. nightStart: TimeOfDay(hour: 6, minute: 0)
  97. )
  98. #expect(config.isNight(at: Self.makeDate(hour: 5, minute: 0), calendar: Self.utcCalendar))
  99. }
  100. @Test("Equal: 06:00:00 is night (wrap >= branch)") func equalAtBoundaryIsNight() {
  101. let config = GlucoseAlertConfiguration(
  102. dayStart: TimeOfDay(hour: 6, minute: 0),
  103. nightStart: TimeOfDay(hour: 6, minute: 0)
  104. )
  105. #expect(config.isNight(at: Self.makeDate(hour: 6, minute: 0, second: 0), calendar: Self.utcCalendar))
  106. }
  107. @Test("Equal: 10:00 is night") func equalAfterIsNight() {
  108. let config = GlucoseAlertConfiguration(
  109. dayStart: TimeOfDay(hour: 6, minute: 0),
  110. nightStart: TimeOfDay(hour: 6, minute: 0)
  111. )
  112. #expect(config.isNight(at: Self.makeDate(hour: 10, minute: 0), calendar: Self.utcCalendar))
  113. }
  114. // MARK: - DST transitions (America/New_York)
  115. /// Calendar pinned to a DST-observing zone so wall-clock arithmetic
  116. /// crosses the spring-forward / fall-back boundaries.
  117. private static let nyCalendar: Calendar = {
  118. var cal = Calendar(identifier: .gregorian)
  119. cal.timeZone = TimeZone(identifier: "America/New_York")!
  120. return cal
  121. }()
  122. private static func nyDate(year: Int, month: Int, day: Int, hour: Int, minute: Int) -> Date {
  123. var components = DateComponents()
  124. components.year = year
  125. components.month = month
  126. components.day = day
  127. components.hour = hour
  128. components.minute = minute
  129. return nyCalendar.date(from: components)!
  130. }
  131. /// Spring-forward in the US 2026: March 8 at 02:00 → 03:00 local. Default
  132. /// config (day 06:00 / night 22:00) — 23:00 must still be night and
  133. /// 10:00 must still be day after the clock jumped.
  134. @Test("DST spring-forward: 23:00 local is night, 10:00 local is day") func springForwardWallClock() {
  135. let config = GlucoseAlertConfiguration()
  136. let night = Self.nyDate(year: 2026, month: 3, day: 8, hour: 23, minute: 0)
  137. let day = Self.nyDate(year: 2026, month: 3, day: 8, hour: 10, minute: 0)
  138. #expect(config.isNight(at: night, calendar: Self.nyCalendar))
  139. #expect(!config.isNight(at: day, calendar: Self.nyCalendar))
  140. }
  141. /// 03:30 local on spring-forward day exists post-jump and is < dayStart
  142. /// 06:00, so it must classify as night.
  143. @Test("DST spring-forward: 03:30 local (immediately post-jump) is night") func springForwardJustAfterJump() {
  144. let config = GlucoseAlertConfiguration()
  145. let postJump = Self.nyDate(year: 2026, month: 3, day: 8, hour: 3, minute: 30)
  146. #expect(config.isNight(at: postJump, calendar: Self.nyCalendar))
  147. }
  148. /// Fall-back in the US 2026: November 1 at 02:00 → 01:00 local. 01:30
  149. /// happens twice; both repetitions are < dayStart 06:00 and must
  150. /// classify as night.
  151. @Test("DST fall-back: 01:30 local (both pre- and post-rewind) is night") func fallBackBothInstancesAreNight() {
  152. let config = GlucoseAlertConfiguration()
  153. let oneThirty = Self.nyDate(year: 2026, month: 11, day: 1, hour: 1, minute: 30)
  154. #expect(config.isNight(at: oneThirty, calendar: Self.nyCalendar))
  155. // 0130 + 1h = 0230 local, which only exists once on fall-back days.
  156. #expect(config.isNight(at: oneThirty.addingTimeInterval(3600), calendar: Self.nyCalendar))
  157. }
  158. }