NotLoopingMonitorTests.swift 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145
  1. import Combine
  2. import Foundation
  3. import LoopKit
  4. import Testing
  5. import UserNotifications
  6. @testable import Trio
  7. /// Records `issueAlert` / `retractAlert` calls in order so tests can assert
  8. /// the retract-then-issue re-arm semantics of `NotLoopingMonitor`. Every
  9. /// other `TrioAlertManager` member is a no-op stub.
  10. final class SpyAlertManager: TrioAlertManager {
  11. enum Call: Equatable {
  12. case retract(Alert.Identifier)
  13. case issue(Alert)
  14. }
  15. private(set) var callLog: [Call] = []
  16. private(set) var issuedAlerts: [Alert] = []
  17. private(set) var retractedIdentifiers: [Alert.Identifier] = []
  18. func issueAlert(_ alert: Alert) {
  19. callLog.append(.issue(alert))
  20. issuedAlerts.append(alert)
  21. }
  22. func retractAlert(identifier: Alert.Identifier) {
  23. callLog.append(.retract(identifier))
  24. retractedIdentifiers.append(identifier)
  25. }
  26. // MARK: - No-op stubs
  27. func register(responder _: AlertResponder, for _: String) {}
  28. func register(soundVendor _: AlertSoundVendor, for _: String) {}
  29. func unregister(managerIdentifier _: String) {}
  30. func handleAcknowledgement(identifier _: Alert.Identifier) {}
  31. func handleNotificationResponse(_: UNNotificationResponse) {}
  32. func acknowledgeAllOutstanding() {}
  33. func replayUnacknowledgedAlerts() {}
  34. @MainActor func applySnooze(for _: TimeInterval) async {}
  35. func clearPendingNonCriticalNotifications() {}
  36. var muter: AlertMuter { AlertMuter() }
  37. let modalScheduler = TrioModalAlertScheduler()
  38. func soundURL(for _: Alert) -> URL? { nil }
  39. }
  40. @Suite("Trio Alerts: NotLoopingMonitor") struct NotLoopingMonitorTests {
  41. /// Reconstructed locally from the private static constant in the source.
  42. private let expectedID = Alert.Identifier(
  43. managerIdentifier: "trio.aps",
  44. alertIdentifier: "loop.notActive"
  45. )
  46. @Test(
  47. "A single loop success retracts then issues a fresh delayed critical alarm"
  48. ) func singleLoopSuccessRetractsThenIssuesDelayedCritical() {
  49. let subject = PassthroughSubject<Date, Never>()
  50. let spy = SpyAlertManager()
  51. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  52. subject.send(Date())
  53. #expect(spy.callLog.count == 2)
  54. guard spy.issuedAlerts.count == 1 else {
  55. Issue.record("expected exactly one issued alert")
  56. return
  57. }
  58. let issued = spy.issuedAlerts[0]
  59. #expect(spy.callLog == [.retract(expectedID), .issue(issued)])
  60. #expect(issued.identifier == expectedID)
  61. #expect(issued.trigger == .delayed(interval: 1200))
  62. #expect(issued.interruptionLevel == .critical)
  63. _ = monitor // retain through the synchronous send
  64. }
  65. @Test("Retract is logged before issue on each re-arm") func retractHappensBeforeIssue() {
  66. let subject = PassthroughSubject<Date, Never>()
  67. let spy = SpyAlertManager()
  68. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  69. subject.send(Date())
  70. let retractIndex = spy.callLog.firstIndex { if case .retract = $0 { return true }
  71. return false }
  72. let issueIndex = spy.callLog.firstIndex { if case .issue = $0 { return true }
  73. return false }
  74. #expect(retractIndex != nil)
  75. #expect(issueIndex != nil)
  76. if let r = retractIndex, let i = issueIndex {
  77. #expect(r < i)
  78. }
  79. _ = monitor
  80. }
  81. @Test("Two successive loop successes re-arm the alarm each time") func twoSuccessiveLoopSuccessesReArmEachTime() {
  82. let subject = PassthroughSubject<Date, Never>()
  83. let spy = SpyAlertManager()
  84. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  85. subject.send(Date())
  86. subject.send(Date())
  87. #expect(spy.callLog.count == 4)
  88. guard spy.issuedAlerts.count == 2 else {
  89. Issue.record("expected exactly two issued alerts")
  90. return
  91. }
  92. #expect(spy.callLog == [
  93. .retract(expectedID),
  94. .issue(spy.issuedAlerts[0]),
  95. .retract(expectedID),
  96. .issue(spy.issuedAlerts[1])
  97. ])
  98. for issued in spy.issuedAlerts {
  99. #expect(issued.trigger == .delayed(interval: 1200))
  100. #expect(issued.interruptionLevel == .critical)
  101. }
  102. _ = monitor
  103. }
  104. @Test("Issued trigger is delayed, not immediate") func issuedTriggerIsDelayedNotImmediate() {
  105. let subject = PassthroughSubject<Date, Never>()
  106. let spy = SpyAlertManager()
  107. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  108. subject.send(Date())
  109. guard let issued = spy.issuedAlerts.first else {
  110. Issue.record("expected an issued alert")
  111. return
  112. }
  113. #expect(issued.trigger == .delayed(interval: 1200))
  114. #expect(issued.trigger != .immediate)
  115. _ = monitor
  116. }
  117. }