AlertColdStartReplayTests.swift 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import Foundation
  2. import LoopKit
  3. import Swinject
  4. import Testing
  5. @testable import Trio
  6. @Suite("Trio Alerts: AlertColdStartReplay decisions") struct AlertColdStartReplayTests {
  7. private let now = Date()
  8. private func entry(
  9. manager: String = "Omni",
  10. alert: String = "userPodExpiration",
  11. triggerType: Int16 = 0,
  12. triggerInterval: Decimal? = nil,
  13. issuedAgo: TimeInterval = 60,
  14. level: Decimal? = 2,
  15. title: String? = "Title",
  16. body: String? = "Body"
  17. ) -> AlertEntry {
  18. AlertEntry(
  19. alertIdentifier: alert,
  20. primitiveInterruptionLevel: level,
  21. issuedDate: now.addingTimeInterval(-issuedAgo),
  22. managerIdentifier: manager,
  23. triggerType: triggerType,
  24. triggerInterval: triggerInterval,
  25. contentTitle: title,
  26. contentBody: body
  27. )
  28. }
  29. private func decide(
  30. _ entry: AlertEntry,
  31. tierSnoozed: Bool = false,
  32. muted: Bool = false
  33. ) -> AlertColdStartReplay.Decision {
  34. AlertColdStartReplay.decision(
  35. for: entry,
  36. now: now,
  37. isTierSnoozed: { _ in tierSnoozed },
  38. isMuted: muted
  39. )
  40. }
  41. @Test("Elapsed delayed trigger replays as immediate") func elapsedDelayedBecomesImmediate() {
  42. let decision = decide(entry(
  43. manager: "trio.aps", alert: "loop.notActive",
  44. triggerType: 1, triggerInterval: 1200, issuedAgo: 25 * 60
  45. ))
  46. guard case let .replay(alert) = decision else {
  47. Issue.record("expected replay, got \(decision)")
  48. return
  49. }
  50. #expect(alert.trigger == .immediate)
  51. }
  52. @Test("Pending delayed trigger re-arms with remaining interval") func pendingDelayedKeepsRemainder() {
  53. let decision = decide(entry(
  54. manager: "trio.aps", alert: "loop.notActive",
  55. triggerType: 1, triggerInterval: 1200, issuedAgo: 5 * 60
  56. ))
  57. guard case let .replay(alert) = decision, case let .delayed(interval) = alert.trigger else {
  58. Issue.record("expected delayed replay, got \(decision)")
  59. return
  60. }
  61. #expect(abs(interval - 900) < 2)
  62. }
  63. @Test("Repeating trigger replays with original interval") func repeatingKeepsInterval() {
  64. let decision = decide(entry(triggerType: 2, triggerInterval: 900, issuedAgo: 3600))
  65. guard case let .replay(alert) = decision else {
  66. Issue.record("expected replay, got \(decision)")
  67. return
  68. }
  69. #expect(alert.trigger == .repeating(repeatInterval: 900))
  70. }
  71. @Test("Delayed trigger without interval is unreconstructable") func corruptTriggerSkips() {
  72. #expect(decide(entry(triggerType: 1, triggerInterval: nil)) == .skip)
  73. }
  74. @Test("Glucose-family entries acknowledge silently") func glucoseAcknowledgesSilently() {
  75. let glucose = entry(manager: "Trio", alert: "glucose.urgentLow.ABC-123")
  76. #expect(decide(glucose) == .acknowledgeSilently)
  77. let carbs = entry(manager: "Trio", alert: "glucose.carbsRequired.DEF-456")
  78. #expect(decide(carbs) == .acknowledgeSilently)
  79. }
  80. @Test("Catalog level override wins over stored level") func catalogOverridesStoredLevel() {
  81. // userPodExpiration is catalogued .active; stored says critical (3).
  82. let decision = decide(entry(level: 3))
  83. guard case let .replay(alert) = decision else {
  84. Issue.record("expected replay, got \(decision)")
  85. return
  86. }
  87. #expect(alert.interruptionLevel == .active)
  88. }
  89. @Test("Unknown identifier falls back to stored level, then timeSensitive") func levelFallbacks() {
  90. guard case let .replay(stored) = decide(entry(manager: "x", alert: "y", level: 3)) else {
  91. Issue.record("expected replay")
  92. return
  93. }
  94. #expect(stored.interruptionLevel == .critical)
  95. guard case let .replay(unstored) = decide(entry(manager: "x", alert: "y", level: nil)) else {
  96. Issue.record("expected replay")
  97. return
  98. }
  99. #expect(unstored.interruptionLevel == .timeSensitive)
  100. }
  101. @Test("Tier snooze skips catalog non-critical, critical pierces") func tierSnoozeGate() {
  102. #expect(decide(entry(), tierSnoozed: true) == .skip)
  103. let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
  104. if case .replay = decide(critical, tierSnoozed: true) {} else {
  105. Issue.record("critical should pierce tier snooze")
  106. }
  107. }
  108. @Test("Mute skips non-critical, critical pierces") func muteGate() {
  109. #expect(decide(entry(), muted: true) == .skip)
  110. let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
  111. if case .replay = decide(critical, muted: true) {} else {
  112. Issue.record("critical should pierce mute")
  113. }
  114. }
  115. @Test("Rebuilt alert carries content on both surfaces, no sound") func rebuiltAlertShape() {
  116. guard case let .replay(alert) = decide(entry()) else {
  117. Issue.record("expected replay")
  118. return
  119. }
  120. #expect(alert.sound == nil)
  121. #expect(alert.foregroundContent?.title == "Title")
  122. #expect(alert.foregroundContent == alert.backgroundContent)
  123. }
  124. @Test("newestPerIdentifier keeps first (newest) entry per identifier") func dedupe() {
  125. let newer = entry(issuedAgo: 60)
  126. let older = entry(issuedAgo: 3600)
  127. let other = entry(alert: "lowReservoir", issuedAgo: 120)
  128. let result = AlertColdStartReplay.newestPerIdentifier([newer, other, older])
  129. #expect(result == [newer, other])
  130. }
  131. }
  132. @Suite("Trio Alerts: AlertHistoryStorage acknowledgement") struct AlertHistoryStorageAckTests {
  133. private func makeStorage() -> (BaseAlertHistoryStorage, UserDefaults) {
  134. let suiteName = "AlertHistoryStorageAckTests-\(UUID().uuidString)"
  135. let defaults = UserDefaults(suiteName: suiteName)!
  136. defaults.removePersistentDomain(forName: suiteName)
  137. let container = Container()
  138. container.register(FileStorage.self) { _ in BaseFileStorage() }
  139. container.register(Broadcaster.self) { _ in BaseBroadcaster() }
  140. let storage = BaseAlertHistoryStorage(resolver: container.synchronize(), userDefaults: defaults)
  141. return (storage, defaults)
  142. }
  143. private func entry(_ alert: String, issuedAgo: TimeInterval, manager: String = "Omni") -> AlertEntry {
  144. AlertEntry(
  145. alertIdentifier: alert,
  146. primitiveInterruptionLevel: 2,
  147. issuedDate: Date().addingTimeInterval(-issuedAgo),
  148. managerIdentifier: manager,
  149. triggerType: 0,
  150. triggerInterval: nil,
  151. contentTitle: "t",
  152. contentBody: "b"
  153. )
  154. }
  155. @Test("acknowledgeAllEntries marks every matching entry") func ackAllMatching() {
  156. let (storage, _) = makeStorage()
  157. storage.addAlert(entry("userPodExpiration", issuedAgo: 3600))
  158. storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
  159. storage.addAlert(entry("lowReservoir", issuedAgo: 120))
  160. storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
  161. let unacked = storage.unacknowledgedAlertsWithinLast24Hours()
  162. #expect(unacked.map(\.alertIdentifier) == ["lowReservoir"])
  163. }
  164. @Test("acknowledgeAllEntries matches manager identifier too") func ackScopedToManager() {
  165. let (storage, _) = makeStorage()
  166. storage.addAlert(entry("expiration", issuedAgo: 60, manager: "Omni"))
  167. storage.addAlert(entry("expiration", issuedAgo: 120, manager: "Medtrum"))
  168. storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "expiration")
  169. #expect(storage.unacknowledgedAlertsWithinLast24Hours().map(\.managerIdentifier) == ["Medtrum"])
  170. }
  171. @Test("removeAlert skips acknowledged entries") func removePreservesAcknowledged() {
  172. let (storage, defaults) = makeStorage()
  173. storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
  174. storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
  175. storage.removeAlert(identifier: "userPodExpiration")
  176. // Row survives as acknowledged history instead of being erased.
  177. let data = defaults.data(forKey: "openaps.monitor.alertHistory.data")!
  178. let persisted = try! JSONCoding.decoder.decode([AlertEntry].self, from: data)
  179. #expect(persisted.count == 1)
  180. #expect(persisted[0].acknowledgedDate != nil)
  181. }
  182. @Test("removeAlert deletes unacknowledged entry") func removeDeletesUnacknowledged() {
  183. let (storage, _) = makeStorage()
  184. storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
  185. storage.removeAlert(identifier: "userPodExpiration")
  186. #expect(storage.unacknowledgedAlertsWithinLast24Hours().isEmpty)
  187. }
  188. }