Bladeren bron

Test: AlertCatalogRegistry escalation table + Medtrum occlusion typo

MedtrumKit ships a misspelled occlusion identifier ("patch-occlussion",
double-s). Register both that and the corrected spelling so the alert
escalates to critical regardless of which MedtrumKit is bundled (drop the
misspelled entry once fixed upstream). Add tests pinning every critical
escalation, representative time-sensitive/active entries, unknown→nil
pass-through, per-manager scoping, and the no-duplicate-keys invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom 2 weken geleden
bovenliggende
commit
09f9d83841

+ 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 */; };
+		BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.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 */; };
@@ -1246,6 +1247,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>"; };
+		BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertCatalogRegistryEntriesTests.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>"; };
@@ -3011,6 +3013,7 @@
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */,
 				BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */,
 				BD11A000000000000000A00B /* TrioModalAlertSchedulerTests.swift */,
 				BD11A000000000000000A00A /* GlucoseAlertCoordinatorTests.swift */,
@@ -5586,6 +5589,7 @@
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */,
 				BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */,
 				BD11A001000000000000A00B /* TrioModalAlertSchedulerTests.swift in Sources */,
 				BD11A001000000000000A00A /* GlucoseAlertCoordinatorTests.swift in Sources */,

+ 7 - 0
Trio/Sources/Services/Alerts/AlertCatalogRegistry.swift

@@ -143,7 +143,14 @@ private extension AlertCatalogRegistry {
             "Delivery",
             .insulinLimitReached
         ),
+        // MedtrumKit currently emits the occlusion alert with a misspelled
+        // identifier ("patch-occlussion", double "s") — see MedtrumKit's
+        // NotificationManager. We register both the misspelled identifier (what
+        // ships today) and the corrected spelling so the alert escalates to
+        // critical regardless of which MedtrumKit is bundled. Drop the
+        // misspelled entry once the typo is fixed upstream.
         addEntry("Medtrum", "com.nightscout.medtrumkit.patch-occlussion", .critical, "Occlusion", "Delivery", .occlusion),
