Просмотр исходного кода

Test: TrioModalAlertScheduler banner gates

Extract the timer-closure fire decision into a pure FireDecision helper
shouldInsertOnFire(...) (behavior-preserving; closure keeps the ?? true /
== true responder fallbacks and !repeats pending cleanup) and add a #if
DEBUG seedForTesting seam. Pin: critical pierces an active snooze while
non-critical is suppressed, stale fires drop, nil-responder inserts, and
clearNonCriticalBanners removes only non-critical banners.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom недель назад: 2
Родитель
Сommit
23023c9dd1

+ 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 */; };
+		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 */; };
 		BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A008 /* GlucoseAlertTests.swift */; };
@@ -1244,6 +1245,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>"; };
+		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>"; };
 		BD11A000000000000000A008 /* GlucoseAlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseAlertTests.swift; sourceTree = "<group>"; };
@@ -3007,6 +3009,7 @@
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */,
 				BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */,
 				BD11A000000000000000A009 /* TrioAlertCategoryTests.swift */,
 				BD11A000000000000000A008 /* GlucoseAlertTests.swift */,
@@ -5580,6 +5583,7 @@
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */,
 				BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */,
 				BD11A001000000000000A009 /* TrioAlertCategoryTests.swift in Sources */,
 				BD11A001000000000000A008 /* GlucoseAlertTests.swift in Sources */,

+ 48 - 12
Trio/Sources/Services/Alerts/TrioModalAlertScheduler.swift

@@ -101,29 +101,57 @@ final class TrioModalAlertScheduler: ObservableObject {
         remove(identifier: identifier)
     }
 
+    /// Outcome of a `.delayed`/`.repeating` timer fire, factored out of the
+    /// timer closure so the snooze-pierce gate is unit-testable as a pure
+    /// function. See `shouldInsertOnFire`.
+    enum FireDecision: Equatable {
+        case insert
+        case suppressKeepPending
+        case dropStale
+    }
+
+    /// Pure decision for what a timer fire should do, given the alert's
+    /// interruption level and the manager's current active/snooze state.
+    /// - `dropStale`: alert no longer tracked — skip and drop pending.
+    /// - `suppressKeepPending`: non-critical banner muted by global snooze.
+    /// - `insert`: present the banner.
+    static func shouldInsertOnFire(
+        interruptionLevel: LoopKit.Alert.InterruptionLevel,
+        isAlertActive: Bool,
+        isSnoozeActive: Bool
+    ) -> FireDecision {
+        guard isAlertActive else { return .dropStale }
+        if interruptionLevel != .critical, isSnoozeActive { return .suppressKeepPending }
+        return .insert
+    }
+
     private func scheduleTimer(alert: LoopKit.Alert, interval: TimeInterval, repeats: Bool) {
         if pending[alert.identifier] != nil { return }
         let timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: repeats) { [weak self] _ in
             DispatchQueue.main.async {
+                guard let self else { return }
                 // Skip stale fires (e.g. iOS suspended the app for longer
                 // than `interval`; on resume the timer fires immediately
                 // even if the underlying alert was already retracted).
-                guard let self, self.responder?.isAlertActive(identifier: alert.identifier) ?? true else {
-                    self?.pending.removeValue(forKey: alert.identifier)
-                    return
-                }
+                let isAlertActive = self.responder?.isAlertActive(identifier: alert.identifier) ?? true
                 // Honor active global snooze for non-critical banners.
-                if alert.interruptionLevel != .critical,
-                   self.responder?.isSnoozeActive(at: Date()) == true
-                {
+                let isSnoozeActive = self.responder?.isSnoozeActive(at: Date()) == true
+                switch Self.shouldInsertOnFire(
+                    interruptionLevel: alert.interruptionLevel,
+                    isAlertActive: isAlertActive,
+                    isSnoozeActive: isSnoozeActive
+                ) {
+                case .dropStale:
+                    self.pending.removeValue(forKey: alert.identifier)
+                case .suppressKeepPending:
+                    if !repeats {
+                        self.pending.removeValue(forKey: alert.identifier)
+                    }
+                case .insert:
+                    self.insert(alert)
                     if !repeats {
                         self.pending.removeValue(forKey: alert.identifier)
                     }
-                    return
-                }
-                self.insert(alert)
-                if !repeats {
-                    self.pending.removeValue(forKey: alert.identifier)
                 }
             }
         }
@@ -411,3 +439,11 @@ extension View {
         modifier(TrioAlertModifier(scheduler: scheduler))
     }
 }
+
+#if DEBUG
+    extension TrioModalAlertScheduler {
+        /// Test-only seam: seeds the published `active` queue so tests can exercise
+        /// `clearNonCriticalBanners()` without driving the private `insert` path.
+        func seedForTesting(_ alerts: [LoopKit.Alert]) { active = alerts }
+    }
+#endif

+ 161 - 0
TrioTests/TrioModalAlertSchedulerTests.swift

