Преглед изворни кода

Test: NotLoopingMonitor re-arm/retract

Add a publisher-only init seam (behavior-preserving; the resolver init still
subscribes to apsManager.lastLoopDateSubject via a shared subscribe(to:)) so
the monitor can be driven with a stub alert manager. Pin that each loop
success retracts then re-issues a delayed(1200s) critical alarm, re-arming
on every event so it only fires once loops actually stall.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom пре 2 недеља
родитељ
комит
a2acb9d6e0

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -237,6 +237,7 @@
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; };
 		BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A002 /* AlertMuterTests.swift */; };
+		BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */; };
 		BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */; };
 		BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */; };
 		BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */; };
@@ -1245,6 +1246,7 @@
 		38FCF3F125E9028E0078B0D1 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
 		38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileStorageTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A002 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotLoopingMonitorTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioModalAlertSchedulerTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertCoordinatorTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertCategoryTests.swift; sourceTree = "<group>"; };
@@ -3009,6 +3011,7 @@
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */,
 				BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */,
 				BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */,
 				BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */,
@@ -5583,6 +5586,7 @@
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */,
 				BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */,
 				BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */,
 				BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */,

+ 13 - 1
Trio/Sources/Services/Alerts/NotLoopingMonitor.swift

@@ -31,7 +31,19 @@ final class NotLoopingMonitor: Injectable {
 
     init(resolver: Resolver) {
         injectServices(resolver)
-        apsManager.lastLoopDateSubject
+        subscribe(to: apsManager.lastLoopDateSubject.eraseToAnyPublisher())
+    }
+
+    /// Publisher-only seam for tests: assigns the alert manager directly and
+    /// subscribes to a supplied loop-date publisher, avoiding the need to stub
+    /// the full `APSManager` protocol.
+    init(loopDates: AnyPublisher<Date, Never>, trioAlertManager: TrioAlertManager) {
+        self.trioAlertManager = trioAlertManager
+        subscribe(to: loopDates)
+    }
+
+    private func subscribe(to loopDates: AnyPublisher<Date, Never>) {
+        loopDates
             .sink { [weak self] _ in self?.rescheduleAlarm() }
             .store(in: &subscriptions)
     }

+ 143 - 0
TrioTests/NotLoopingMonitorTests.swift

@@ -0,0 +1,143 @@
+import Combine
+import Foundation
+import LoopKit
+import Testing
+import UserNotifications
+
+@testable import Trio
+
+/// Records `issueAlert` / `retractAlert` calls in order so tests can assert
+/// the retract-then-issue re-arm semantics of `NotLoopingMonitor`. Every
+/// other `TrioAlertManager` member is a no-op stub.
+final class SpyAlertManager: TrioAlertManager {
+    enum Call: Equatable {
+        case retract(Alert.Identifier)
+        case issue(Alert)
+    }
+
+    private(set) var callLog: [Call] = []
+    private(set) var issuedAlerts: [Alert] = []
+    private(set) var retractedIdentifiers: [Alert.Identifier] = []
+
+    func issueAlert(_ alert: Alert) {
+        callLog.append(.issue(alert))
+        issuedAlerts.append(alert)
+    }
+
+    func retractAlert(identifier: Alert.Identifier) {
+        callLog.append(.retract(identifier))
+        retractedIdentifiers.append(identifier)
+    }
+
+    // MARK: - No-op stubs
+
+    func register(responder _: AlertResponder, for _: String) {}
+    func register(soundVendor _: AlertSoundVendor, for _: String) {}
+    func unregister(managerIdentifier _: String) {}
+
+    func handleAcknowledgement(identifier _: Alert.Identifier) {}
+    func handleNotificationResponse(_: UNNotificationResponse) {}
+    func acknowledgeAllOutstanding() {}
+    @MainActor func applySnooze(for _: TimeInterval) async {}
+    func clearPendingNonCriticalNotifications() {}
+
+    var muter: AlertMuter { AlertMuter() }
+    let modalScheduler = TrioModalAlertScheduler()
+
+    func soundURL(for _: Alert) -> URL? { nil }
+}
+
+@Suite("Trio Alerts: NotLoopingMonitor") struct NotLoopingMonitorTests {
+    /// Reconstructed locally from the private static constant in the source.
+    private let expectedID = Alert.Identifier(
+        managerIdentifier: "trio.aps",
+        alertIdentifier: "loop.notActive"
+    )
+
+    @Test(
+        "A single loop success retracts then issues a fresh delayed critical alarm"
+    ) func singleLoopSuccessRetractsThenIssuesDelayedCritical() {
+        let subject = PassthroughSubject<Date, Never>()
+        let spy = SpyAlertManager()
+        let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
+
+        subject.send(Date())
+
+        #expect(spy.callLog.count == 2)
+        guard spy.issuedAlerts.count == 1 else {
+            Issue.record("expected exactly one issued alert")
+            return
+        }
+        let issued = spy.issuedAlerts[0]
+        #expect(spy.callLog == [.retract(expectedID), .issue(issued)])
+        #expect(issued.identifier == expectedID)
+        #expect(issued.trigger == .delayed(interval: 1200))
+        #expect(issued.interruptionLevel == .critical)
+
+        _ = monitor // retain through the synchronous send
+    }
+
+    @Test("Retract is logged before issue on each re-arm") func retractHappensBeforeIssue() {
+        let subject = PassthroughSubject<Date, Never>()
+        let spy = SpyAlertManager()
+        let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
+
+        subject.send(Date())
+
+        let retractIndex = spy.callLog.firstIndex { if case .retract = $0 { return true }
+            return false }
+        let issueIndex = spy.callLog.firstIndex { if case .issue = $0 { return true }
+            return false }
+        #expect(retractIndex != nil)
+        #expect(issueIndex != nil)
+        if let r = retractIndex, let i = issueIndex {
+            #expect(r < i)
+        }
+
+        _ = monitor
+    }
+
+    @Test("Two successive loop successes re-arm the alarm each time") func twoSuccessiveLoopSuccessesReArmEachTime() {
+        let subject = PassthroughSubject<Date, Never>()
+        let spy = SpyAlertManager()
+        let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
+
+        subject.send(Date())
+        subject.send(Date())
+
+        #expect(spy.callLog.count == 4)
+        guard spy.issuedAlerts.count == 2 else {
+            Issue.record("expected exactly two issued alerts")
+            return
+        }
+        #expect(spy.callLog == [
+            .retract(expectedID),
+            .issue(spy.issuedAlerts[0]),
+            .retract(expectedID),
+            .issue(spy.issuedAlerts[1])
+        ])
+        for issued in spy.issuedAlerts {
+            #expect(issued.trigger == .delayed(interval: 1200))
+            #expect(issued.interruptionLevel == .critical)
+        }
+
+        _ = monitor
+    }
+
+    @Test("Issued trigger is delayed, not immediate") func issuedTriggerIsDelayedNotImmediate() {
+        let subject = PassthroughSubject<Date, Never>()
+        let spy = SpyAlertManager()
+        let monitor = NotLoopingMonitor(loopDates: subject.eraseToAnyPublisher(), trioAlertManager: spy)
+
+        subject.send(Date())
+
+        guard let issued = spy.issuedAlerts.first else {
+            Issue.record("expected an issued alert")
+            return
+        }
+        #expect(issued.trigger == .delayed(interval: 1200))
+        #expect(issued.trigger != .immediate)
+
+        _ = monitor
+    }
+}