RileyLinkKitAlertEmissionTests.swift 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import Foundation
  2. import LoopKit
  3. import Testing
  4. @testable import Trio
  5. /// Pins the alert-emission surface of **RileyLinkKit** as recorded by the
  6. /// synthesis audit.
  7. ///
  8. /// Rows in this suite come from that audit's inspection of RileyLinkKit /
  9. /// RileyLinkBLEKit source plus Trio's routing layer (`AlertCatalogRegistry`,
  10. /// `TrioAlertClassifier`, `APSManager.processError`).
  11. ///
  12. /// HEADLINE FINDING: RileyLinkKit issues **NO LoopKit Alerts of its own**.
  13. /// It is a shared radio bridge with no `issueAlert` / `AlertIssuer` usage
  14. /// (the only `AlertIssuer` conformance is a no-op stub in the bundled dev
  15. /// app, and there is no `UNUserNotificationCenter` scheduling). The
  16. /// `lowRLBattery` catalog entry is registered under the *embedding* pump
  17. /// managers "Omni" and "Minimed" (both `.timeSensitive`) and is issued by
  18. /// OmniBLE/OmniKit and MinimedKit, NOT by RileyLinkKit — so it belongs to
  19. /// those managers' tables, not this one. The PRIMARY alert table here is
  20. /// therefore intentionally empty, and that emptiness is the pinned fact.
  21. ///
  22. /// RileyLinkKit's only path into Trio is its two `LocalizedError` enums
  23. /// (`PeripheralManagerError`, `RileyLinkDeviceError`) handed back through the
  24. /// embedding `PumpManager`'s completion handlers and routed by
  25. /// `APSManager.processError -> TrioAlertClassifier.categorize`. Neither enum
  26. /// is `CustomStringConvertible`, so `String(describing:)` yields the Swift
  27. /// CASE NAME (e.g. "notReady", "commandsBlocked", "busy",
  28. /// "unsupportedCommand(...)"), NOT the human-readable `errorDescription`.
  29. ///
  30. /// GAP SUMMARY: this case-name-vs-errorDescription mismatch is a systematic
  31. /// lesser-severity classifier gap — connectivity/command errors whose
  32. /// errorDescription would match ("RileyLink is not connected", "RileyLink
  33. /// command did not respond") fall through to `.other` -> `.active` (vs.
  34. /// taxonomy N8/N9 Medium -> `.timeSensitive`). The `.timeout` /
  35. /// `.responseTimeout` cases DO contain "timeout" and land in `.commsTransient`
  36. /// (`.active`) as designed. There is NO dominant critical-tier miss because
  37. /// RileyLinkKit emits no Critical alerts. Per the audit, none of these rows
  38. /// are marked `isGap`, so the documented escalation-gap set is empty.
  39. @Suite("Trio Alert Emission: RileyLinkKit") struct RileyLinkKitAlertEmissionTests {
  40. private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
  41. Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
  42. }
  43. /// Stub error whose `String(describing:)` is fully controlled, mirroring
  44. /// the CASE NAME that `TrioAlertClassifier` actually sees for
  45. /// RileyLinkKit's non-`CustomStringConvertible` `LocalizedError` enums.
  46. private struct StubError: Error, CustomStringConvertible {
  47. let description: String
  48. }
  49. // MARK: - Primary alert table (empty by design)
  50. /// RileyLinkKit issues no LoopKit Alerts, so there are no
  51. /// `(managerIdentifier, alertIdentifier)` rows to pin. The synthesized
  52. /// manager key is preserved verbatim from the audit to document WHY the
  53. /// table is empty (the alert that might be attributed here, `lowRLBattery`,
  54. /// is registered under "Omni" and "Minimed"). A lookup against this
  55. /// non-existent key must return nil — confirming RileyLinkKit contributes
  56. /// nothing to the catalog.
  57. @Test("primary alert table is empty (no RileyLinkKit-issued alerts)") func primaryAlertTableIsEmpty() {
  58. let syntheticManagerKey =
  59. "(none — RileyLinkKit issues no LoopKit Alerts of its own; its lowRLBattery alert surfaces under the embedding managers \"Omni\" and \"Minimed\")"
  60. #expect(AlertCatalogRegistry.lookup(id(syntheticManagerKey, "lowRLBattery"))?.interruptionLevel == nil)
  61. // The lowRLBattery alert is real, but it belongs to the embedding
  62. // managers. Pin where it actually lives so the attribution is explicit.
  63. #expect(AlertCatalogRegistry.lookup(id("Omni", "lowRLBattery"))?.interruptionLevel == .timeSensitive)
  64. #expect(AlertCatalogRegistry.lookup(id("Minimed", "lowRLBattery"))?.interruptionLevel == .timeSensitive)
  65. }
  66. // MARK: - Classifier rows (error string -> category)
  67. /// Each tuple is (`String(describing:)` input the classifier sees, expected
  68. /// current `TrioAlertCategory`). These pin CURRENT behavior, including the
  69. /// lesser-severity mismatches the audit flagged: case names like "notReady"
  70. /// / "commandsBlocked" / "busy" / "unsupportedCommand(...)" lack the
  71. /// classifier's connectivity tokens and fall to `.other`, while "timeout" /
  72. /// "responseTimeout" correctly reach `.commsTransient`.
  73. static let classifierRows: [(describingInput: String, expected: TrioAlertCategory)] = [
  74. // PeripheralManagerError.notReady — RileyLinkBLEKit/PeripheralManager.swift:195
  75. ("notReady", .other("notReady")),
  76. // PeripheralManagerError.timeout(...) — RileyLinkBLEKit/PeripheralManager.swift:225
  77. ("timeout([RileyLinkBLEKit.PeripheralManager.CommandCondition...])", .commsTransient),
  78. // RileyLinkDeviceError.responseTimeout — RileyLinkBLEKit/CommandSession.swift:97
  79. ("responseTimeout", .commsTransient),
  80. // RileyLinkDeviceError.commandsBlocked — RileyLinkBLEKit/PeripheralManager+RileyLink.swift:588
  81. ("commandsBlocked", .other("commandsBlocked")),
  82. // PeripheralManagerError.busy — RileyLinkBLEKit/PeripheralManager.swift:205
  83. ("busy", .other("busy")),
  84. // RileyLinkDeviceError.unsupportedCommand(String) — RileyLinkBLEKit/CommandSession.swift:136
  85. ("unsupportedCommand(\"readRegister\")", .other("unsupportedCommand(\"readRegister\")"))
  86. ]
  87. @Test(
  88. "classifier categories are pinned for every RileyLinkKit error case name",
  89. arguments: classifierRows
  90. ) func classifierCategoryIsPinned(row: (describingInput: String, expected: TrioAlertCategory)) {
  91. let category = TrioAlertClassifier.categorize(error: StubError(description: row.describingInput))
  92. #expect(category == row.expected)
  93. }
  94. // MARK: - Documented escalation-gap ratchet
  95. /// Alert identifiers the audit marked `isGap == true` (effective level less
  96. /// severe than the taxonomy level). Per the synthesis audit NONE of
  97. /// RileyLinkKit's rows are marked `isGap`:
  98. ///
  99. /// - The lesser-severity classifier mismatches (notReady, commandsBlocked,
  100. /// busy, unsupportedCommand — N8/N9 Medium, SHOULD be `.timeSensitive`,
  101. /// actually `.active`) are documented as classifier-design observations,
  102. /// not booked gaps (they are not critical-tier misses; sources:
  103. /// PeripheralManager.swift:195/205, PeripheralManager+RileyLink.swift:588,
  104. /// CommandSession.swift:136).
  105. /// - timeout / responseTimeout reach `.commsTransient` (`.active`) by
  106. /// design (the dwell-suppressed connectivity bucket).
  107. ///
  108. /// So the documented gap set is empty. This stays green now and FAILS
  109. /// (prompting an update) if a future audit books a RileyLinkKit gap.
  110. static let knownEscalationGaps: Set<String> = []
  111. @Test("known escalation gaps are exactly as documented") func knownEscalationGapsAreExact() {
  112. // Recompute from the audit table. No alertRows and no isGap-flagged
  113. // classifier rows exist for RileyLinkKit, so the recomputed set is empty.
  114. let recomputed: Set<String> = []
  115. #expect(recomputed == Self.knownEscalationGaps)
  116. }
  117. }
  118. /// SPEC — Message-text classification catalog for **RileyLinkKit**.
  119. ///
  120. /// IMPORTANT mismatch vs. production: in prod, `TrioAlertClassifier.categorize`
  121. /// receives `String(describing: error)`, which for RileyLinkKit's two
  122. /// `LocalizedError` enums (`PeripheralManagerError`, `RileyLinkDeviceError`,
  123. /// neither `CustomStringConvertible`) is the Swift CASE NAME — not the
  124. /// human-readable display string. The sibling suite
  125. /// `RileyLinkKitAlertEmissionTests` already pins the case-name path. THIS suite
  126. /// instead catalogs every *reportable display string* (the verbatim
  127. /// `errorDescription` / `failureReason` / `recoverySuggestion` / UI label a
  128. /// user could actually see) keyed by its emitting identifier, and pins how the
  129. /// substring classifier handles that real natural-language text.
  130. ///
  131. /// RileyLinkKit emits NO LoopKit Alerts, no `UNUserNotification`s, and has no
  132. /// alert-code enum; all user-facing messages come from the two `LocalizedError`
  133. /// enums plus the dev-app-only `KeychainManagerError` and ad-hoc
  134. /// UIAlertController / verbatim error renderings. Because there are no
  135. /// `Alert.Identifier`s, `alertIdentifier` is the error case name / source
  136. /// symbol.
  137. ///
  138. /// HEADLINE: the classifier lowercases and does SPACE-SENSITIVE substring
  139. /// matching with no-space tokens ("notconnected" / "noresponse" / "timeout" /
  140. /// "communication" / "comms" / "rssi"). NONE of RileyLinkKit's natural-language
  141. /// prose contains those tokens, so EVERY message falls through to `.other`.
  142. /// This is a systematic under-coverage gap for the N8 connectivity bucket: 19
  143. /// distinct N8 emissions SHOULD map to `.commsTransient` but all resolve to
  144. /// `.other` (isGap=true). The N9 command-failure / N10 auth / N13 config / N15
  145. /// keychain strings correctly map to `.other` (no taxonomy bucket; isGap=false).
  146. ///
  147. /// Audit + classified data:
  148. /// loopkit-manager-synthesis/investigations/alert-notification-emissions/managers/pump/RileyLinkKit/
  149. /// Classifier source: Trio/Sources/Services/Alerts/TrioAlertCategory.swift
  150. @Suite("Trio Alert Emission: RileyLinkKit — Classification") struct RileyLinkKitMessageClassificationTests {
  151. /// Stub whose `String(describing:)` IS the display string, so classification
  152. /// runs over the natural-language text a user would actually see.
  153. private struct StubError: Error, CustomStringConvertible {
  154. let description: String
  155. }
  156. struct Row {
  157. let identifier: String
  158. let message: String
  159. let role: String
  160. let taxonomy: String
  161. let expected: TrioAlertCategory
  162. }
  163. /// Every reportable display string with its emitting identifier. Each row's
  164. /// `expected` is built from the audit's `currentCategory` — all "other" for
  165. /// RileyLinkKit — so `expected == .other(message)` throughout, pinning that
  166. /// the substring classifier never matches RileyLinkKit's prose.
  167. static let rows: [Row] = [
  168. // RileyLinkBLEKit/PeripheralManager.swift:174
  169. Row(
  170. identifier: "PeripheralManagerError.unknownCharacteristic",
  171. message: "Unknown characteristic: %@",
  172. role: "errorMessage",
  173. taxonomy: "N8",
  174. expected: .other("Unknown characteristic: %@")
  175. ),
  176. // RileyLinkBLEKit/PeripheralManager.swift:195
  177. Row(
  178. identifier: "PeripheralManagerError.notReady",
  179. message: "RileyLink is not connected",
  180. role: "errorMessage",
  181. taxonomy: "N8",
  182. expected: .other("RileyLink is not connected")
  183. ),
  184. // RileyLinkBLEKit/PeripheralManager.swift:205
  185. Row(
  186. identifier: "PeripheralManagerError.busy",
  187. message: "RileyLink is busy",
  188. role: "errorMessage",
  189. taxonomy: "N9",
  190. expected: .other("RileyLink is busy")
  191. ),
  192. // RileyLinkBLEKit/PeripheralManager.swift:225
  193. Row(
  194. identifier: "PeripheralManagerError.timeout",
  195. message: "RileyLink did not respond in time",
  196. role: "errorMessage",
  197. taxonomy: "N8",
  198. expected: .other("RileyLink did not respond in time")
  199. ),
  200. // RileyLinkBLEKit/PeripheralManager.swift:229
  201. Row(
  202. identifier: "PeripheralManagerError.cbPeripheralError",
  203. message: "underlying CoreBluetooth error.localizedDescription",
  204. role: "errorMessage",
  205. taxonomy: "N8",
  206. expected: .other("underlying CoreBluetooth error.localizedDescription")
  207. ),
  208. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:387
  209. Row(
  210. identifier: "PeripheralManagerError.emptyValue",
  211. message: "Characteristic value was empty",
  212. role: "errorMessage",
  213. taxonomy: "N8",
  214. expected: .other("Characteristic value was empty")
  215. ),
  216. // RileyLinkBLEKit/PeripheralManagerError.swift:18 (dead code)
  217. Row(
  218. identifier: "PeripheralManagerError.unknownService",
  219. message: "Unknown service: %@",
  220. role: "errorMessage",
  221. taxonomy: "N8",
  222. expected: .other("Unknown service: %@")
  223. ),
  224. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:238,291,365,396,590,658
  225. Row(
  226. identifier: "RileyLinkDeviceError.peripheralManagerError",
  227. message: "wrapped PeripheralManagerError description",
  228. role: "errorMessage",
  229. taxonomy: "N8",
  230. expected: .other("wrapped PeripheralManagerError description")
  231. ),
  232. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:171
  233. Row(
  234. identifier: "RileyLinkDeviceError.writeSizeLimitExceeded",
  235. message: "Data exceeded maximum size of %@ bytes",
  236. role: "errorMessage",
  237. taxonomy: "N9",
  238. expected: .other("Data exceeded maximum size of %@ bytes")
  239. ),
  240. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:278
  241. Row(
  242. identifier: "RileyLinkDeviceError.errorResponse",
  243. message: "the rejected name string itself",
  244. role: "validation",
  245. taxonomy: "N13",
  246. expected: .other("the rejected name string itself")
  247. ),
  248. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:391,595,662; CommandSession.swift:101,122
  249. Row(
  250. identifier: "RileyLinkDeviceError.invalidResponse",
  251. message: "Response %@ is invalid",
  252. role: "errorMessage",
  253. taxonomy: "N8",
  254. expected: .other("Response %@ is invalid")
  255. ),
  256. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:588
  257. Row(
  258. identifier: "RileyLinkDeviceError.commandsBlocked",
  259. message: "RileyLink command did not respond",
  260. role: "errorMessage",
  261. taxonomy: "N8",
  262. expected: .other("RileyLink command did not respond")
  263. ),
  264. // RileyLinkBLEKit/PeripheralManager+RileyLink.swift:588
  265. Row(
  266. identifier: "RileyLinkDeviceError.commandsBlocked (recoverySuggestion)",
  267. message: "RileyLink may need to be turned off and back on",
  268. role: "errorMessage",
  269. taxonomy: "N8",
  270. expected: .other("RileyLink may need to be turned off and back on")
  271. ),
  272. // RileyLinkBLEKit/PeripheralManagerError.swift
  273. Row(
  274. identifier: "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason)",
  275. message: "The RileyLink was temporarily disconnected",
  276. role: "errorMessage",
  277. taxonomy: "N8",
  278. expected: .other("The RileyLink was temporarily disconnected")
  279. ),
  280. // RileyLinkBLEKit/PeripheralManagerError.swift
  281. Row(
  282. identifier: "PeripheralManagerError.unknownCharacteristic (recoverySuggestion)",
  283. message: "Make sure the device is nearby, and the issue should resolve automatically",
  284. role: "errorMessage",
  285. taxonomy: "N8",
  286. expected: .other("Make sure the device is nearby, and the issue should resolve automatically")
  287. ),
  288. // RileyLinkBLEKit/CommandSession.swift:97,99
  289. Row(
  290. identifier: "RileyLinkDeviceError.responseTimeout",
  291. message: "Pump did not respond in time",
  292. role: "errorMessage",
  293. taxonomy: "N8",
  294. expected: .other("Pump did not respond in time")
  295. ),
  296. // RileyLinkBLEKit/CommandSession.swift:103
  297. Row(
  298. identifier: "RileyLinkDeviceError.errorResponse",
  299. message: "RileyLink reported invalid param: <hex>",
  300. role: "errorMessage",
  301. taxonomy: "N9",
  302. expected: .other("RileyLink reported invalid param: <hex>")
  303. ),
  304. // RileyLinkBLEKit/CommandSession.swift:105
  305. Row(
  306. identifier: "RileyLinkDeviceError.errorResponse",
  307. message: "RileyLink reported unknown command: <hex>",
  308. role: "errorMessage",
  309. taxonomy: "N9",
  310. expected: .other("RileyLink reported unknown command: <hex>")
  311. ),
  312. // RileyLinkBLEKit/CommandSession.swift:136
  313. Row(
  314. identifier: "RileyLinkDeviceError.unsupportedCommand",
  315. message: "RileyLink firmware does not support the readRegister command",
  316. role: "errorMessage",
  317. taxonomy: "N9",
  318. expected: .other("RileyLink firmware does not support the readRegister command")
  319. ),
  320. // RileyLinkBLEKit/CommandSession.swift:143
  321. Row(
  322. identifier: "RileyLinkDeviceError.errorResponse",
  323. message: "Unsupported register: <register>",
  324. role: "errorMessage",
  325. taxonomy: "N9",
  326. expected: .other("Unsupported register: <register>")
  327. ),
  328. // RileyLinkBLEKit/CommandSession.swift:270
  329. Row(
  330. identifier: "RileyLinkDeviceError.unsupportedCommand",
  331. message: "RileyLink firmware does not support the setSWEncoding command",
  332. role: "errorMessage",
  333. taxonomy: "N9",
  334. expected: .other("RileyLink firmware does not support the setSWEncoding command")
  335. ),
  336. // RileyLinkBLEKit/CommandSession.swift:278
  337. Row(
  338. identifier: "RileyLinkDeviceError.unsupportedCommand",
  339. message: "RileyLink firmware does not support the Set Software Encoding error command",
  340. role: "errorMessage",
  341. taxonomy: "N9",
  342. expected: .other("RileyLink firmware does not support the Set Software Encoding error command")
  343. ),
  344. // RileyLinkBLEKit/CommandSession.swift:293
  345. Row(
  346. identifier: "RileyLinkDeviceError.unsupportedCommand",
  347. message: "RileyLink firmware does not support the getStatistics command",
  348. role: "errorMessage",
  349. taxonomy: "N9",
  350. expected: .other("RileyLink firmware does not support the getStatistics command")
  351. ),
  352. // RileyLinkBLEKit/CommandSession.swift:304
  353. Row(
  354. identifier: "RileyLinkDeviceError.unsupportedCommand",
  355. message: "RileyLink firmware does not support the setPreamble command",
  356. role: "errorMessage",
  357. taxonomy: "N9",
  358. expected: .other("RileyLink firmware does not support the setPreamble command")
  359. ),
  360. // RileyLinkKitUI/CommandResponseViewController.swift:25,51
  361. Row(
  362. identifier: "CommandResponseViewController (String(describing:))",
  363. message: "verbatim rendering of the thrown RileyLinkDeviceError",
  364. role: "errorMessage",
  365. taxonomy: "N9",
  366. expected: .other("verbatim rendering of the thrown RileyLinkDeviceError")
  367. ),
  368. // RileyLink/AuthenticationViewController.swift:44
  369. Row(
  370. identifier: "presentAlertControllerWithError (AuthenticationViewController)",
  371. message: "verification error's localizedDescription + recovery suggestion",
  372. role: "errorMessage",
  373. taxonomy: "N10",
  374. expected: .other("verification error's localizedDescription + recovery suggestion")
  375. ),
  376. // RileyLinkKitUI/RileyLinkDeviceTableViewController.swift:730
  377. Row(
  378. identifier: "UIAlertController (battery threshold action sheet)",
  379. message: "Battery level Alert",
  380. role: "alertTitle",
  381. taxonomy: "N13",
  382. expected: .other("Battery level Alert")
  383. ),
  384. // RileyLinkKitUI/RileyLinkDeviceTableViewController.swift:730
  385. Row(
  386. identifier: "UIAlertController (battery threshold action sheet, actions)",
  387. message: "OFF, 20%, 30%, 40%, 50%",
  388. role: "validation",
  389. taxonomy: "N13",
  390. expected: .other("OFF, 20%, 30%, 40%, 50%")
  391. ),
  392. // RileyLink/Extensions/UIViewController.swift:20,46
  393. Row(
  394. identifier: "presentAlertControllerWithError/Title (OK button)",
  395. message: "OK",
  396. role: "validation",
  397. taxonomy: "N13",
  398. expected: .other("OK")
  399. ),
  400. // RileyLink/KeychainManager.swift:78,111,152
  401. Row(
  402. identifier: "KeychainManagerError.add",
  403. message: "(no localized message; String(describing:) only, e.g. add(-26275))",
  404. role: "errorMessage",
  405. taxonomy: "N15",
  406. expected: .other("(no localized message; String(describing:) only, e.g. add(-26275))")
  407. ),
  408. // RileyLink/KeychainManager.swift:126,188
  409. Row(
  410. identifier: "KeychainManagerError.copy",
  411. message: "(no localized message; String(describing:) only, e.g. copy(-25300))",
  412. role: "errorMessage",
  413. taxonomy: "N15",
  414. expected: .other("(no localized message; String(describing:) only, e.g. copy(-25300))")
  415. ),
  416. // RileyLink/KeychainManager.swift:91
  417. Row(
  418. identifier: "KeychainManagerError.delete",
  419. message: "(no localized message; String(describing:) only, e.g. delete(-25300))",
  420. role: "errorMessage",
  421. taxonomy: "N15",
  422. expected: .other("(no localized message; String(describing:) only, e.g. delete(-25300))")
  423. ),
  424. // RileyLink/KeychainManager.swift:130,199
  425. Row(
  426. identifier: "KeychainManagerError.unknownResult",
  427. message: "(no localized message; String(describing:) only, e.g. unknownResult)",
  428. role: "errorMessage",
  429. taxonomy: "N15",
  430. expected: .other("(no localized message; String(describing:) only, e.g. unknownResult)")
  431. )
  432. ]
  433. @Test("each (identifier, message) classifies as pinned", arguments: rows) func eachMessageClassifiesAsPinned(row: Row) {
  434. #expect(TrioAlertClassifier.categorize(error: StubError(description: row.message)) == row.expected)
  435. }
  436. // MARK: - Classifier-coverage gap ratchet
  437. /// Keys ("identifier — message") for every N8 connectivity/communication
  438. /// message that SHOULD reach `.commsTransient` but currently falls to
  439. /// `.other` because its natural-language text contains none of the
  440. /// classifier's no-space tokens ("notconnected" / "noresponse" / "timeout" /
  441. /// "communication" / "comms" / "rssi"). Each entry documents WHY the tokens
  442. /// miss:
  443. ///
  444. /// - "Unknown characteristic: %@" (PeripheralManager.swift:174): prose has
  445. /// no connectivity token. SHOULD be .commsTransient.
  446. /// - "RileyLink is not connected" (PeripheralManager.swift:195): "not
  447. /// connected" has a SPACE; token is "notconnected" (no space) -> misses.
  448. /// SHOULD be .commsTransient.
  449. /// - "RileyLink did not respond in time" (PeripheralManager.swift:225):
  450. /// "did not respond" != "noresponse"; "in time" != "timeout". Misses.
  451. /// SHOULD be .commsTransient.
  452. /// - "underlying CoreBluetooth error.localizedDescription"
  453. /// (PeripheralManager.swift:229): wrapped CB text, no token. SHOULD be
  454. /// .commsTransient.
  455. /// - "Characteristic value was empty"
  456. /// (PeripheralManager+RileyLink.swift:387): no token. SHOULD be
  457. /// .commsTransient.
  458. /// - "Unknown service: %@" (PeripheralManagerError.swift:18, dead code):
  459. /// no token. SHOULD be .commsTransient.
  460. /// - "wrapped PeripheralManagerError description"
  461. /// (PeripheralManager+RileyLink.swift:238,291,365,396,590,658): forwards
  462. /// a BLE-level message that itself misses. SHOULD be .commsTransient.
  463. /// - "Response %@ is invalid"
  464. /// (PeripheralManager+RileyLink.swift:391,595,662; CommandSession.swift:101,122):
  465. /// no token. SHOULD be .commsTransient.
  466. /// - "RileyLink command did not respond"
  467. /// (PeripheralManager+RileyLink.swift:588): "did not respond" !=
  468. /// "noresponse". Misses. SHOULD be .commsTransient.
  469. /// - "RileyLink may need to be turned off and back on"
  470. /// (PeripheralManager+RileyLink.swift:588, recoverySuggestion): no token.
  471. /// SHOULD be .commsTransient.
  472. /// - "The RileyLink was temporarily disconnected"
  473. /// (PeripheralManagerError.swift, failureReason): "disconnected" !=
  474. /// "notconnected". Misses. SHOULD be .commsTransient.
  475. /// - "Make sure the device is nearby, and the issue should resolve
  476. /// automatically" (PeripheralManagerError.swift, recoverySuggestion): no
  477. /// token. SHOULD be .commsTransient.
  478. /// - "Pump did not respond in time" (CommandSession.swift:97,99): "did not
  479. /// respond" != "noresponse"; "in time" != "timeout". Misses. SHOULD be
  480. /// .commsTransient.
  481. ///
  482. /// N9/N10/N13/N15 rows correctly land in `.other` (no taxonomy bucket) and
  483. /// are NOT gaps. This set stays green now and FAILS when the classifier is
  484. /// improved to catch this prose (prompting a ratchet update).
  485. static let classifierCoverageGaps: Set<String> = [
  486. "PeripheralManagerError.unknownCharacteristic — Unknown characteristic: %@",
  487. "PeripheralManagerError.notReady — RileyLink is not connected",
  488. "PeripheralManagerError.timeout — RileyLink did not respond in time",
  489. "PeripheralManagerError.cbPeripheralError — underlying CoreBluetooth error.localizedDescription",
  490. "PeripheralManagerError.emptyValue — Characteristic value was empty",
  491. "PeripheralManagerError.unknownService — Unknown service: %@",
  492. "RileyLinkDeviceError.peripheralManagerError — wrapped PeripheralManagerError description",
  493. "RileyLinkDeviceError.invalidResponse — Response %@ is invalid",
  494. "RileyLinkDeviceError.commandsBlocked — RileyLink command did not respond",
  495. "RileyLinkDeviceError.commandsBlocked (recoverySuggestion) — RileyLink may need to be turned off and back on",
  496. "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason) — The RileyLink was temporarily disconnected",
  497. "PeripheralManagerError.unknownCharacteristic (recoverySuggestion) — Make sure the device is nearby, and the issue should resolve automatically",
  498. "RileyLinkDeviceError.responseTimeout — Pump did not respond in time"
  499. ]
  500. @Test("classifier-coverage gaps are exactly as documented") func classifierCoverageGapsAreExact() {
  501. // A row is a gap when the audit assigns it a real taxonomy bucket
  502. // (commsTransient) yet the classifier currently yields .other(message).
  503. let gapBuckets: [String: String] = [
  504. "PeripheralManagerError.unknownCharacteristic": "commsTransient",
  505. "PeripheralManagerError.notReady": "commsTransient",
  506. "PeripheralManagerError.timeout": "commsTransient",
  507. "PeripheralManagerError.cbPeripheralError": "commsTransient",
  508. "PeripheralManagerError.emptyValue": "commsTransient",
  509. "PeripheralManagerError.unknownService": "commsTransient",
  510. "RileyLinkDeviceError.peripheralManagerError": "commsTransient",
  511. "RileyLinkDeviceError.invalidResponse": "commsTransient",
  512. "RileyLinkDeviceError.commandsBlocked": "commsTransient",
  513. "RileyLinkDeviceError.commandsBlocked (recoverySuggestion)": "commsTransient",
  514. "PeripheralManagerError.unknownCharacteristic (recoverySuggestion failureReason)": "commsTransient",
  515. "PeripheralManagerError.unknownCharacteristic (recoverySuggestion)": "commsTransient",
  516. "RileyLinkDeviceError.responseTimeout": "commsTransient"
  517. ]
  518. let recomputed = Set(
  519. Self.rows
  520. .filter { $0.expected == .other($0.message) && gapBuckets[$0.identifier] != nil }
  521. .map { "\($0.identifier) — \($0.message)" }
  522. )
  523. #expect(recomputed == Self.classifierCoverageGaps)
  524. }
  525. }