+        addEntry("Medtrum", "com.nightscout.medtrumkit.patch-occlusion", .critical, "Occlusion", "Delivery", .occlusion),
         addEntry("Medtrum", "com.nightscout.medtrumkit.patch-fault", .critical, "Patch Fault", "Hardware", .hardwareFault),
         addEntry("Medtrum", "com.nightscout.medtrumkit.patch-empty", .critical, "Reservoir Empty", "Reservoir", .reservoirEmpty),
         addEntry(

+ 130 - 0
TrioTests/AlertCatalogRegistryEntriesTests.swift

@@ -0,0 +1,130 @@
+import Foundation
+import LoopKit
+import Testing
+
+@testable import Trio
+
+/// Pins `AlertCatalogRegistry.entries` and the static, exact-match
+/// `lookup(_:)` behavior. Pump plugins emit at LoopKit's default
+/// (`.timeSensitive`); this table is the sole escalation source. An unknown
+/// `(manager, alertId)` returns nil (pass-through). The Omni hex-fault parser
+/// is covered separately in `AlertCatalogRegistryOmniFaultTests`.
+@Suite("AlertCatalogRegistry — static exact-match entries") struct AlertCatalogRegistryEntriesTests {
+    private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
+        Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
+    }
+
+    // MARK: - A: critical entries
+
+    @Test(
+        "Critical entries escalate to .critical",
+        arguments: [
+            ("Omni", "unexpectedAlert"),
+            ("Minimed", "PumpReservoirEmpty"),
+            ("Dana", "batteryZeroPercent"),
+            ("Dana", "pumpError"),
+            ("Dana", "occlusion"),
+            ("Dana", "shutdown"),
+            ("Dana", "emptyReservoir"),
+            ("Dana", "checkShaft"),
+            // Both spellings are registered: MedtrumKit ships the misspelled
+            // "patch-occlussion" (double-s) today; "patch-occlusion" is
+            // forward-compat for when the upstream typo is fixed. Both escalate.
+            ("Medtrum", "com.nightscout.medtrumkit.patch-occlussion"), // double-s (current MedtrumKit)
+            ("Medtrum", "com.nightscout.medtrumkit.patch-occlusion"), // single-s (future MedtrumKit)
+            ("Medtrum", "com.nightscout.medtrumkit.patch-fault"),
+            ("Medtrum", "com.nightscout.medtrumkit.patch-empty")
+        ]
+    ) func criticalEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .critical)
+    }
+
+    // MARK: - B: representative .timeSensitive entries
+
+    @Test(
+        "Representative entries land at .timeSensitive",
+        arguments: [
+            ("Omni", "lowReservoir"),
+            ("Minimed", "PumpBatteryLow"),
+            ("Dana", "lowBattery"),
+            ("Dana", "unknown"),
+            ("Medtrum", "com.nightscout.medtrumkit.reservoir-low"),
+            ("Medtrum", "com.nightscout.medtrumkit.patch-daily-limit")
+        ]
+    ) func timeSensitiveEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .timeSensitive)
+    }
+
+    // MARK: - C: representative .active entries
+
+    @Test(
+        "Representative entries stay at .active",
+        arguments: [
+            ("Omni", "userPodExpiration"),
+            ("Dana", "basalCompare"),
+            ("Medtrum", "com.nightscout.medtrumkit.patch-expired")
+        ]
+    ) func activeEntries(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID))?.interruptionLevel == .active)
+    }
+
+    // MARK: - D: unknown identifiers pass through (nil)
+
+    @Test(
+        "Unknown identifiers return nil (pass-through)",
+        arguments: [
+            ("Dana", "totallyMadeUp"),
+            ("NoSuchManager", "lowReservoir"),
+            ("", "")
+        ]
+    ) func unknownReturnsNil(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - E: wrong manager, right alert id → nil
+
+    @Test(
+        "Right alert id under the wrong manager returns nil",
+        arguments: [
+            ("Minimed", "unexpectedAlert"),
+            ("Dana", "PumpReservoirEmpty")
+        ]
+    ) func wrongManagerRightID(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - F: right manager, wrong alert id → nil
+
+    // Dana's occlusion entry is the single-s "occlusion"; the double-s
+    // "occlussion" is not a Dana key and must return nil. (The double-s
+    // spelling IS a valid key under the Medtrum manager — see test A — so this
+    // also confirms entries are scoped per-manager.)
+    @Test(
+        "Wrong alert id under the right manager returns nil",
+        arguments: [
+            ("Dana", "occlussion"), // double-s is not a Dana key — must NOT match
+            ("Medtrum", "patch-fault"), // missing com.nightscout.medtrumkit. prefix
+            ("Omni", "lowReservoir ") // trailing space
+        ]
+    ) func rightManagerWrongID(manager: String, alertID: String) {
+        #expect(AlertCatalogRegistry.lookup(id(manager, alertID)) == nil)
+    }
+
+    // MARK: - G: manager-scoped lowRLBattery
+
+    @Test("lowRLBattery resolves per-manager for Omni and Minimed") func lowRLBatteryIsManagerScoped() {
+        let omni = AlertCatalogRegistry.lookup(id("Omni", "lowRLBattery"))
+        let minimed = AlertCatalogRegistry.lookup(id("Minimed", "lowRLBattery"))
+        #expect(omni != nil)
+        #expect(minimed != nil)
+        #expect(omni?.identifier.managerIdentifier == "Omni")
+        #expect(minimed?.identifier.managerIdentifier == "Minimed")
+    }
+
+    // MARK: - H: invariant — no duplicate manager+alert keys
+
+    @Test("entries have unique manager+alert identifiers") func entriesHaveUniqueKeys() {
+        let keys = Set(AlertCatalogRegistry.entries.map(\.identifier))
+        #expect(keys.count == AlertCatalogRegistry.entries.count)
+    }
+}