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

Alerts: add unit tests for classifier, muter, device alerts store

Deniz Cengiz недель назад: 3
Родитель
Сommit
b895debe33

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -234,6 +234,9 @@
 		38F3B2EF25ED8E2A005C48AA /* TempTargetsStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38F3B2EE25ED8E2A005C48AA /* TempTargetsStorage.swift */; };
 		38FCF3D625E8FDF40078B0D1 /* MD5.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3D525E8FDF40078B0D1 /* MD5.swift */; };
 		38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */; };
+		BD11A001000000000000A001 /* TrioAlertClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A001 /* TrioAlertClassifierTests.swift */; };
+		BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A002 /* AlertMuterTests.swift */; };
+		BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */; };
 		38FCF3FD25E997A80078B0D1 /* PumpHistoryStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */; };
 		38FE826A25CC82DB001FF17A /* NetworkService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826925CC82DB001FF17A /* NetworkService.swift */; };
 		38FE826D25CC8461001FF17A /* NightscoutAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38FE826C25CC8461001FF17A /* NightscoutAPI.swift */; };
@@ -1153,6 +1156,9 @@
 		38FCF3ED25E9028E0078B0D1 /* TrioTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TrioTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
 		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>"; };
+		BD11A000000000000000A001 /* TrioAlertClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrioAlertClassifierTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A002 /* AlertMuterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertMuterTests.swift; sourceTree = "<group>"; };
+		BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceAlertsStoreTests.swift; sourceTree = "<group>"; };
 		38FCF3FC25E997A80078B0D1 /* PumpHistoryStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryStorage.swift; sourceTree = "<group>"; };
 		38FE826925CC82DB001FF17A /* NetworkService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkService.swift; sourceTree = "<group>"; };
 		38FE826C25CC8461001FF17A /* NightscoutAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NightscoutAPI.swift; sourceTree = "<group>"; };
@@ -2843,6 +2849,9 @@
 				CEE9A65D2BBC9F6500EB5194 /* CalibrationsTests.swift */,
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
+				BD11A000000000000000A001 /* TrioAlertClassifierTests.swift */,
+				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A003 /* DeviceAlertsStoreTests.swift */,
 				3B997DCE2DC00A3A006B6BB2 /* JSONImporterTests.swift */,
 				CE1F6DD82BADF4620064EB8D /* PluginManagerTests.swift */,
 				B3919BBB515547118D684CA2 /* SettingsSearchTests.swift */,
@@ -5149,6 +5158,9 @@
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
+				BD11A001000000000000A001 /* TrioAlertClassifierTests.swift in Sources */,
+				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A003 /* DeviceAlertsStoreTests.swift in Sources */,
 				BD8FC0602D6619DB00B95AED /* CarbsStorageTests.swift in Sources */,
 				BD8FC05E2D6618CE00B95AED /* BolusCalculatorTests.swift in Sources */,
 				41740E936552456AAC0EDAC3 /* SettingsSearchTests.swift in Sources */,

+ 6 - 4
Trio/Sources/Services/Alerts/TrioAlertCategory.swift

