|
|
@@ -0,0 +1,216 @@
|
|
|
+import Foundation
|
|
|
+import LoopKit
|
|
|
+import Swinject
|
|
|
+import Testing
|
|
|
+
|
|
|
+@testable import Trio
|
|
|
+
|
|
|
+@Suite("Trio Alerts: AlertColdStartReplay decisions") struct AlertColdStartReplayTests {
|
|
|
+ private let now = Date()
|
|
|
+
|
|
|
+ private func entry(
|
|
|
+ manager: String = "Omni",
|
|
|
+ alert: String = "userPodExpiration",
|
|
|
+ triggerType: Int16 = 0,
|
|
|
+ triggerInterval: Decimal? = nil,
|
|
|
+ issuedAgo: TimeInterval = 60,
|
|
|
+ level: Decimal? = 2,
|
|
|
+ title: String? = "Title",
|
|
|
+ body: String? = "Body"
|
|
|
+ ) -> AlertEntry {
|
|
|
+ AlertEntry(
|
|
|
+ alertIdentifier: alert,
|
|
|
+ primitiveInterruptionLevel: level,
|
|
|
+ issuedDate: now.addingTimeInterval(-issuedAgo),
|
|
|
+ managerIdentifier: manager,
|
|
|
+ triggerType: triggerType,
|
|
|
+ triggerInterval: triggerInterval,
|
|
|
+ contentTitle: title,
|
|
|
+ contentBody: body
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ private func decide(
|
|
|
+ _ entry: AlertEntry,
|
|
|
+ tierSnoozed: Bool = false,
|
|
|
+ muted: Bool = false
|
|
|
+ ) -> AlertColdStartReplay.Decision {
|
|
|
+ AlertColdStartReplay.decision(
|
|
|
+ for: entry,
|
|
|
+ now: now,
|
|
|
+ isTierSnoozed: { _ in tierSnoozed },
|
|
|
+ isMuted: muted
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Elapsed delayed trigger replays as immediate") func elapsedDelayedBecomesImmediate() {
|
|
|
+ let decision = decide(entry(
|
|
|
+ manager: "trio.aps", alert: "loop.notActive",
|
|
|
+ triggerType: 1, triggerInterval: 1200, issuedAgo: 25 * 60
|
|
|
+ ))
|
|
|
+ guard case let .replay(alert) = decision else {
|
|
|
+ Issue.record("expected replay, got \(decision)")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(alert.trigger == .immediate)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Pending delayed trigger re-arms with remaining interval") func pendingDelayedKeepsRemainder() {
|
|
|
+ let decision = decide(entry(
|
|
|
+ manager: "trio.aps", alert: "loop.notActive",
|
|
|
+ triggerType: 1, triggerInterval: 1200, issuedAgo: 5 * 60
|
|
|
+ ))
|
|
|
+ guard case let .replay(alert) = decision, case let .delayed(interval) = alert.trigger else {
|
|
|
+ Issue.record("expected delayed replay, got \(decision)")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(abs(interval - 900) < 2)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Repeating trigger replays with original interval") func repeatingKeepsInterval() {
|
|
|
+ let decision = decide(entry(triggerType: 2, triggerInterval: 900, issuedAgo: 3600))
|
|
|
+ guard case let .replay(alert) = decision else {
|
|
|
+ Issue.record("expected replay, got \(decision)")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(alert.trigger == .repeating(repeatInterval: 900))
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Delayed trigger without interval is unreconstructable") func corruptTriggerSkips() {
|
|
|
+ #expect(decide(entry(triggerType: 1, triggerInterval: nil)) == .skip)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Glucose-family entries acknowledge silently") func glucoseAcknowledgesSilently() {
|
|
|
+ let glucose = entry(manager: "Trio", alert: "glucose.urgentLow.ABC-123")
|
|
|
+ #expect(decide(glucose) == .acknowledgeSilently)
|
|
|
+ let carbs = entry(manager: "Trio", alert: "glucose.carbsRequired.DEF-456")
|
|
|
+ #expect(decide(carbs) == .acknowledgeSilently)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Catalog level override wins over stored level") func catalogOverridesStoredLevel() {
|
|
|
+ // userPodExpiration is catalogued .active; stored says critical (3).
|
|
|
+ let decision = decide(entry(level: 3))
|
|
|
+ guard case let .replay(alert) = decision else {
|
|
|
+ Issue.record("expected replay, got \(decision)")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(alert.interruptionLevel == .active)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Unknown identifier falls back to stored level, then timeSensitive") func levelFallbacks() {
|
|
|
+ guard case let .replay(stored) = decide(entry(manager: "x", alert: "y", level: 3)) else {
|
|
|
+ Issue.record("expected replay")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(stored.interruptionLevel == .critical)
|
|
|
+
|
|
|
+ guard case let .replay(unstored) = decide(entry(manager: "x", alert: "y", level: nil)) else {
|
|
|
+ Issue.record("expected replay")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(unstored.interruptionLevel == .timeSensitive)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Tier snooze skips catalog non-critical, critical pierces") func tierSnoozeGate() {
|
|
|
+ #expect(decide(entry(), tierSnoozed: true) == .skip)
|
|
|
+ let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
|
|
|
+ if case .replay = decide(critical, tierSnoozed: true) {} else {
|
|
|
+ Issue.record("critical should pierce tier snooze")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Mute skips non-critical, critical pierces") func muteGate() {
|
|
|
+ #expect(decide(entry(), muted: true) == .skip)
|
|
|
+ let critical = entry(manager: "trio.aps", alert: "loop.notActive", issuedAgo: 60)
|
|
|
+ if case .replay = decide(critical, muted: true) {} else {
|
|
|
+ Issue.record("critical should pierce mute")
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("Rebuilt alert carries content on both surfaces, no sound") func rebuiltAlertShape() {
|
|
|
+ guard case let .replay(alert) = decide(entry()) else {
|
|
|
+ Issue.record("expected replay")
|
|
|
+ return
|
|
|
+ }
|
|
|
+ #expect(alert.sound == nil)
|
|
|
+ #expect(alert.foregroundContent?.title == "Title")
|
|
|
+ #expect(alert.foregroundContent == alert.backgroundContent)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("newestPerIdentifier keeps first (newest) entry per identifier") func dedupe() {
|
|
|
+ let newer = entry(issuedAgo: 60)
|
|
|
+ let older = entry(issuedAgo: 3600)
|
|
|
+ let other = entry(alert: "lowReservoir", issuedAgo: 120)
|
|
|
+ let result = AlertColdStartReplay.newestPerIdentifier([newer, other, older])
|
|
|
+ #expect(result == [newer, other])
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@Suite("Trio Alerts: AlertHistoryStorage acknowledgement") struct AlertHistoryStorageAckTests {
|
|
|
+ private func makeStorage() -> (BaseAlertHistoryStorage, UserDefaults) {
|
|
|
+ let suiteName = "AlertHistoryStorageAckTests-\(UUID().uuidString)"
|
|
|
+ let defaults = UserDefaults(suiteName: suiteName)!
|
|
|
+ defaults.removePersistentDomain(forName: suiteName)
|
|
|
+ let container = Container()
|
|
|
+ container.register(FileStorage.self) { _ in BaseFileStorage() }
|
|
|
+ container.register(Broadcaster.self) { _ in BaseBroadcaster() }
|
|
|
+ let storage = BaseAlertHistoryStorage(resolver: container.synchronize(), userDefaults: defaults)
|
|
|
+ return (storage, defaults)
|
|
|
+ }
|
|
|
+
|
|
|
+ private func entry(_ alert: String, issuedAgo: TimeInterval, manager: String = "Omni") -> AlertEntry {
|
|
|
+ AlertEntry(
|
|
|
+ alertIdentifier: alert,
|
|
|
+ primitiveInterruptionLevel: 2,
|
|
|
+ issuedDate: Date().addingTimeInterval(-issuedAgo),
|
|
|
+ managerIdentifier: manager,
|
|
|
+ triggerType: 0,
|
|
|
+ triggerInterval: nil,
|
|
|
+ contentTitle: "t",
|
|
|
+ contentBody: "b"
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("acknowledgeAllEntries marks every matching entry") func ackAllMatching() {
|
|
|
+ let (storage, _) = makeStorage()
|
|
|
+ storage.addAlert(entry("userPodExpiration", issuedAgo: 3600))
|
|
|
+ storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
|
|
|
+ storage.addAlert(entry("lowReservoir", issuedAgo: 120))
|
|
|
+
|
|
|
+ storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
|
|
|
+
|
|
|
+ let unacked = storage.unacknowledgedAlertsWithinLast24Hours()
|
|
|
+ #expect(unacked.map(\.alertIdentifier) == ["lowReservoir"])
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("acknowledgeAllEntries matches manager identifier too") func ackScopedToManager() {
|
|
|
+ let (storage, _) = makeStorage()
|
|
|
+ storage.addAlert(entry("expiration", issuedAgo: 60, manager: "Omni"))
|
|
|
+ storage.addAlert(entry("expiration", issuedAgo: 120, manager: "Medtrum"))
|
|
|
+
|
|
|
+ storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "expiration")
|
|
|
+
|
|
|
+ #expect(storage.unacknowledgedAlertsWithinLast24Hours().map(\.managerIdentifier) == ["Medtrum"])
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("removeAlert skips acknowledged entries") func removePreservesAcknowledged() {
|
|
|
+ let (storage, defaults) = makeStorage()
|
|
|
+ storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
|
|
|
+ storage.acknowledgeAllEntries(managerIdentifier: "Omni", alertIdentifier: "userPodExpiration")
|
|
|
+
|
|
|
+ storage.removeAlert(identifier: "userPodExpiration")
|
|
|
+
|
|
|
+ // Row survives as acknowledged history instead of being erased.
|
|
|
+ let data = defaults.data(forKey: "openaps.monitor.alertHistory.data")!
|
|
|
+ let persisted = try! JSONCoding.decoder.decode([AlertEntry].self, from: data)
|
|
|
+ #expect(persisted.count == 1)
|
|
|
+ #expect(persisted[0].acknowledgedDate != nil)
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test("removeAlert deletes unacknowledged entry") func removeDeletesUnacknowledged() {
|
|
|
+ let (storage, _) = makeStorage()
|
|
|
+ storage.addAlert(entry("userPodExpiration", issuedAgo: 60))
|
|
|
+ storage.removeAlert(identifier: "userPodExpiration")
|
|
|
+ #expect(storage.unacknowledgedAlertsWithinLast24Hours().isEmpty)
|
|
|
+ }
|
|
|
+}
|