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

Test: DanaKit alert emissions vs registry routing

Table-driven test over every PumpManagerAlert DanaKit issues (exact
identifiers from the loopkit-manager-synthesis audit). Pins current
AlertCatalogRegistry routing under DanaKit's real managerIdentifier "Dana"
(all 15 resolve), plus a gap ratchet. Documented gaps: basalMax & dailyMax
(taxonomy N2 critical, registry .active) and unknown (N1 critical catch-all,
registry .timeSensitive) under-escalate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom недель назад: 2
Родитель
Сommit
6c399013eb
2 измененных файлов с 180 добавлено и 0 удалено
  1. 4 0
      Trio.xcodeproj/project.pbxproj
  2. 176 0
      TrioTests/DanaKitAlertEmissionTests.swift

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

+ 176 - 0
TrioTests/DanaKitAlertEmissionTests.swift

@@ -0,0 +1,176 @@
+import Foundation
+import LoopKit
+import Testing
+
+@testable import Trio
+
+/// Pins how DanaKit's emitted LoopKit Alerts are routed through Trio's alert
+/// layer, as recorded by the synthesis audit over the managers / pump /
+/// DanaKit sources (`DanaKit/Packets/DanaNotifyAlarm.swift`,
+/// `DanaKit/PumpManager/PumpManagerAlert.swift`,
+/// `DanaKit/PumpManager/DanaKitPumpManager.swift`).
+///
+/// What this suite pins:
+///  - The CURRENT (not ideal) registry behavior for every alert DanaKit
+///    issues. Unlike MinimedKit, DanaKit issues all 15 `PumpManagerAlert`
+///    cases via `delegate.issueAlert` with managerIdentifier "Dana", which
+///    MATCHES the registry key (AlertCatalogRegistry.swift:99-115). Every
+///    emission therefore resolves to a registry `CatalogEntry` and is
+///    overridden to that entry's interruptionLevel; the plugin itself sets no
+///    interruptionLevel, so without a registry hit they would fall back to
+///    LoopKit's default `.timeSensitive`.
+///  - The documented escalation gaps, as a ratchet that fails when a gap is
+///    fixed (forcing this file to be updated).
+///
+/// No classifier rows: every `DanaKitPumpManagerError` reaching
+/// `APSManager.processError` is wrapped in LoopKit's `PumpManagerError` before
+/// leaving the completion handler, so the exact `String(describing:)` input is
+/// the wrapping, not the Dana case name. That input cannot be stated from the
+/// synthesis audit alone, so classifier rows are omitted rather than guessed.
+///
+/// One-line gap summary: three taxonomy-Critical emissions are
+/// under-escalated by the registry — `basalMax` and `dailyMax` (N2 Delivery
+/// Suspended/Stopped -> `.critical`) are registered `.active`, and `unknown`
+/// (N1 Hardware Fault catch-all -> `.critical`) is registered `.timeSensitive`
+/// — so a daily/basal hard stop or an unrecognized pump alarm reaches the user
+/// below critical.
+@Suite("Manager Emissions: DanaKit") struct DanaKitAlertEmissionTests {
+    private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
+        Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
+    }
+
+    /// The managerIdentifier DanaKit actually issues with, which matches the
+    /// registry's "Dana" key (so lookups resolve, unlike MinimedKit).
+    private static let emittedManagerIdentifier = "Dana"
+
+    /// Emitted alerts from the synthesis audit. `currentRegistryLevel` is the
+    /// level `lookup(id("Dana", alertID))` returns TODAY. `taxonomyLevel` is
+    /// what the row should be per taxonomy; `isGap` is true when the effective
+    /// level (the registry level here, since all "Dana" lookups resolve) is
+    /// less severe than taxonomy.
+    struct Row {
+        let alertIdentifier: String
+        let currentRegistryLevel: Alert.InterruptionLevel?
+        let taxonomyLevel: Alert.InterruptionLevel
+        let isGap: Bool
+    }
+
+    private static let rows: [Row] = [
+        // N5-pump -> .critical. Registry .critical matches taxonomy.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:11
+        Row(alertIdentifier: "batteryZeroPercent", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // N1 Hardware Fault -> .critical. Registry .critical matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:12
+        Row(alertIdentifier: "pumpError", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // N1 -> .critical. Registry .critical matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:13
+        Row(alertIdentifier: "occlusion", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // F2 Battery Low (High) -> .timeSensitive. Registry .timeSensitive matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:14
+        Row(alertIdentifier: "lowBattery", currentRegistryLevel: .timeSensitive, taxonomyLevel: .timeSensitive, isGap: false),
+        // N2 Delivery Suspended -> .critical. Registry .critical matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:15
+        Row(alertIdentifier: "shutdown", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // N14 Informational/Status -> .active. Registry .active matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:16
+        Row(alertIdentifier: "basalCompare", currentRegistryLevel: .active, taxonomyLevel: .active, isGap: false),
+        // N14 -> .active. Registry .active matches. Codes 0x07/0xFF both map here.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:17
+        Row(alertIdentifier: "bloodSugarMeasure", currentRegistryLevel: .active, taxonomyLevel: .active, isGap: false),
+        // F1 Insulin Supply Low (High) -> .timeSensitive. Registry .timeSensitive
+        // matches. Codes 0x08/0xFE both map here.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:19
+        Row(
+            alertIdentifier: "remainingInsulinLevel",
+            currentRegistryLevel: .timeSensitive,
+            taxonomyLevel: .timeSensitive,
+            isGap: false
+        ),
+        // N4 Reservoir Empty -> .critical. Registry .critical matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:21
+        Row(alertIdentifier: "emptyReservoir", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // N1 Hardware Fault -> .critical. Registry .critical matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:22
+        Row(alertIdentifier: "checkShaft", currentRegistryLevel: .critical, taxonomyLevel: .critical, isGap: false),
+        // N2 Delivery Suspended/Stopped -> .critical. GAP: registry registers
+        // .active, which is LESS severe than taxonomy .critical.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:23
+        Row(alertIdentifier: "basalMax", currentRegistryLevel: .active, taxonomyLevel: .critical, isGap: true),
+        // N2 -> .critical. GAP: registry registers .active, LESS severe than
+        // taxonomy .critical.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:24
+        Row(alertIdentifier: "dailyMax", currentRegistryLevel: .active, taxonomyLevel: .critical, isGap: true),
+        // N14 Informational/Status -> .active. Registry .active matches.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:25
+        Row(alertIdentifier: "bloodSugarCheckMiss", currentRegistryLevel: .active, taxonomyLevel: .active, isGap: false),
+        // N1 Hardware Fault catch-all for unmapped alarm codes -> .critical.
+        // GAP: registry registers .timeSensitive, LESS severe than taxonomy
+        // .critical, so an unrecognized pump alarm reaches the user below
+        // critical.
+        // DanaKit/Packets/DanaNotifyAlarm.swift:29
+        Row(alertIdentifier: "unknown", currentRegistryLevel: .timeSensitive, taxonomyLevel: .critical, isGap: true),
+        // N10 Authentication/Security -> .timeSensitive. Registry .timeSensitive
+        // matches. DEAD CASE: defined with full copy but never constructed/fired
+        // via issueAlert (the equivalent text is an ad hoc SwiftUI string at
+        // DanaKitScanViewModel.swift:77, which Trio never receives). Listed for
+        // completeness; no gap if it were ever fired.
+        // DanaKit/PumpManager/PumpManagerAlert.swift:18
+        Row(alertIdentifier: "ble5InvalidKeys", currentRegistryLevel: .timeSensitive, taxonomyLevel: .timeSensitive, isGap: false)
+    ]
+
+    // MARK: - Registry behavior (pinned to CURRENT, must be green)
+
+    @Test(
+        "registry behavior is pinned for every emitted alert",
+        arguments: rows
+    ) func registryBehaviorPinned(_ row: Row) {
+        // Asserts CURRENT behavior: lookup under the EMITTED managerIdentifier
+        // "Dana" returns the recorded level. All Dana lookups resolve (the
+        // managerIdentifier matches the registry key); this documents the
+        // level the registry overrides each emission to today, not the ideal.
+        #expect(
+            AlertCatalogRegistry.lookup(
+                id(Self.emittedManagerIdentifier, row.alertIdentifier)
+            )?.interruptionLevel == row.currentRegistryLevel
+        )
+    }
+
+    // MARK: - Known escalation gaps (ratchet)
+
+    /// AlertIdentifiers whose effective level is LESS severe than their
+    /// taxonomy level today. Documented expectation per identifier:
+    ///
+    ///  - "basalMax": SHOULD be `.critical` (taxonomy N2 Delivery
+    ///    Suspended/Stopped). Registry registers it `.active`
+    ///    (AlertCatalogRegistry.swift:110). A basal hard stop reaches the user
+    ///    below critical.
+    ///    Source: DanaKit/Packets/DanaNotifyAlarm.swift:23
+    ///
+    ///  - "dailyMax": SHOULD be `.critical` (taxonomy N2). Registry registers
+    ///    it `.active` (AlertCatalogRegistry.swift:111). A daily-insulin hard
+    ///    stop reaches the user below critical.
+    ///    Source: DanaKit/Packets/DanaNotifyAlarm.swift:24
+    ///
+    ///  - "unknown": SHOULD be `.critical` (taxonomy N1 Hardware Fault). This
+    ///    is the catch-all for unmapped pump alarm codes; registry registers it
+    ///    `.timeSensitive` (AlertCatalogRegistry.swift:114), so an unrecognized
+    ///    pump alarm reaches the user below critical.
+    ///    Source: DanaKit/Packets/DanaNotifyAlarm.swift:29
+    ///
+    /// (basalMax/dailyMax carry warning-style copy — "contact your distributer
+    /// to increase the limit" — so the N2 classification itself may warrant
+    /// review; as mapped per classified.md they are gaps.)
+    ///
+    /// This stays green now and FAILS (prompting an update here) once a gap is
+    /// closed in the registry.
+    private static let knownEscalationGaps: Set<String> = [
+        "basalMax",
+        "dailyMax",
+        "unknown"
+    ]
+
+    @Test("known escalation gaps are exactly as documented") func knownEscalationGapsExact() {
+        let computed = Set(Self.rows.filter(\.isGap).map(\.alertIdentifier))
+        #expect(computed == Self.knownEscalationGaps)
+    }
+}