@@ -0,0 +1,161 @@
+import Foundation
+import LoopKit
+import Testing
+@testable import Trio
+
+@MainActor
+@Suite("Trio Alerts: TrioModalAlertScheduler clearNonCriticalBanners") struct TrioModalAlertSchedulerClearTests {
+    private func makeAlert(
+        interruptionLevel: LoopKit.Alert.InterruptionLevel,
+        identifier: String
+    ) -> LoopKit.Alert {
+        let content = LoopKit.Alert.Content(
+            title: "Title",
+            body: "Body",
+            acknowledgeActionButtonLabel: "OK"
+        )
+        return LoopKit.Alert(
+            identifier: LoopKit.Alert.Identifier(
+                managerIdentifier: "trio.test",
+                alertIdentifier: identifier
+            ),
+            foregroundContent: content,
+            backgroundContent: content,
+            trigger: .immediate,
+            interruptionLevel: interruptionLevel,
+            sound: nil
+        )
+    }
+
+    @Test("Mixed queue keeps only critical") func mixedKeepsOnlyCritical() {
+        let scheduler = TrioModalAlertScheduler()
+        scheduler.seedForTesting([
+            makeAlert(interruptionLevel: .critical, identifier: "c"),
+            makeAlert(interruptionLevel: .timeSensitive, identifier: "ts"),
+            makeAlert(interruptionLevel: .active, identifier: "a")
+        ])
+        scheduler.clearNonCriticalBanners()
+        #expect(scheduler.active.map(\.identifier.alertIdentifier) == ["c"])
+    }
+
+    @Test("All-critical queue is unchanged and order-preserved") func allCriticalUnchanged() {
+        let scheduler = TrioModalAlertScheduler()
+        scheduler.seedForTesting([
+            makeAlert(interruptionLevel: .critical, identifier: "c1"),
+            makeAlert(interruptionLevel: .critical, identifier: "c2")
+        ])
+        scheduler.clearNonCriticalBanners()
+        #expect(scheduler.active.map(\.identifier.alertIdentifier) == ["c1", "c2"])
+    }
+
+    @Test("Empty queue stays empty") func emptyStaysEmpty() {
+        let scheduler = TrioModalAlertScheduler()
+        scheduler.seedForTesting([])
+        scheduler.clearNonCriticalBanners()
+        #expect(scheduler.active.isEmpty)
+    }
+
+    @Test("All-non-critical queue becomes empty") func allNonCriticalBecomesEmpty() {
+        let scheduler = TrioModalAlertScheduler()
+        scheduler.seedForTesting([
+            makeAlert(interruptionLevel: .timeSensitive, identifier: "ts"),
+            makeAlert(interruptionLevel: .active, identifier: "a")
+        ])
+        scheduler.clearNonCriticalBanners()
+        #expect(scheduler.active.isEmpty)
+    }
+}
+
+@Suite("Trio Alerts: TrioModalAlertScheduler fire-gate") struct TrioModalAlertSchedulerFireGateTests {
+    @Test("Critical, active, snoozed -> insert") func criticalActiveSnoozedInserts() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .critical,
+                isAlertActive: true,
+                isSnoozeActive: true
+            ) == .insert
+        )
+    }
+
+    @Test("TimeSensitive, active, snoozed -> suppressKeepPending") func timeSensitiveActiveSnoozedSuppresses() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .timeSensitive,
+                isAlertActive: true,
+                isSnoozeActive: true
+            ) == .suppressKeepPending
+        )
+    }
+
+    @Test("TimeSensitive, active, not snoozed -> insert") func timeSensitiveActiveNotSnoozedInserts() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .timeSensitive,
+                isAlertActive: true,
+                isSnoozeActive: false
+            ) == .insert
+        )
+    }
+
+    @Test("Active, active, snoozed -> suppressKeepPending") func activeActiveSnoozedSuppresses() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .active,
+                isAlertActive: true,
+                isSnoozeActive: true
+            ) == .suppressKeepPending
+        )
+    }
+
+    @Test("TimeSensitive, inactive, not snoozed -> dropStale") func timeSensitiveInactiveDropsStale() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .timeSensitive,
+                isAlertActive: false,
+                isSnoozeActive: false
+            ) == .dropStale
+        )
+    }
+
+    @Test("Critical, inactive, snoozed -> dropStale") func criticalInactiveDropsStale() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .critical,
+                isAlertActive: false,
+                isSnoozeActive: true
+            ) == .dropStale
+        )
+    }
+
+    @Test("Active, inactive, snoozed -> dropStale") func activeInactiveDropsStale() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .active,
+                isAlertActive: false,
+                isSnoozeActive: true
+            ) == .dropStale
+        )
+    }
+
+    // nil-responder modeled as isAlertActive defaulting true and
+    // isSnoozeActive defaulting false (== true comparison yields false).
+    @Test("Nil responder, timeSensitive -> insert") func nilResponderTimeSensitiveInserts() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .timeSensitive,
+                isAlertActive: true,
+                isSnoozeActive: false
+            ) == .insert
+        )
+    }
+
+    @Test("Nil responder, critical -> insert") func nilResponderCriticalInserts() {
+        #expect(
+            TrioModalAlertScheduler.shouldInsertOnFire(
+                interruptionLevel: .critical,
+                isAlertActive: true,
+                isSnoozeActive: false
+            ) == .insert
+        )
+    }
+}