NotLoopingMonitorTests.swift 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  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. @MainActor func applySnooze(for _: TimeInterval) async {}
  34. func clearPendingNonCriticalNotifications() {}
  35. var muter: AlertMuter { AlertMuter() }
  36. let modalScheduler = TrioModalAlertScheduler()
  37. func soundURL(for _: Alert) -> URL? { nil }
  38. }
  39. @Suite("Trio Alerts: NotLoopingMonitor") struct NotLoopingMonitorTests {
  40. /// Reconstructed locally from the private static constant in the source.
  41. private let expectedID = Alert.Identifier(
  42. managerIdentifier: "trio.aps",
  43. alertIdentifier: "loop.notActive"
  44. )
  45. @Test(
  46. "A single loop success retracts then issues a fresh delayed critical alarm"
  47. ) func singleLoopSuccessRetractsThenIssuesDelayedCritical() {
  48. let subject = PassthroughSubject<Date, Never>()
  49. let spy = SpyAlertManager()
  50. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  51. subject.send(Date())
  52. #expect(spy.callLog.count == 2)
  53. guard spy.issuedAlerts.count == 1 else {
  54. Issue.record("expected exactly one issued alert")
  55. return
  56. }
  57. let issued = spy.issuedAlerts[0]
  58. #expect(spy.callLog == [.retract(expectedID), .issue(issued)])
  59. #expect(issued.identifier == expectedID)
  60. #expect(issued.trigger == .delayed(interval: 1200))
  61. #expect(issued.interruptionLevel == .critical)
  62. _ = monitor // retain through the synchronous send
  63. }
  64. @Test("Retract is logged before issue on each re-arm") func retractHappensBeforeIssue() {
  65. let subject = PassthroughSubject<Date, Never>()
  66. let spy = SpyAlertManager()
  67. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  68. subject.send(Date())
  69. let retractIndex = spy.callLog.firstIndex { if case .retract = $0 { return true }
  70. return false }
  71. let issueIndex = spy.callLog.firstIndex { if case .issue = $0 { return true }
  72. return false }
  73. #expect(retractIndex != nil)
  74. #expect(issueIndex != nil)
  75. if let r = retractIndex, let i = issueIndex {
  76. #expect(r < i)
  77. }
  78. _ = monitor
  79. }
  80. @Test("Two successive loop successes re-arm the alarm each time") func twoSuccessiveLoopSuccessesReArmEachTime() {
  81. let subject = PassthroughSubject<Date, Never>()
  82. let spy = SpyAlertManager()
  83. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  84. subject.send(Date())
  85. subject.send(Date())
  86. #expect(spy.callLog.count == 4)
  87. guard spy.issuedAlerts.count == 2 else {
  88. Issue.record("expected exactly two issued alerts")
  89. return
  90. }
  91. #expect(spy.callLog == [
  92. .retract(expectedID),
  93. .issue(spy.issuedAlerts[0]),
  94. .retract(expectedID),
  95. .issue(spy.issuedAlerts[1])
  96. ])
  97. for issued in spy.issuedAlerts {
  98. #expect(issued.trigger == .delayed(interval: 1200))
  99. #expect(issued.interruptionLevel == .critical)
  100. }
  101. _ = monitor
  102. }
  103. @Test("Issued trigger is delayed, not immediate") func issuedTriggerIsDelayedNotImmediate() {
  104. let subject = PassthroughSubject<Date, Never>()
  105. let spy = SpyAlertManager()
  106. let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
  107. subject.send(Date())
  108. guard let issued = spy.issuedAlerts.first else {
  109. Issue.record("expected an issued alert")
  110. return
  111. }
  112. #expect(issued.trigger == .delayed(interval: 1200))
  113. #expect(issued.trigger != .immediate)
  114. _ = monitor
  115. }
  116. }