Quellcode durchsuchen

Test: RileyLinkKit message classification catalog

Augment the RileyLinkKit emission suite with a complete (identifier, message) ->
TrioAlertClassifier.categorize table sourced from the loopkit-manager-synthesis
audit: every reported alert/error string paired with its identifier, asserting
the current classifier category (green) plus a coverage-gap ratchet for
messages whose taxonomy implies a bucket the substring classifier misses.
Spec note: production feeds the classifier String(describing: error) (case
names), not these display strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
James Woglom vor 2 Wochen
Ursprung
Commit
a53e0fdda3
1 geänderte Dateien mit 415 neuen und 0 gelöschten Zeilen
  1. 415 0
      TrioTests/RileyLinkKitAlertEmissionTests.swift

+ 415 - 0
TrioTests/RileyLinkKitAlertEmissionTests.swift

@@ -127,3 +127,418 @@ import Testing
         #expect(recomputed == Self.knownEscalationGaps)
     }
 }
+
+/// SPEC — Message-text classification catalog for **RileyLinkKit**.
+///
+/// IMPORTANT mismatch vs. production: in prod, `TrioAlertClassifier.categorize`
+/// receives `String(describing: error)`, which for RileyLinkKit's two
+/// `LocalizedError` enums (`PeripheralManagerError`, `RileyLinkDeviceError`,
+/// neither `CustomStringConvertible`) is the Swift CASE NAME — not the
+/// human-readable display string. The sibling suite
+/// `RileyLinkKitAlertEmissionTests` already pins the case-name path. THIS suite
+/// instead catalogs every *reportable display string* (the verbatim
+/// `errorDescription` / `failureReason` / `recoverySuggestion` / UI label a
+/// user could actually see) keyed by its emitting identifier, and pins how the
+/// substring classifier handles that real natural-language text.
+///
+/// RileyLinkKit emits NO LoopKit Alerts, no `UNUserNotification`s, and has no
+/// alert-code enum; all user-facing messages come from the two `LocalizedError`
+/// enums plus the dev-app-only `KeychainManagerError` and ad-hoc
+/// UIAlertController / verbatim error renderings. Because there are no
+/// `Alert.Identifier`s, `alertIdentifier` is the error case name / source
+/// symbol.
+///
+/// HEADLINE: the classifier lowercases and does SPACE-SENSITIVE substring
+/// matching with no-space tokens ("notconnected" / "noresponse" / "timeout" /
+/// "communication" / "comms" / "rssi"). NONE of RileyLinkKit's natural-language
+/// prose contains those tokens, so EVERY message falls through to `.other`.
+/// This is a systematic under-coverage gap for the N8 connectivity bucket: 19
+/// distinct N8 emissions SHOULD map to `.commsTransient` but all resolve to
+/// `.other` (isGap=true). The N9 command-failure / N10 auth / N13 config / N15
+/// keychain strings correctly map to `.other` (no taxonomy bucket; isGap=false).
+///
+/// Audit + classified data:
+/// loopkit-manager-synthesis/investigations/alert-notification-emissions/managers/pump/RileyLinkKit/
+/// Classifier source: Trio/Sources/Services/Alerts/TrioAlertCategory.swift
+@Suite("Manager Message Classification: RileyLinkKit") struct RileyLinkKitMessageClassificationTests {
+    /// Stub whose `String(describing:)` IS the display string, so classification
+    /// runs over the natural-language text a user would actually see.
+    private struct StubError: Error, CustomStringConvertible {
+        let description: String
+    }
+
+    struct Row {
+        let identifier: String
+        let message: String
+        let role: String
+        let taxonomy: String
+        let expected: TrioAlertCategory
+    }
+
+    /// Every reportable display string with its emitting identifier. Each row's
+    /// `expected` is built from the audit's `currentCategory` — all "other" for
+    /// RileyLinkKit — so `expected == .other(message)` throughout, pinning that
+    /// the substring classifier never matches RileyLinkKit's prose.
+    static let rows: [Row] = [
+        // RileyLinkBLEKit/PeripheralManager.swift:174
+        Row(
+            identifier: "PeripheralManagerError.unknownCharacteristic",
+            message: "Unknown characteristic: %@",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Unknown characteristic: %@")
+        ),
+        // RileyLinkBLEKit/PeripheralManager.swift:195
+        Row(
+            identifier: "PeripheralManagerError.notReady",
+            message: "RileyLink is not connected",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("RileyLink is not connected")
+        ),
+        // RileyLinkBLEKit/PeripheralManager.swift:205
+        Row(
+            identifier: "PeripheralManagerError.busy",
+            message: "RileyLink is busy",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink is busy")
+        ),
+        // RileyLinkBLEKit/PeripheralManager.swift:225
+        Row(
+            identifier: "PeripheralManagerError.timeout",
+            message: "RileyLink did not respond in time",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("RileyLink did not respond in time")
+        ),
+        // RileyLinkBLEKit/PeripheralManager.swift:229
+        Row(
+            identifier: "PeripheralManagerError.cbPeripheralError",
+            message: "underlying CoreBluetooth error.localizedDescription",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("underlying CoreBluetooth error.localizedDescription")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:387
+        Row(
+            identifier: "PeripheralManagerError.emptyValue",
+            message: "Characteristic value was empty",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Characteristic value was empty")
+        ),
+        // RileyLinkBLEKit/PeripheralManagerError.swift:18 (dead code)
+        Row(
+            identifier: "PeripheralManagerError.unknownService",
+            message: "Unknown service: %@",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Unknown service: %@")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:238,291,365,396,590,658
+        Row(
+            identifier: "RileyLinkDeviceError.peripheralManagerError",
+            message: "wrapped PeripheralManagerError description",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("wrapped PeripheralManagerError description")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:171
+        Row(
+            identifier: "RileyLinkDeviceError.writeSizeLimitExceeded",
+            message: "Data exceeded maximum size of %@ bytes",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("Data exceeded maximum size of %@ bytes")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:278
+        Row(
+            identifier: "RileyLinkDeviceError.errorResponse",
+            message: "the rejected name string itself",
+            role: "validation",
+            taxonomy: "N13",
+            expected: .other("the rejected name string itself")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:391,595,662; CommandSession.swift:101,122
+        Row(
+            identifier: "RileyLinkDeviceError.invalidResponse",
+            message: "Response %@ is invalid",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Response %@ is invalid")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:588
+        Row(
+            identifier: "RileyLinkDeviceError.commandsBlocked",
+            message: "RileyLink command did not respond",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("RileyLink command did not respond")
+        ),
+        // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:588
+        Row(
+            identifier: "RileyLinkDeviceError.commandsBlocked (recoverySuggestion)",
+            message: "RileyLink may need to be turned off and back on",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("RileyLink may need to be turned off and back on")
+        ),
+        // RileyLinkBLEKit/PeripheralManagerError.swift
+        Row(
+            identifier: "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason)",
+            message: "The RileyLink was temporarily disconnected",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("The RileyLink was temporarily disconnected")
+        ),
+        // RileyLinkBLEKit/PeripheralManagerError.swift
+        Row(
+            identifier: "PeripheralManagerError.unknownCharacteristic (recoverySuggestion)",
+            message: "Make sure the device is nearby, and the issue should resolve automatically",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Make sure the device is nearby, and the issue should resolve automatically")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:97,99
+        Row(
+            identifier: "RileyLinkDeviceError.responseTimeout",
+            message: "Pump did not respond in time",
+            role: "errorMessage",
+            taxonomy: "N8",
+            expected: .other("Pump did not respond in time")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:103
+        Row(
+            identifier: "RileyLinkDeviceError.errorResponse",
+            message: "RileyLink reported invalid param: <hex>",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink reported invalid param: <hex>")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:105
+        Row(
+            identifier: "RileyLinkDeviceError.errorResponse",
+            message: "RileyLink reported unknown command: <hex>",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink reported unknown command: <hex>")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:136
+        Row(
+            identifier: "RileyLinkDeviceError.unsupportedCommand",
+            message: "RileyLink firmware does not support the readRegister command",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink firmware does not support the readRegister command")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:143
+        Row(
+            identifier: "RileyLinkDeviceError.errorResponse",
+            message: "Unsupported register: <register>",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("Unsupported register: <register>")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:270
+        Row(
+            identifier: "RileyLinkDeviceError.unsupportedCommand",
+            message: "RileyLink firmware does not support the setSWEncoding command",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink firmware does not support the setSWEncoding command")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:278
+        Row(
+            identifier: "RileyLinkDeviceError.unsupportedCommand",
+            message: "RileyLink firmware does not support the Set Software Encoding error command",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink firmware does not support the Set Software Encoding error command")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:293
+        Row(
+            identifier: "RileyLinkDeviceError.unsupportedCommand",
+            message: "RileyLink firmware does not support the getStatistics command",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink firmware does not support the getStatistics command")
+        ),
+        // RileyLinkBLEKit/CommandSession.swift:304
+        Row(
+            identifier: "RileyLinkDeviceError.unsupportedCommand",
+            message: "RileyLink firmware does not support the setPreamble command",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("RileyLink firmware does not support the setPreamble command")
+        ),
+        // RileyLinkKitUI/CommandResponseViewController.swift:25,51
+        Row(
+            identifier: "CommandResponseViewController (String(describing:))",
+            message: "verbatim rendering of the thrown RileyLinkDeviceError",
+            role: "errorMessage",
+            taxonomy: "N9",
+            expected: .other("verbatim rendering of the thrown RileyLinkDeviceError")
+        ),
+        // RileyLink/AuthenticationViewController.swift:44
+        Row(
+            identifier: "presentAlertControllerWithError (AuthenticationViewController)",
+            message: "verification error's localizedDescription + recovery suggestion",
+            role: "errorMessage",
+            taxonomy: "N10",
+            expected: .other("verification error's localizedDescription + recovery suggestion")
+        ),
+        // RileyLinkKitUI/RileyLinkDeviceTableViewController.swift:730
+        Row(
+            identifier: "UIAlertController (battery threshold action sheet)",
+            message: "Battery level Alert",
+            role: "alertTitle",
+            taxonomy: "N13",
+            expected: .other("Battery level Alert")
+        ),
+        // RileyLinkKitUI/RileyLinkDeviceTableViewController.swift:730
+        Row(
+            identifier: "UIAlertController (battery threshold action sheet, actions)",
+            message: "OFF, 20%, 30%, 40%, 50%",
+            role: "validation",
+            taxonomy: "N13",
+            expected: .other("OFF, 20%, 30%, 40%, 50%")
+        ),
+        // RileyLink/Extensions/UIViewController.swift:20,46
+        Row(
+            identifier: "presentAlertControllerWithError/Title (OK button)",
+            message: "OK",
+            role: "validation",
+            taxonomy: "N13",
+            expected: .other("OK")
+        ),
+        // RileyLink/KeychainManager.swift:78,111,152
+        Row(
+            identifier: "KeychainManagerError.add",
+            message: "(no localized message; String(describing:) only, e.g. add(-26275))",
+            role: "errorMessage",
+            taxonomy: "N15",
+            expected: .other("(no localized message; String(describing:) only, e.g. add(-26275))")
+        ),
+        // RileyLink/KeychainManager.swift:126,188
+        Row(
+            identifier: "KeychainManagerError.copy",
+            message: "(no localized message; String(describing:) only, e.g. copy(-25300))",
+            role: "errorMessage",
+            taxonomy: "N15",
+            expected: .other("(no localized message; String(describing:) only, e.g. copy(-25300))")
+        ),
+        // RileyLink/KeychainManager.swift:91
+        Row(
+            identifier: "KeychainManagerError.delete",
+            message: "(no localized message; String(describing:) only, e.g. delete(-25300))",
+            role: "errorMessage",
+            taxonomy: "N15",
+            expected: .other("(no localized message; String(describing:) only, e.g. delete(-25300))")
+        ),
+        // RileyLink/KeychainManager.swift:130,199
+        Row(
+            identifier: "KeychainManagerError.unknownResult",
+            message: "(no localized message; String(describing:) only, e.g. unknownResult)",
+            role: "errorMessage",
+            taxonomy: "N15",
+            expected: .other("(no localized message; String(describing:) only, e.g. unknownResult)")
+        )
+    ]
+
+    @Test("each (identifier, message) classifies as pinned", arguments: rows) func eachMessageClassifiesAsPinned(row: Row) {
+        #expect(TrioAlertClassifier.categorize(error: StubError(description: row.message)) == row.expected)
+    }
+
+    // MARK: - Classifier-coverage gap ratchet
+
+    /// Keys ("identifier — message") for every N8 connectivity/communication
+    /// message that SHOULD reach `.commsTransient` but currently falls to
+    /// `.other` because its natural-language text contains none of the
+    /// classifier's no-space tokens ("notconnected" / "noresponse" / "timeout" /
+    /// "communication" / "comms" / "rssi"). Each entry documents WHY the tokens
+    /// miss:
+    ///
+    ///   - "Unknown characteristic: %@" (PeripheralManager.swift:174): prose has
+    ///     no connectivity token. SHOULD be .commsTransient.
+    ///   - "RileyLink is not connected" (PeripheralManager.swift:195): "not
+    ///     connected" has a SPACE; token is "notconnected" (no space) -> misses.
+    ///     SHOULD be .commsTransient.
+    ///   - "RileyLink did not respond in time" (PeripheralManager.swift:225):
+    ///     "did not respond" != "noresponse"; "in time" != "timeout". Misses.
+    ///     SHOULD be .commsTransient.
+    ///   - "underlying CoreBluetooth error.localizedDescription"
+    ///     (PeripheralManager.swift:229): wrapped CB text, no token. SHOULD be
+    ///     .commsTransient.
+    ///   - "Characteristic value was empty"
+    ///     (PeripheralManager+RileyLink.swift:387): no token. SHOULD be
+    ///     .commsTransient.
+    ///   - "Unknown service: %@" (PeripheralManagerError.swift:18, dead code):
+    ///     no token. SHOULD be .commsTransient.
+    ///   - "wrapped PeripheralManagerError description"
+    ///     (PeripheralManager+RileyLink.swift:238,291,365,396,590,658): forwards
+    ///     a BLE-level message that itself misses. SHOULD be .commsTransient.
+    ///   - "Response %@ is invalid"
+    ///     (PeripheralManager+RileyLink.swift:391,595,662; CommandSession.swift:101,122):
+    ///     no token. SHOULD be .commsTransient.
+    ///   - "RileyLink command did not respond"
+    ///     (PeripheralManager+RileyLink.swift:588): "did not respond" !=
+    ///     "noresponse". Misses. SHOULD be .commsTransient.
+    ///   - "RileyLink may need to be turned off and back on"
+    ///     (PeripheralManager+RileyLink.swift:588, recoverySuggestion): no token.
+    ///     SHOULD be .commsTransient.
+    ///   - "The RileyLink was temporarily disconnected"
+    ///     (PeripheralManagerError.swift, failureReason): "disconnected" !=
+    ///     "notconnected". Misses. SHOULD be .commsTransient.
+    ///   - "Make sure the device is nearby, and the issue should resolve
+    ///     automatically" (PeripheralManagerError.swift, recoverySuggestion): no
+    ///     token. SHOULD be .commsTransient.
+    ///   - "Pump did not respond in time" (CommandSession.swift:97,99): "did not
+    ///     respond" != "noresponse"; "in time" != "timeout". Misses. SHOULD be
+    ///     .commsTransient.
+    ///
+    /// N9/N10/N13/N15 rows correctly land in `.other` (no taxonomy bucket) and
+    /// are NOT gaps. This set stays green now and FAILS when the classifier is
+    /// improved to catch this prose (prompting a ratchet update).
+    static let classifierCoverageGaps: Set<String> = [
+        "PeripheralManagerError.unknownCharacteristic — Unknown characteristic: %@",
+        "PeripheralManagerError.notReady — RileyLink is not connected",
+        "PeripheralManagerError.timeout — RileyLink did not respond in time",
+        "PeripheralManagerError.cbPeripheralError — underlying CoreBluetooth error.localizedDescription",
+        "PeripheralManagerError.emptyValue — Characteristic value was empty",
+        "PeripheralManagerError.unknownService — Unknown service: %@",
+        "RileyLinkDeviceError.peripheralManagerError — wrapped PeripheralManagerError description",
+        "RileyLinkDeviceError.invalidResponse — Response %@ is invalid",
+        "RileyLinkDeviceError.commandsBlocked — RileyLink command did not respond",
+        "RileyLinkDeviceError.commandsBlocked (recoverySuggestion) — RileyLink may need to be turned off and back on",
+        "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason) — The RileyLink was temporarily disconnected",
+        "PeripheralManagerError.unknownCharacteristic (recoverySuggestion) — Make sure the device is nearby, and the issue should resolve automatically",
+        "RileyLinkDeviceError.responseTimeout — Pump did not respond in time"
+    ]
+
+    @Test("classifier-coverage gaps are exactly as documented") func classifierCoverageGapsAreExact() {
+        // A row is a gap when the audit assigns it a real taxonomy bucket
+        // (commsTransient) yet the classifier currently yields .other(message).
+        let gapBuckets: [String: String] = [
+            "PeripheralManagerError.unknownCharacteristic": "commsTransient",
+            "PeripheralManagerError.notReady": "commsTransient",
+            "PeripheralManagerError.timeout": "commsTransient",
+            "PeripheralManagerError.cbPeripheralError": "commsTransient",
+            "PeripheralManagerError.emptyValue": "commsTransient",
+            "PeripheralManagerError.unknownService": "commsTransient",
+            "RileyLinkDeviceError.peripheralManagerError": "commsTransient",
+            "RileyLinkDeviceError.invalidResponse": "commsTransient",
+            "RileyLinkDeviceError.commandsBlocked": "commsTransient",
+            "RileyLinkDeviceError.commandsBlocked (recoverySuggestion)": "commsTransient",
+            "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason)": "commsTransient",
+            "PeripheralManagerError.unknownCharacteristic (recoverySuggestion)": "commsTransient",
+            "RileyLinkDeviceError.responseTimeout": "commsTransient"
+        ]
+        let recomputed = Set(
+            Self.rows
+                .filter { $0.expected == .other($0.message) && gapBuckets[$0.identifier] != nil }
+                .map { "\($0.identifier) — \($0.message)" }
+        )
+        #expect(recomputed == Self.classifierCoverageGaps)
+    }
+}