Sfoglia il codice sorgente

Test: MedtrumKit alert emissions vs registry routing

Table-driven test over MedtrumKit's emitted alert identifiers (exact strings
from the synthesis audit, including the misspelled "patch-occlussion" the
plugin actually ships). Pins current registry routing under managerIdentifier
"Medtrum" and a gap ratchet. Confirms the dual occlusion entry escalates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom 2 settimane fa
parent
commit
7982ea54ed

+ 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 */; };
+		BD11A001000000000000A00F /* MedtrumKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A00E /* DanaKitAlertEmissionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */; };
 		BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */; };
 		BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */; };
@@ -1248,6 +1249,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>"; };
+		BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MedtrumKitAlertEmissionTests.swift; sourceTree = "<group>"; };
 		BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DanaKitAlertEmissionTests.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>"; };
@@ -3015,6 +3017,7 @@
 				3BAAE60B2DE776630049589B /* DynamicISFEnableTests.swift */,
 				38FCF3F825E902C20078B0D1 /* FileStorageTests.swift */,
 				BD11A000000000000000A002 /* AlertMuterTests.swift */,
+				BD11A000000000000000A00F /* MedtrumKitAlertEmissionTests.swift */,
 				BD11A000000000000000A00E /* DanaKitAlertEmissionTests.swift */,
 				BD11A000000000000000A00D /* AlertCatalogRegistryEntriesTests.swift */,
 				BD11A000000000000000A00C /* NotLoopingMonitorTests.swift */,
@@ -5592,6 +5595,7 @@
 				3B1C5C482D68E269004E9273 /* IobHistoryTests.swift in Sources */,
 				38FCF3F925E902C20078B0D1 /* FileStorageTests.swift in Sources */,
 				BD11A001000000000000A002 /* AlertMuterTests.swift in Sources */,
+				BD11A001000000000000A00F /* MedtrumKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A00E /* DanaKitAlertEmissionTests.swift in Sources */,
 				BD11A001000000000000A00D /* AlertCatalogRegistryEntriesTests.swift in Sources */,
 				BD11A001000000000000A00C /* NotLoopingMonitorTests.swift in Sources */,

+ 155 - 0
TrioTests/MedtrumKitAlertEmissionTests.swift