@@ -183,7 +183,8 @@ enum TrioAlertClassifier {
 
         // Uncertain delivery — N3, must come before generic bolusFailed.
         if id.contains("unacknowledged") || id.contains("uncertaindelivery") || id.contains("uncertain delivery")
-            || id.contains("delivery-uncertain") || id.contains("unabletoreachpod") || id.contains("commsrecovery")
+            || id.contains("delivery-uncertain") || id.contains("deliveryuncertain")
+            || id.contains("unabletoreachpod") || id.contains("commsrecovery")
         {
             return .deliveryUncertain
         }
@@ -201,9 +202,10 @@ enum TrioAlertClassifier {
 
         // Sensor / session failure — N7 (CGM-side).
         if id.contains("sensorfailed") || id.contains("sensor.failed") || id.contains("sensorstopped")
-            || id.contains("sensorerror") || id.contains("invalidsensor") || id.contains("encryptedsensor")
-            || id.contains("sensortemperature") || id.contains("sensorlowtemperature")
-            || id.contains("readertemperature") || id.contains("sensorretirement") || id.contains("nosensordetected")
+            || id.contains("sensorfailure") || id.contains("sensorerror") || id.contains("invalidsensor")
+            || id.contains("encryptedsensor") || id.contains("sensortemperature")
+            || id.contains("sensorlowtemperature") || id.contains("readertemperature")
+            || id.contains("sensorretirement") || id.contains("nosensordetected")
             || id.contains("transmitterdisconnected") || id.contains("glucosesuspended")
             || id.contains("sensorconnection")
         {

+ 60 - 0
TrioTests/AlertMuterTests.swift

@@ -0,0 +1,60 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Trio Alerts: AlertMuter") struct AlertMuterTests {
+    @Test("Default state is unmuted") func defaultUnmuted() {
+        let muter = AlertMuter()
+        #expect(!muter.shouldMute(at: Date()))
+        #expect(muter.endsAt == nil)
+    }
+
+    @Test("mute(for:) covers the start through end-exclusive boundary") func muteWindowMath() {
+        let muter = AlertMuter()
+        let start = Date(timeIntervalSince1970: 1_700_000_000)
+        muter.mute(for: 600, from: start)
+
+        #expect(muter.shouldMute(at: start)) // inclusive start
+        #expect(muter.shouldMute(at: start.addingTimeInterval(1)))
+        #expect(muter.shouldMute(at: start.addingTimeInterval(599)))
+        #expect(!muter.shouldMute(at: start.addingTimeInterval(600))) // exclusive end
+        #expect(!muter.shouldMute(at: start.addingTimeInterval(601)))
+        #expect(!muter.shouldMute(at: start.addingTimeInterval(-1)))
+        #expect(muter.endsAt == start.addingTimeInterval(600))
+    }
+
+    @Test("unmute() clears the window") func unmuteClears() {
+        let muter = AlertMuter()
+        let start = Date()
+        muter.mute(for: 3600, from: start)
+        #expect(muter.shouldMute(at: start.addingTimeInterval(60)))
+
+        muter.unmute()
+        #expect(!muter.shouldMute(at: start.addingTimeInterval(60)))
+        #expect(muter.endsAt == nil)
+    }
+
+    @Test("mute(for: 0) creates a zero-length window — never mutes") func zeroDurationNoOp() {
+        let muter = AlertMuter()
+        let start = Date()
+        muter.mute(for: 0, from: start)
+        #expect(!muter.shouldMute(at: start))
+        #expect(!muter.shouldMute(at: start.addingTimeInterval(1)))
+    }
+
+    @Test("Re-muting replaces the previous window rather than stacking") func reMuteReplaces() {
+        let muter = AlertMuter()
+        let first = Date(timeIntervalSince1970: 1_700_000_000)
+        muter.mute(for: 600, from: first)
+
+        let second = first.addingTimeInterval(120)
+        muter.mute(for: 60, from: second)
+
+        // Second window ends at first+180, not first+600.
+        #expect(muter.shouldMute(at: second.addingTimeInterval(30)))
+        #expect(!muter.shouldMute(at: second.addingTimeInterval(60)))
+        #expect(!muter.shouldMute(at: first.addingTimeInterval(300)))
+        #expect(muter.endsAt == second.addingTimeInterval(60))
+    }
+}

+ 124 - 0
TrioTests/DeviceAlertsStoreTests.swift

@@ -0,0 +1,124 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Trio Alerts: DeviceAlertsStore variant precedence", .serialized) struct DeviceAlertsStoreTests {
+    /// Each test gets a unique suite name so UserDefaults state can't leak
+    /// between tests in parallel runs.
+    private static func makeStore(seed: [DeviceAlertSeverityConfig]? = nil) -> DeviceAlertsStore {
+        let suiteName = "DeviceAlertsStoreTests.\(UUID().uuidString)"
+        let defaults = UserDefaults(suiteName: suiteName)!
+        defaults.removePersistentDomain(forName: suiteName)
+        if let seed {
+            let data = try? JSONEncoder().encode(seed)
+            defaults.set(data, forKey: "trio.deviceAlertSeverityConfigs.v1")
+        }
+        return DeviceAlertsStore(defaults: defaults, configsKey: "trio.deviceAlertSeverityConfigs.v1")
+    }
+
+    @Test("Fresh store seeds one .always config per severity") func freshSeed() {
+        let store = Self.makeStore()
+        for severity in DeviceAlertSeverity.allCases {
+            let always = store.configs.first { $0.severity == severity && $0.activeOption == .always }
+            #expect(always != nil, "Missing .always seed for \(severity)")
+        }
+        #expect(store.configs.count == DeviceAlertSeverity.allCases.count)
+    }
+
+    @Test("config(for:isNight:) returns .day variant during daytime") func dayMatchPicksDayVariant() {
+        var dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
+        dayOnly.soundFilename = "day.caf"
+        let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
+            dayOnly,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        let match = store.config(for: .critical, at: Date(), isNight: false)
+        #expect(match?.soundFilename == "day.caf")
+    }
+
+    @Test("config(for:isNight:) returns .night variant overnight") func nightMatchPicksNightVariant() {
+        var nightOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .night)
+        nightOnly.soundFilename = "night.caf"
+        let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
+            nightOnly,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        let match = store.config(for: .critical, at: Date(), isNight: true)
+        #expect(match?.soundFilename == "night.caf")
+    }
+
+    @Test("Day variant doesn't match at night — falls back to .always") func dayVariantSkippedAtNight() {
+        var always = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
+        always.soundFilename = "fallback.caf"
+        var dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
+        dayOnly.soundFilename = "day.caf"
+        let store = Self.makeStore(seed: [
+            always,
+            dayOnly,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        let match = store.config(for: .critical, at: Date(), isNight: true)
+        #expect(match?.soundFilename == "fallback.caf")
+    }
+
+    @Test("Disabled variants are skipped; falls back to next enabled") func disabledVariantSkipped() {
+        var dayDisabled = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
+        dayDisabled.isEnabled = false
+        dayDisabled.soundFilename = "disabled-day.caf"
+        var alwaysFallback = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
+        alwaysFallback.soundFilename = "always.caf"
+        let store = Self.makeStore(seed: [
+            alwaysFallback,
+            dayDisabled,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        let match = store.config(for: .critical, at: Date(), isNight: false)
+        #expect(match?.soundFilename == "always.caf")
+    }
+
+    @Test("All variants disabled returns nil") func allDisabledReturnsNil() {
+        var always = DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        always.isEnabled = false
+        let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            always
+        ])
+        #expect(store.config(for: .normal, at: Date(), isNight: false) == nil)
+    }
+
+    @Test("canDelete protects the last .always per severity") func canDeleteLastAlways() {
+        let store = Self.makeStore()
+        let onlyAlways = store.configs.first { $0.severity == .critical && $0.activeOption == .always }!
+        #expect(!store.canDelete(onlyAlways))
+    }
+
+    @Test("canDelete allows removing one of two .always in the same tier") func canDeleteSecondAlways() {
+        let extraAlways = DeviceAlertSeverityConfig(severity: .critical, activeOption: .always)
+        let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
+            extraAlways,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        #expect(store.canDelete(extraAlways))
+    }
+
+    @Test("canDelete always allows non-always variants") func canDeleteDayOrNight() {
+        let dayOnly = DeviceAlertSeverityConfig(severity: .critical, activeOption: .day)
+        let store = Self.makeStore(seed: [
+            DeviceAlertSeverityConfig(severity: .critical, activeOption: .always),
+            dayOnly,
+            DeviceAlertSeverityConfig(severity: .timeSensitive, activeOption: .always),
+            DeviceAlertSeverityConfig(severity: .normal, activeOption: .always)
+        ])
+        #expect(store.canDelete(dayOnly))
+    }
+}

+ 378 - 0
TrioTests/TrioAlertClassifierTests.swift

@@ -0,0 +1,378 @@
+import Foundation
+import Testing
+
+@testable import Trio
+
+@Suite("Trio Alerts: TrioAlertClassifier — alertIdentifier round-trip") struct TrioAlertClassifierIdentifierTests {
+    /// Ground-truth slugs emitted by bundled pump managers. The classifier is
+    /// substring-matched and case-insensitive, so the test inputs reproduce the
+    /// real source-of-truth casing.
+
+    // MARK: - OmnipodKit (covers Eros + DASH)
+
+    @Test("Omnipod: lowReservoir → reservoirLow") func omnipodLowReservoir() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowReservoir") == .reservoirLow)
+    }
+
+    @Test("Omnipod: podExpiring is a reminder, not an expiration") func omnipodPodExpiring() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "podExpiring") == .deviceExpirationReminder)
+    }
+
+    @Test("Omnipod: userPodExpiration → reminder") func omnipodUserPodExpiration() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "userPodExpiration") == .deviceExpirationReminder)
+    }
+
+    @Test("Omnipod: podExpireImminent → shutdown imminent") func omnipodExpireImminent() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "podExpireImminent") == .podShutdownImminent)
+    }
+
+    @Test("Omnipod: suspendEnded → suspendTimeExpired") func omnipodSuspendEnded() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendEnded") == .suspendTimeExpired)
+    }
+
+    @Test("Omnipod: suspendEnded-repeating → suspendTimeExpired") func omnipodSuspendEndedRepeating() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendEnded-repeating") == .suspendTimeExpired)
+    }
+
+    @Test("Omnipod: unexpectedAlert → hardwareFault") func omnipodUnexpectedAlert() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "unexpectedAlert") == .hardwareFault)
+    }
+
+    @Test("Omnipod: timeOffsetChangeDetected → other") func omnipodTimeOffset() {
+        // Not a clinically actionable category — falls through to .other.
+        if case .other = TrioAlertClassifier.categorize(alertIdentifier: "timeOffsetChangeDetected") {
+            // ok
+        } else {
+            Issue.record("Expected .other for timeOffsetChangeDetected")
+        }
+    }
+
+    @Test("Omnipod: lowRLBattery → batteryLow") func omnipodLowRLBattery() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowRLBattery") == .batteryLow)
+    }
+
+    @Test("Omnipod: finishSetupReminder → other") func omnipodFinishSetup() {
+        if case .other = TrioAlertClassifier.categorize(alertIdentifier: "finishSetupReminder") {
+            // ok
+        } else {
+            Issue.record("Expected .other for finishSetupReminder")
+        }
+    }
+
+    @Test("Omnipod: suspendInProgress → other") func omnipodSuspendInProgress() {
+        if case .other = TrioAlertClassifier.categorize(alertIdentifier: "suspendInProgress") {
+            // ok
+        } else {
+            Issue.record("Expected .other for suspendInProgress")
+        }
+    }
+
+    // MARK: - DanaKit
+
+    @Test("Dana: batteryZeroPercent → batteryEmpty") func danaBatteryZero() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryZeroPercent") == .batteryEmpty)
+    }
+
+    @Test("Dana: pumpError → hardwareFault") func danaPumpError() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "pumpError") == .hardwareFault)
+    }
+
+    @Test("Dana: occlusion") func danaOcclusion() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "occlusion") == .occlusion)
+    }
+
+    @Test("Dana: lowBattery → batteryLow") func danaLowBattery() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowBattery") == .batteryLow)
+    }
+
+    @Test("Dana: shutdown → hardwareFault") func danaShutdown() {
+        // The classifier matches the exact lowercased "shutdown" to hardwareFault.
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "shutdown") == .hardwareFault)
+    }
+
+    @Test("Dana: emptyReservoir → reservoirEmpty") func danaEmptyReservoir() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "emptyReservoir") == .reservoirEmpty)
+    }
+
+    @Test("Dana: remainingInsulinLevel → reservoirLow") func danaRemainingInsulin() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "remainingInsulinLevel") == .reservoirLow)
+    }
+
+    @Test("Dana: checkShaft → hardwareFault") func danaCheckShaft() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "checkShaft") == .hardwareFault)
+    }
+
+    @Test("Dana: ble5InvalidKeys → other") func danaInvalidKeys() {
+        if case .other = TrioAlertClassifier.categorize(alertIdentifier: "ble5InvalidKeys") {
+            // ok
+        } else {
+            Issue.record("Expected .other for ble5InvalidKeys")
+        }
+    }
+
+    // MARK: - MinimedKit
+
+    @Test("Minimed: PumpBatteryLow → batteryLow") func minimedBatteryLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpBatteryLow") == .batteryLow)
+    }
+
+    @Test("Minimed: PumpReservoirEmpty → reservoirEmpty") func minimedReservoirEmpty() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpReservoirEmpty") == .reservoirEmpty)
+    }
+
+    @Test("Minimed: PumpReservoirLow → reservoirLow") func minimedReservoirLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpReservoirLow") == .reservoirLow)
+    }
+
+    // MARK: - Trio internal slugs (APSManager + NotLoopingMonitor + GlucoseAlertCoordinator)
+
+    @Test("Trio: occlusion") func trioOcclusion() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "occlusion") == .occlusion)
+    }
+
+    @Test("Trio: reservoirLow") func trioReservoirLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "reservoirLow") == .reservoirLow)
+    }
+
+    @Test("Trio: reservoirEmpty") func trioReservoirEmpty() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "reservoirEmpty") == .reservoirEmpty)
+    }
+
+    @Test("Trio: batteryLow") func trioBatteryLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryLow") == .batteryLow)
+    }
+
+    @Test("Trio: batteryEmpty") func trioBatteryEmpty() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryEmpty") == .batteryEmpty)
+    }
+
+    @Test("Trio: hardwareFault") func trioHardwareFault() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "hardwareFault") == .hardwareFault)
+    }
+
+    @Test("Trio: deliveryUncertain") func trioDeliveryUncertain() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "deliveryUncertain") == .deliveryUncertain)
+    }
+
+    @Test("Trio: deviceExpirationReminder") func trioReminder() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "deviceExpirationReminder") == .deviceExpirationReminder)
+    }
+
+    @Test("Trio: deviceExpired") func trioExpired() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "deviceExpired") == .deviceExpired)
+    }
+
+    @Test("Trio: podShutdownImminent") func trioShutdownImminent() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "podShutdownImminent") == .podShutdownImminent)
+    }
+
+    @Test("Trio: suspendTimeExpired") func trioSuspendTimeExpired() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendTimeExpired") == .suspendTimeExpired)
+    }
+
+    @Test("Trio: bolusFailed") func trioBolusFailed() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "bolusFailed") == .bolusFailed)
+    }
+
+    @Test("Trio: manualTempBasalActive") func trioManualTempBasal() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "manualTempBasalActive") == .manualTempBasalActive)
+    }
+
+    @Test("Trio: notLooping") func trioNotLooping() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "notLooping") == .notLooping)
+    }
+
+    @Test("Trio: loop.notActive (NotLoopingMonitor slug)") func trioLoopNotActive() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "loop.notActive") == .notLooping)
+    }
+
+    @Test("Trio: sensorFailure") func trioSensorFailure() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorFailure") == .sensorFailure)
+    }
+
+    @Test("Trio: algorithmError") func trioAlgorithmError() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "algorithmError") == .algorithmError)
+    }
+
+    @Test("Trio: glucose.urgentLow.<uuid>") func trioGlucoseUrgentLow() {
+        #expect(
+            TrioAlertClassifier
+                .categorize(alertIdentifier: "glucose.urgentLow.5C9C3D2A-1234-4321-9876-ABCDEF012345") == .glucoseUrgentLow
+        )
+    }
+
+    @Test("Trio: glucose.low.<uuid>") func trioGlucoseLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.low.uuid-1") == .glucoseLow)
+    }
+
+    @Test("Trio: glucose.forecastedLow.<uuid>") func trioGlucoseForecastedLow() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.forecastedLow.uuid-2") == .glucoseForecastedLow)
+    }
+
+    @Test("Trio: glucose.high.<uuid>") func trioGlucoseHigh() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.high.uuid-3") == .glucoseHigh)
+    }
+
+    // MARK: - CGM driver slugs the classifier should recognize when bridges land
+
+    @Test("CGM: sensorFailed → sensorFailure") func cgmSensorFailed() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorFailed") == .sensorFailure)
+    }
+
+    @Test("CGM: sensorStopped → sensorFailure") func cgmSensorStopped() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorStopped") == .sensorFailure)
+    }
+
+    @Test("CGM: transmitterDisconnected → sensorFailure") func cgmTransmitterDisconnected() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterDisconnected") == .sensorFailure)
+    }
+
+    @Test("CGM: sensorExpired → deviceExpired") func cgmSensorExpired() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorExpired") == .deviceExpired)
+    }
+
+    @Test("CGM: transmitterEoL → deviceExpired") func cgmTransmitterEoL() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterEoL") == .deviceExpired)
+    }
+
+    @Test("CGM: sensorGrace → reminder, not expired") func cgmSensorGrace() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorGrace") == .deviceExpirationReminder)
+    }
+
+    @Test("CGM: gracePeriod → reminder") func cgmGracePeriod() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "gracePeriod") == .deviceExpirationReminder)
+    }
+
+    @Test("CGM: transmitterError → hardwareFault") func cgmTransmitterError() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterError") == .hardwareFault)
+    }
+
+    @Test("CGM: criticalFault → hardwareFault") func cgmCriticalFault() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "criticalFault") == .hardwareFault)
+    }
+
+    // MARK: - Glucose family wins over substring overlap
+
+    @Test("Glucose stale beats the generic 'glucose' fallthrough") func glucoseDataStale() {
+        #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucoseDataStale") == .glucoseDataStale)
+    }
+
+    // MARK: - Unrecognized slug
+
+    @Test("Unrecognized slug → .other with original identifier") func unrecognizedFallsThrough() {
+        let category = TrioAlertClassifier.categorize(alertIdentifier: "totallyMadeUpSlug")
+        if case let .other(id) = category {
+            #expect(id == "totallyMadeUpSlug")
+        } else {
+            Issue.record("Expected .other(\"totallyMadeUpSlug\")")
+        }
+    }
+}
+
+@Suite("Trio Alerts: TrioAlertClassifier — error round-trip") struct TrioAlertClassifierErrorTests {
+    @Test("APSError.invalidPumpState → hardwareFault") func invalidPumpState() {
+        let err = APSError.invalidPumpState(message: "x")
+        #expect(TrioAlertClassifier.categorize(error: err) == .hardwareFault)
+    }
+
+    @Test("APSError.glucoseError → glucoseDataStale") func glucoseError() {
+        let err = APSError.glucoseError(message: "x")
+        #expect(TrioAlertClassifier.categorize(error: err) == .glucoseDataStale)
+    }
+
+    @Test("APSError.apsError → algorithmError") func apsErrorCase() {
+        let err = APSError.apsError(message: "x")
+        #expect(TrioAlertClassifier.categorize(error: err) == .algorithmError)
+    }
+
+    @Test("APSError.manualBasalTemp → manualTempBasalActive") func manualBasalTemp() {
+        let err = APSError.manualBasalTemp(message: "x")
+        #expect(TrioAlertClassifier.categorize(error: err) == .manualTempBasalActive)
+    }
+
+    /// `categorize(pumpError:)` keys on `String(describing:)`, not
+    /// `localizedDescription`. Match that contract with `CustomStringConvertible`
+    /// so the fake errors actually expose the substring the classifier looks for.
+    private struct PumpFake: Error, CustomStringConvertible {
+        let description: String
+    }
+
+    @Test("APSError.pumpError(occlusion) descends into pumpError categorize") func pumpErrorOcclusion() {
+        let err = APSError.pumpError(PumpFake(description: "Pump occlusion detected"))
+        #expect(TrioAlertClassifier.categorize(error: err) == .occlusion)
+    }
+
+    @Test("APSError.pumpError(uncertain) → deliveryUncertain") func pumpErrorUncertain() {
+        let err = APSError.pumpError(PumpFake(description: "UncertainDelivery: bolus may have failed"))
+        #expect(TrioAlertClassifier.categorize(error: err) == .deliveryUncertain)
+    }
+
+    @Test("APSError.pumpError(timeout) → commsTransient") func pumpErrorComms() {
+        let err = APSError.pumpError(PumpFake(description: "Communication timeout, no response"))
+        #expect(TrioAlertClassifier.categorize(error: err) == .commsTransient)
+    }
+
+    @Test("Generic error → .other") func genericError() {
+        struct OddError: Error {}
+        let category = TrioAlertClassifier.categorize(error: OddError())
+        if case .other = category {
+            // ok
+        } else {
+            Issue.record("Expected .other for unrecognized error")
+        }
+    }
+}
+
+@Suite("Trio Alerts: TrioAlertClassifier — surfacing rules") struct TrioAlertCategorySurfacingTests {
+    @Test("commsTransient is the only category dwell-gated at both boundaries") func commsTransientNotImmediate() {
+        #expect(!TrioAlertCategory.commsTransient.shouldFireImmediately)
+        // Spot-check that everything else surfaces immediately.
+        let immediate: [TrioAlertCategory] = [
+            .occlusion, .reservoirLow, .reservoirEmpty, .batteryLow, .batteryEmpty,
+            .hardwareFault, .deliveryUncertain, .deviceExpirationReminder, .deviceExpired,
+            .podShutdownImminent, .suspendTimeExpired, .bolusFailed, .manualTempBasalActive,
+            .notLooping, .sensorFailure, .glucoseUrgentLow, .glucoseLow, .glucoseForecastedLow,
+            .glucoseHigh, .glucoseDataStale, .algorithmError, .other("any")
+        ]
+        for category in immediate {
+            #expect(category.shouldFireImmediately, "Expected \(category) to surface immediately")
+        }
+    }
+
+    @Test("sensorFailure default tier is Critical") func sensorFailureCritical() {
+        #expect(PumpAlertCategory.sensorFailure.defaultSeverity == .critical)
+    }
+
+    @Test("algorithmError defaults to Normal") func algorithmErrorNormal() {
+        #expect(PumpAlertCategory.algorithmError.defaultSeverity == .normal)
+    }
+
+    @Test("Identifier slug round-trips through the classifier for every category") func slugRoundTrip() {
+        for category in PumpAlertCategory.allCases {
+            // Build the canonical slug from the matching TrioAlertCategory if available.
+            let trioCategory = TrioAlertCategory.allCasesForRoundTripTest
+                .first(where: { PumpAlertCategory(trioCategory: $0) == category })
+            guard let trioCategory else { continue }
+            let slug = trioCategory.alertIdentifier
+            let reclassified = TrioAlertClassifier.categorize(alertIdentifier: slug)
+            #expect(
+                reclassified == trioCategory,
+                "Slug \"\(slug)\" should reclassify as \(trioCategory) but got \(reclassified)"
+            )
+        }
+    }
+}
+
+// Helper: TrioAlertCategory is not CaseIterable because of `.other(String)`,
+// so we enumerate the round-trippable cases explicitly for the round-trip test.
+extension TrioAlertCategory {
+    static var allCasesForRoundTripTest: [TrioAlertCategory] {
+        [
+            .occlusion, .reservoirLow, .reservoirEmpty, .batteryLow, .batteryEmpty,
+            .hardwareFault, .deliveryUncertain, .deviceExpirationReminder, .deviceExpired,
+            .podShutdownImminent, .suspendTimeExpired, .bolusFailed, .manualTempBasalActive,
+            .notLooping, .sensorFailure, .glucoseUrgentLow, .glucoseLow, .glucoseForecastedLow,
+            .glucoseHigh, .glucoseDataStale, .algorithmError, .commsTransient
+        ]
+    }
+}