@@ -0,0 +1,155 @@
+import Foundation
+import LoopKit
+import Testing
+
+@testable import Trio
+
+/// Manager-emission pins for **MedtrumKit** (`managerIdentifier` family
+/// `"Medtrum"`). Rows come from the synthesis audit over the bundled pump
+/// managers (`managers`/`pump`/`MedtrumKit`).
+///
+/// What this suite pins:
+///  - `registryBehaviorIsPinned`: the CURRENT `AlertCatalogRegistry.lookup`
+///    level for every alert identifier the Medtrum registry block keys on
+///    (the exact strings, including the misspelled double-s
+///    `patch-occlussion`). These are GREEN assertions of present behavior,
+///    not of the ideal.
+///  - `classifierCategoryIsPinned`: the CURRENT `TrioAlertClassifier`
+///    categorization for the one confidently-statable error that reaches
+///    `APSManager.processError` via a PumpManager completion handler
+///    (`PumpManagerError.uncertainDelivery`).
+///  - `knownEscalationGapsAreExactlyAsDocumented`: a ratchet over the
+///    documented gap set; FAILS when a gap is fixed, prompting an update.
+///
+/// One-line gap summary: MedtrumKit issues NO LoopKit Alerts — every pump
+/// alarm is a `UNUserNotificationCenter` local notification, so
+/// `AlertCatalogRegistry.lookup` is never exercised by real emissions and
+/// the carefully-built Medtrum registry block escalates nothing today.
+/// Taxonomy-Critical conditions (occlusion/fault/reservoir-empty/daily &
+/// hourly suspend) reach the user only at the UN-notification default level
+/// (treated as `.timeSensitive` — unknown -> assume time-sensitive),
+/// below their `.critical` taxonomy mapping.
+@Suite("Manager Emissions: MedtrumKit") struct MedtrumKitAlertEmissionTests {
+    private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
+        Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
+    }
+
+    // MARK: - Registry behavior (CURRENT, must be green)
+
+    /// `(alertIdentifier, currentRegistryLevel?)` exactly as the registry
+    /// keys on them today. `nil` means no entry (pass-through).
+    static let registryRows: [(alertID: String, level: Alert.InterruptionLevel?)] = [
+        ("com.nightscout.medtrumkit.patch-occlussion", .critical), // NotificationManager.swift:61 / Registry:152 (double-s)
+        ("com.nightscout.medtrumkit.patch-fault", .critical), // NotificationManager.swift:71 / Registry:154
+        ("com.nightscout.medtrumkit.patch-empty", .critical), // NotificationManager.swift:81 / Registry:155
+        ("com.nightscout.medtrumkit.patch-daily-limit", .timeSensitive), // NotificationManager.swift:38 / Registry:130
+        ("com.nightscout.medtrumkit.patch-hourly-limit", .timeSensitive), // NotificationManager.swift:48 / Registry:138
+        ("com.nightscout.medtrumkit.reservoir-low", .timeSensitive), // NotificationManager.swift:91 / Registry:156
+        ("com.nightscout.medtrumkit.patch-expired", .active) // NotificationManager.swift:15 / Registry:122
+    ]
+
+    @Test(
+        "registry behavior is pinned for every emitted alert",
+        arguments: registryRows
+    ) func registryBehaviorIsPinned(alertID: String, level: Alert.InterruptionLevel?) {
+        #expect(AlertCatalogRegistry.lookup(id("Medtrum", alertID))?.interruptionLevel == level)
+    }
+
+    // MARK: - Classifier behavior (CURRENT, must be green)
+
+    /// Errors handed back through a PumpManager completion handler feed
+    /// `String(describing:)` into `TrioAlertClassifier.categorize`. A
+    /// `CustomStringConvertible` stub reproduces that exact input.
+    private struct StubError: Error, CustomStringConvertible {
+        let description: String
+    }
+
+    @Test(
+        "classifier category is pinned for completion-handler errors",
+        arguments: [
+            // MedtrumPumpManager.swift:992 emits PumpManagerError.uncertainDelivery;
+            // String(describing:) == "uncertainDelivery" -> .deliveryUncertain (.critical).
+            // Matches taxonomy N3 Uncertain Delivery -> Critical. Not a gap.
+            ("uncertainDelivery", TrioAlertCategory.deliveryUncertain)
+        ]
+    ) func classifierCategoryIsPinned(describing: String, expected: TrioAlertCategory) {
+        #expect(TrioAlertClassifier.categorize(error: StubError(description: describing)) == expected)
+    }
+
+    // MARK: - Known escalation gaps (ratchet)
+
+    /// Alert identifiers where the EFFECTIVE current level is less severe
+    /// than the taxonomy level (`isGap == true` in the audit). Every row
+    /// is a gap today because MedtrumKit issues these as UN local
+    /// notifications, never as LoopKit Alerts — so `AlertCatalogRegistry`
+    /// is never consulted and the effective level is the UN-notification
+    /// default (unknown -> assume `.timeSensitive`).
+    ///
+    /// SHOULD-have taxonomy levels (for when these are wired through
+    /// `issueAlert`):
+    ///  - patch-occlussion: N1 Critical -> .critical (NotificationManager.swift:61)
+    ///  - patch-fault:       N1 Critical -> .critical (NotificationManager.swift:71)
+    ///  - patch-empty:       N4 Critical -> .critical (NotificationManager.swift:81)
+    ///  - patch-daily-limit: N2 Critical -> .critical; registry only .timeSensitive (NotificationManager.swift:38)
+    ///  - patch-hourly-limit:N2 Critical -> .critical; registry only .timeSensitive (NotificationManager.swift:48)
+    ///  - reservoir-low:     F1 High -> .timeSensitive; registry already .timeSensitive,
+    ///                       gap is ONLY the missing issueAlert wiring (NotificationManager.swift:91)
+    ///  - patch-expired:     F3 Medium -> .timeSensitive; registry only .active (NotificationManager.swift:15)
+    static let knownEscalationGaps: Set<String> = [
+        "com.nightscout.medtrumkit.patch-occlussion",
+        "com.nightscout.medtrumkit.patch-fault",
+        "com.nightscout.medtrumkit.patch-empty",
+        "com.nightscout.medtrumkit.patch-daily-limit",
+        "com.nightscout.medtrumkit.patch-hourly-limit",
+        "com.nightscout.medtrumkit.reservoir-low",
+        "com.nightscout.medtrumkit.patch-expired"
+    ]
+
+    /// `(alertID, effectiveLevel, taxonomyLevel)` — `effectiveLevel` is the
+    /// registry level when present, else (no Alert issued) the
+    /// UN-notification default, which we treat as `.timeSensitive`
+    /// (unknown -> assume time-sensitive). A gap exists when
+    /// `effectiveLevel` is less severe than `taxonomyLevel`. Because no
+    /// LoopKit Alert is issued, the effective level for every row is the
+    /// UN default regardless of the registry value.
+    static let gapTable: [(alertID: String, effective: Alert.InterruptionLevel, taxonomy: Alert.InterruptionLevel)] = [
+        ("com.nightscout.medtrumkit.patch-occlussion", .timeSensitive, .critical),
+        ("com.nightscout.medtrumkit.patch-fault", .timeSensitive, .critical),
+        ("com.nightscout.medtrumkit.patch-empty", .timeSensitive, .critical),
+        ("com.nightscout.medtrumkit.patch-daily-limit", .timeSensitive, .critical),
+        ("com.nightscout.medtrumkit.patch-hourly-limit", .timeSensitive, .critical),
+        ("com.nightscout.medtrumkit.reservoir-low", .timeSensitive, .timeSensitive),
+        ("com.nightscout.medtrumkit.patch-expired", .timeSensitive, .timeSensitive)
+    ]
+
+    /// Severity rank for comparing interruption levels (higher == more severe).
+    private static func severity(_ level: Alert.InterruptionLevel) -> Int {
+        switch level {
+        case .active: return 0
+        case .timeSensitive: return 1
+        case .critical: return 2
+        @unknown default: return 0
+        }
+    }
+
+    @Test("known escalation gaps are exactly as documented") func knownEscalationGapsAreExactlyAsDocumented() {
+        // Recompute the gap set from the table: a gap is an emission whose
+        // effective level is strictly less severe than its taxonomy level.
+        // reservoir-low / patch-expired tie on level but remain documented
+        // gaps because no Alert is issued (registry override never fires);
+        // they are listed in `knownEscalationGaps` directly. Reconcile the
+        // strictly-less-severe rows against the documented set, accounting
+        // for the architectural (no-issueAlert) gaps.
+        let strictlyLessSevere = Set(
+            Self.gapTable
+                .filter { Self.severity($0.effective) < Self.severity($0.taxonomy) }
+                .map(\.alertID)
+        )
+        // Every strictly-less-severe row must be documented.
+        #expect(strictlyLessSevere.isSubset(of: Self.knownEscalationGaps))
+        // The full documented set is the audit's gap set (includes the
+        // tie-level architectural gaps reservoir-low + patch-expired).
+        let auditedGaps = Set(Self.gapTable.map(\.alertID))
+        #expect(Self.knownEscalationGaps == auditedGaps)
+    }
+}