MedtrumKitAlertEmissionTests.swift 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884
  1. import Foundation
  2. import LoopKit
  3. import Testing
  4. @testable import Trio
  5. /// Manager-emission pins for **MedtrumKit** (`managerIdentifier` family
  6. /// `"Medtrum"`). Rows come from the synthesis audit over the bundled pump
  7. /// managers (`managers`/`pump`/`MedtrumKit`).
  8. ///
  9. /// What this suite pins:
  10. /// - `registryBehaviorIsPinned`: the CURRENT `AlertCatalogRegistry.lookup`
  11. /// level for every alert identifier the Medtrum registry block keys on
  12. /// (the exact strings, including the misspelled double-s
  13. /// `patch-occlussion`). These are GREEN assertions of present behavior,
  14. /// not of the ideal.
  15. /// - `classifierCategoryIsPinned`: the CURRENT `TrioAlertClassifier`
  16. /// categorization for the one confidently-statable error that reaches
  17. /// `APSManager.processError` via a PumpManager completion handler
  18. /// (`PumpManagerError.uncertainDelivery`).
  19. /// - `knownEscalationGapsAreExactlyAsDocumented`: a ratchet over the
  20. /// documented gap set; FAILS when a gap is fixed, prompting an update.
  21. ///
  22. /// One-line gap summary: MedtrumKit issues NO LoopKit Alerts — every pump
  23. /// alarm is a `UNUserNotificationCenter` local notification, so
  24. /// `AlertCatalogRegistry.lookup` is never exercised by real emissions and
  25. /// the carefully-built Medtrum registry block escalates nothing today.
  26. /// Taxonomy-Critical conditions (occlusion/fault/reservoir-empty/daily &
  27. /// hourly suspend) reach the user only at the UN-notification default level
  28. /// (treated as `.timeSensitive` — unknown -> assume time-sensitive),
  29. /// below their `.critical` taxonomy mapping.
  30. @Suite("Trio Alert Emission: MedtrumKit") struct MedtrumKitAlertEmissionTests {
  31. private func id(_ manager: String, _ alertID: String) -> Alert.Identifier {
  32. Alert.Identifier(managerIdentifier: manager, alertIdentifier: alertID)
  33. }
  34. // MARK: - Registry behavior (CURRENT, must be green)
  35. /// `(alertIdentifier, currentRegistryLevel?)` exactly as the registry
  36. /// keys on them today. `nil` means no entry (pass-through).
  37. static let registryRows: [(alertID: String, level: Alert.InterruptionLevel?)] = [
  38. ("com.nightscout.medtrumkit.patch-occlussion", .critical), // NotificationManager.swift:61 / Registry:152 (double-s)
  39. ("com.nightscout.medtrumkit.patch-fault", .critical), // NotificationManager.swift:71 / Registry:154
  40. ("com.nightscout.medtrumkit.patch-empty", .critical), // NotificationManager.swift:81 / Registry:155
  41. ("com.nightscout.medtrumkit.patch-daily-limit", .timeSensitive), // NotificationManager.swift:38 / Registry:130
  42. ("com.nightscout.medtrumkit.patch-hourly-limit", .timeSensitive), // NotificationManager.swift:48 / Registry:138
  43. ("com.nightscout.medtrumkit.reservoir-low", .timeSensitive), // NotificationManager.swift:91 / Registry:156
  44. ("com.nightscout.medtrumkit.patch-expired", .active) // NotificationManager.swift:15 / Registry:122
  45. ]
  46. @Test(
  47. "registry behavior is pinned for every emitted alert",
  48. arguments: registryRows
  49. ) func registryBehaviorIsPinned(alertID: String, level: Alert.InterruptionLevel?) {
  50. #expect(AlertCatalogRegistry.lookup(id("Medtrum", alertID))?.interruptionLevel == level)
  51. }
  52. // MARK: - Classifier behavior (CURRENT, must be green)
  53. /// Errors handed back through a PumpManager completion handler feed
  54. /// `String(describing:)` into `TrioAlertClassifier.categorize`. A
  55. /// `CustomStringConvertible` stub reproduces that exact input.
  56. private struct StubError: Error, CustomStringConvertible {
  57. let description: String
  58. }
  59. @Test(
  60. "classifier category is pinned for completion-handler errors",
  61. arguments: [
  62. // MedtrumPumpManager.swift:992 emits PumpManagerError.uncertainDelivery;
  63. // String(describing:) == "uncertainDelivery" -> .deliveryUncertain (.critical).
  64. // Matches taxonomy N3 Uncertain Delivery -> Critical. Not a gap.
  65. ("uncertainDelivery", TrioAlertCategory.deliveryUncertain)
  66. ]
  67. ) func classifierCategoryIsPinned(describing: String, expected: TrioAlertCategory) {
  68. #expect(TrioAlertClassifier.categorize(error: StubError(description: describing)) == expected)
  69. }
  70. // MARK: - Known escalation gaps (ratchet)
  71. /// Alert identifiers where the EFFECTIVE current level is less severe
  72. /// than the taxonomy level (`isGap == true` in the audit). Every row
  73. /// is a gap today because MedtrumKit issues these as UN local
  74. /// notifications, never as LoopKit Alerts — so `AlertCatalogRegistry`
  75. /// is never consulted and the effective level is the UN-notification
  76. /// default (unknown -> assume `.timeSensitive`).
  77. ///
  78. /// SHOULD-have taxonomy levels (for when these are wired through
  79. /// `issueAlert`):
  80. /// - patch-occlussion: N1 Critical -> .critical (NotificationManager.swift:61)
  81. /// - patch-fault: N1 Critical -> .critical (NotificationManager.swift:71)
  82. /// - patch-empty: N4 Critical -> .critical (NotificationManager.swift:81)
  83. /// - patch-daily-limit: N2 Critical -> .critical; registry only .timeSensitive (NotificationManager.swift:38)
  84. /// - patch-hourly-limit:N2 Critical -> .critical; registry only .timeSensitive (NotificationManager.swift:48)
  85. /// - reservoir-low: F1 High -> .timeSensitive; registry already .timeSensitive,
  86. /// gap is ONLY the missing issueAlert wiring (NotificationManager.swift:91)
  87. /// - patch-expired: F3 Medium -> .timeSensitive; registry only .active (NotificationManager.swift:15)
  88. static let knownEscalationGaps: Set<String> = [
  89. "com.nightscout.medtrumkit.patch-occlussion",
  90. "com.nightscout.medtrumkit.patch-fault",
  91. "com.nightscout.medtrumkit.patch-empty",
  92. "com.nightscout.medtrumkit.patch-daily-limit",
  93. "com.nightscout.medtrumkit.patch-hourly-limit",
  94. "com.nightscout.medtrumkit.reservoir-low",
  95. "com.nightscout.medtrumkit.patch-expired"
  96. ]
  97. /// `(alertID, effectiveLevel, taxonomyLevel)` — `effectiveLevel` is the
  98. /// registry level when present, else (no Alert issued) the
  99. /// UN-notification default, which we treat as `.timeSensitive`
  100. /// (unknown -> assume time-sensitive). A gap exists when
  101. /// `effectiveLevel` is less severe than `taxonomyLevel`. Because no
  102. /// LoopKit Alert is issued, the effective level for every row is the
  103. /// UN default regardless of the registry value.
  104. static let gapTable: [(alertID: String, effective: Alert.InterruptionLevel, taxonomy: Alert.InterruptionLevel)] = [
  105. ("com.nightscout.medtrumkit.patch-occlussion", .timeSensitive, .critical),
  106. ("com.nightscout.medtrumkit.patch-fault", .timeSensitive, .critical),
  107. ("com.nightscout.medtrumkit.patch-empty", .timeSensitive, .critical),
  108. ("com.nightscout.medtrumkit.patch-daily-limit", .timeSensitive, .critical),
  109. ("com.nightscout.medtrumkit.patch-hourly-limit", .timeSensitive, .critical),
  110. ("com.nightscout.medtrumkit.reservoir-low", .timeSensitive, .timeSensitive),
  111. ("com.nightscout.medtrumkit.patch-expired", .timeSensitive, .timeSensitive)
  112. ]
  113. /// Severity rank for comparing interruption levels (higher == more severe).
  114. private static func severity(_ level: Alert.InterruptionLevel) -> Int {
  115. switch level {
  116. case .active: return 0
  117. case .timeSensitive: return 1
  118. case .critical: return 2
  119. @unknown default: return 0
  120. }
  121. }
  122. @Test("known escalation gaps are exactly as documented") func knownEscalationGapsAreExactlyAsDocumented() {
  123. // Recompute the gap set from the table: a gap is an emission whose
  124. // effective level is strictly less severe than its taxonomy level.
  125. // reservoir-low / patch-expired tie on level but remain documented
  126. // gaps because no Alert is issued (registry override never fires);
  127. // they are listed in `knownEscalationGaps` directly. Reconcile the
  128. // strictly-less-severe rows against the documented set, accounting
  129. // for the architectural (no-issueAlert) gaps.
  130. let strictlyLessSevere = Set(
  131. Self.gapTable
  132. .filter { Self.severity($0.effective) < Self.severity($0.taxonomy) }
  133. .map(\.alertID)
  134. )
  135. // Every strictly-less-severe row must be documented.
  136. #expect(strictlyLessSevere.isSubset(of: Self.knownEscalationGaps))
  137. // The full documented set is the audit's gap set (includes the
  138. // tie-level architectural gaps reservoir-low + patch-expired).
  139. let auditedGaps = Set(Self.gapTable.map(\.alertID))
  140. #expect(Self.knownEscalationGaps == auditedGaps)
  141. }
  142. }
  143. /// SPEC — Message-text classification catalog for **MedtrumKit**
  144. /// (`managers`/`pump`/`MedtrumKit`).
  145. ///
  146. /// MedtrumKit does NOT use the LoopKit `Alert`/`AlertIssuer` system at all:
  147. /// there are zero `issueAlert`/`Alert(identifier:)` calls. Every pump alarm
  148. /// surfaces via `UNUserNotificationCenter` local notifications, LoopKit
  149. /// status surfaces (`pumpStatusHighlight`, `didError`,
  150. /// `completion(.failure)`), and in-app SwiftUI text / published
  151. /// `errorMessage` fields. So there are no real LoopKit `alertIdentifier`s to
  152. /// attach — the `identifier` column below is the error enum case name /
  153. /// source symbol per the synthesis instructions.
  154. ///
  155. /// IMPORTANT: in production the classifier is fed `String(describing: error)`
  156. /// (i.e. the error *case name*, e.g. `"uncertainDelivery"`), NOT these
  157. /// user-facing display strings. This suite instead feeds each emission's
  158. /// EXACT display string through a `StubError` so we can pin how
  159. /// `TrioAlertClassifier`'s substring matcher handles the real natural-language
  160. /// text a user would see. Because the classifier matches compressed/
  161. /// concatenated tokens ("occlusion", "reservoirempty", "lowreservoir",
  162. /// "fault", "timeout", …) while the Medtrum strings are prose, almost every
  163. /// meaningful alarm string falls through to `.other`. Only a handful match:
  164. /// occlusion via PatchState "Occlusion", fault via "Fault"/"in Fault state",
  165. /// and the BLE "timeout" strings. Notably the occlusion notification body is
  166. /// misspelled "occlussion" (double-s), which does NOT contain "occlusion" and
  167. /// thus mis-routes to `.other`.
  168. @Suite("Trio Alert Emission: MedtrumKit — Classification") struct MedtrumKitMessageClassificationTests {
  169. /// `String(describing:)` returns `description` verbatim, reproducing the
  170. /// exact text the classifier's substring matcher sees.
  171. private struct StubError: Error, CustomStringConvertible {
  172. let description: String
  173. }
  174. struct Row {
  175. let identifier: String
  176. let message: String
  177. let role: String
  178. let taxonomy: String
  179. let expected: TrioAlertCategory
  180. }
  181. /// Every distinct user-facing emission, paired with the classifier
  182. /// category it currently resolves to. `currentCategory == "other"` pins
  183. /// `.other(message)` with the EXACT original (non-lowercased) string.
  184. static let rows: [Row] = [
  185. // MedtrumKit/PumpManager/NotificationManager.swift:15
  186. Row(
  187. identifier: "NotificationManager.Identifiers.patchExpiredNotification",
  188. message: "Your patch will expire soon! / Your patch has %lld hours left",
  189. role: "notificationBody",
  190. taxonomy: "F3",
  191. expected: .other("Your patch will expire soon! / Your patch has %lld hours left")
  192. ),
  193. // MedtrumKit/PumpManager/NotificationManager.swift:38
  194. Row(
  195. identifier: "NotificationManager.Identifiers.patchDailyMaxNotification",
  196. message: "Insulin has been suspended! / Your patch has reached its daily maximum!",
  197. role: "notificationBody",
  198. taxonomy: "N2",
  199. expected: .other("Insulin has been suspended! / Your patch has reached its daily maximum!")
  200. ),
  201. // MedtrumKit/PumpManager/NotificationManager.swift:48
  202. Row(
  203. identifier: "NotificationManager.Identifiers.patchHourlyMaxNotification",
  204. message: "Insulin has been suspended! / Your patch has reached its hourly maximum!",
  205. role: "notificationBody",
  206. taxonomy: "N2",
  207. expected: .other("Insulin has been suspended! / Your patch has reached its hourly maximum!")
  208. ),
  209. // MedtrumKit/PumpManager/NotificationManager.swift:61
  210. // Misspelled "occlussion" (double-s) does NOT contain "occlusion" -> .other.
  211. Row(
  212. identifier: "NotificationManager.Identifiers.occlusionNotification",
  213. message: "Replace your patch now! / Your patch has detected an occlussion!",
  214. role: "notificationBody",
  215. taxonomy: "N1",
  216. expected: .other("Replace your patch now! / Your patch has detected an occlussion!")
  217. ),
  218. // MedtrumKit/PumpManager/NotificationManager.swift:71
  219. // Contains "Fault" -> .hardwareFault.
  220. Row(
  221. identifier: "NotificationManager.Identifiers.patchFaultNotification",
  222. message: "Replace your patch now! / Your patch is in Fault state!",
  223. role: "notificationBody",
  224. taxonomy: "N1",
  225. expected: .hardwareFault
  226. ),
  227. // MedtrumKit/PumpManager/NotificationManager.swift:81
  228. Row(
  229. identifier: "NotificationManager.Identifiers.reservoirEmptyNotification",
  230. message: "Replace your patch now! / Your patch is out of insulin!",
  231. role: "notificationBody",
  232. taxonomy: "N4",
  233. expected: .other("Replace your patch now! / Your patch is out of insulin!")
  234. ),
  235. // MedtrumKit/PumpManager/NotificationManager.swift:91
  236. Row(
  237. identifier: "NotificationManager.Identifiers.reservoirEmptyNotification",
  238. message: "Reservoir low (%lld U) / Your patch is running out of insulin!",
  239. role: "notificationBody",
  240. taxonomy: "F1",
  241. expected: .other("Reservoir low (%lld U) / Your patch is running out of insulin!")
  242. ),
  243. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:76
  244. Row(
  245. identifier: "PumpStatusHighlight",
  246. message: "No patch",
  247. role: "errorMessage",
  248. taxonomy: "N14",
  249. expected: .other("No patch")
  250. ),
  251. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:85
  252. Row(
  253. identifier: "PumpStatusHighlight",
  254. message: "No Insulin",
  255. role: "errorMessage",
  256. taxonomy: "N4",
  257. expected: .other("No Insulin")
  258. ),
  259. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:91
  260. Row(
  261. identifier: "PumpStatusHighlight",
  262. message: "Insulin Suspended",
  263. role: "errorMessage",
  264. taxonomy: "N2",
  265. expected: .other("Insulin Suspended")
  266. ),
  267. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:100
  268. Row(
  269. identifier: "PumpStatusHighlight",
  270. message: "Patch expired. Basal only.",
  271. role: "errorMessage",
  272. taxonomy: "N6",
  273. expected: .other("Patch expired. Basal only.")
  274. ),
  275. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:109
  276. Row(
  277. identifier: "PumpStatusHighlight",
  278. message: "Signal Loss",
  279. role: "errorMessage",
  280. taxonomy: "N8",
  281. expected: .other("Signal Loss")
  282. ),
  283. // MedtrumKitUI/MedtrumKitPumpManager+UI.swift:118
  284. Row(
  285. identifier: "PumpStatusHighlight",
  286. message: "Patch Error",
  287. role: "errorMessage",
  288. taxonomy: "N1",
  289. expected: .other("Patch Error")
  290. ),
  291. // MedtrumKit/PumpManager/PeripheralManager.swift:126
  292. Row(
  293. identifier: "MedtrumConnectError.failedToCompleteAuthorizationFlow",
  294. message: "invalid response",
  295. role: "errorMessage",
  296. taxonomy: "N10",
  297. expected: .other("invalid response")
  298. ),
  299. // MedtrumKit/PumpManager/PeripheralManager.swift:209
  300. Row(
  301. identifier: "MedtrumConnectError.failedToDiscoverServices",
  302. message: "No Medtrum service found - <discovered service UUIDs>",
  303. role: "errorMessage",
  304. taxonomy: "N8",
  305. expected: .other("No Medtrum service found - <discovered service UUIDs>")
  306. ),
  307. // MedtrumKit/PumpManager/PeripheralManager.swift:231
  308. Row(
  309. identifier: "MedtrumConnectError.failedToDiscoverCharacteristics",
  310. message: "Failed to discover read, write or config characteristic - <UUIDs>",
  311. role: "errorMessage",
  312. taxonomy: "N8",
  313. expected: .other("Failed to discover read, write or config characteristic - <UUIDs>")
  314. ),
  315. // MedtrumKit/PumpManager/BluetoothManager.swift:76,162,333
  316. // Contains "Timeout" -> .commsTransient.
  317. Row(
  318. identifier: "MedtrumConnectError.failedToConnectToDevice",
  319. message: "Failed to connect to patch -> Timeout reached",
  320. role: "errorMessage",
  321. taxonomy: "N8",
  322. expected: .commsTransient
  323. ),
  324. // MedtrumKit/PumpManager/BluetoothManager.swift:114,128; MedtrumPumpManager.swift:1009
  325. Row(
  326. identifier: "MedtrumConnectError.failedToFindDevice",
  327. message: "Failed to connect to patch",
  328. role: "errorMessage",
  329. taxonomy: "N8",
  330. expected: .other("Failed to connect to patch")
  331. ),
  332. // MedtrumKit/PumpManager/BluetoothManager.swift:45
  333. Row(
  334. identifier: "MedtrumScanError.invalidBluetoothState",
  335. message: "Invalid Bluetooth state: <state>",
  336. role: "errorMessage",
  337. taxonomy: "N8",
  338. expected: .other("Invalid Bluetooth state: <state>")
  339. ),
  340. // MedtrumKit/PumpManager/PeripheralManager.swift:39,71
  341. Row(
  342. identifier: "MedtrumWriteError.noData",
  343. message: "No data",
  344. role: "errorMessage",
  345. taxonomy: "N8",
  346. expected: .other("No data")
  347. ),
  348. // MedtrumKit/PumpManager/PeripheralManager.swift:58
  349. Row(
  350. identifier: "MedtrumWriteError.noWriteCharacteristic",
  351. message: "No write characteristic. Device might be disconnected",
  352. role: "errorMessage",
  353. taxonomy: "N8",
  354. expected: .other("No write characteristic. Device might be disconnected")
  355. ),
  356. // MedtrumKit/PumpManager/PeripheralManager.swift:99
  357. // Contains "timeout" -> .commsTransient.
  358. Row(
  359. identifier: "MedtrumWriteError.timeout",
  360. message: "A command timeout is hit",
  361. role: "errorMessage",
  362. taxonomy: "N8",
  363. expected: .commsTransient
  364. ),
  365. // MedtrumKit/PumpManager/PeripheralManager.swift:314,321
  366. Row(
  367. identifier: "MedtrumWriteError.invalidData",
  368. message: "Invalid data received",
  369. role: "errorMessage",
  370. taxonomy: "N8",
  371. expected: .other("Invalid data received")
  372. ),
  373. // MedtrumKit/PumpManager/BluetoothManager.swift:170
  374. Row(
  375. identifier: "MedtrumWriteError.noManager",
  376. message: "No peripheral manager",
  377. role: "errorMessage",
  378. taxonomy: "N8",
  379. expected: .other("No peripheral manager")
  380. ),
  381. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:992
  382. // "uncertain delivery" (with a space) != "uncertaindelivery" -> .other.
  383. Row(
  384. identifier: "PumpManagerError.uncertainDelivery",
  385. message: "uncertain delivery",
  386. role: "errorMessage",
  387. taxonomy: "N3",
  388. expected: .other("uncertain delivery")
  389. ),
  390. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:281,405
  391. Row(
  392. identifier: "MedtrumConnectError.isBolussing",
  393. message: "Bolus issue. Patch is already bolussing",
  394. role: "errorMessage",
  395. taxonomy: "N9",
  396. expected: .other("Bolus issue. Patch is already bolussing")
  397. ),
  398. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:287
  399. Row(
  400. identifier: "MedtrumConnectError.isSuspended",
  401. message: "Bolus issue. Patch is suspended. Resume delivery",
  402. role: "errorMessage",
  403. taxonomy: "N9",
  404. expected: .other("Bolus issue. Patch is suspended. Resume delivery")
  405. ),
  406. // MedtrumKit/PumpManager/PeripheralManager.swift:53
  407. Row(
  408. identifier: "MedtrumWriteError.alreadyRunning",
  409. message: "A command is already running",
  410. role: "errorMessage",
  411. taxonomy: "N9",
  412. expected: .other("A command is already running")
  413. ),
  414. // MedtrumKit/PumpManager/PeripheralManager.swift:311
  415. Row(
  416. identifier: "MedtrumWriteError.invalidResponse",
  417. message: "Invalid response code: <code>",
  418. role: "errorMessage",
  419. taxonomy: "N9",
  420. expected: .other("Invalid response code: <code>")
  421. ),
  422. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:630
  423. Row(
  424. identifier: "MedtrumPrimePatchError.noKnownPumpBase",
  425. message: "No known pump base found.",
  426. role: "errorMessage",
  427. taxonomy: "N12",
  428. expected: .other("No known pump base found.")
  429. ),
  430. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:645
  431. Row(
  432. identifier: "MedtrumPrimePatchError.connectionFailure",
  433. message: "Failed to connect to pump base: <reason>",
  434. role: "errorMessage",
  435. taxonomy: "N12",
  436. expected: .other("Failed to connect to pump base: <reason>")
  437. ),
  438. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:659
  439. Row(
  440. identifier: "MedtrumPrimePatchError.unknownError",
  441. message: "Unknown error: <write error>",
  442. role: "errorMessage",
  443. taxonomy: "N12",
  444. expected: .other("Unknown error: <write error>")
  445. ),
  446. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:674
  447. Row(
  448. identifier: "MedtrumActivatePatchError.connectionFailure",
  449. message: "Connection failure: <reason>",
  450. role: "errorMessage",
  451. taxonomy: "N12",
  452. expected: .other("Connection failure: <reason>")
  453. ),
  454. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:698
  455. Row(
  456. identifier: "MedtrumActivatePatchError.unknownError",
  457. message: "Unknown error: <error description>",
  458. role: "errorMessage",
  459. taxonomy: "N12",
  460. expected: .other("Unknown error: <error description>")
  461. ),
  462. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:704
  463. Row(
  464. identifier: "MedtrumActivatePatchError.unknownError",
  465. message: "Unknown error: Failed to parse response...",
  466. role: "errorMessage",
  467. taxonomy: "N12",
  468. expected: .other("Unknown error: Failed to parse response...")
  469. ),
  470. // MedtrumKit/PumpManager/BluetoothManager.swift:41
  471. Row(
  472. identifier: "MedtrumScanError.noSerialNumberAvailable",
  473. message: "No Serial number setup. Please complete activation flow...",
  474. role: "errorMessage",
  475. taxonomy: "N12",
  476. expected: .other("No Serial number setup. Please complete activation flow...")
  477. ),
  478. // MedtrumKitUI/ViewModels/Onboarding/PumpBaseSettingsViewModel.swift:44
  479. Row(
  480. identifier: "PumpBaseSettingsViewModel.errorMessage",
  481. message: "No pump manager available",
  482. role: "validation",
  483. taxonomy: "N12",
  484. expected: .other("No pump manager available")
  485. ),
  486. // MedtrumKitUI/ViewModels/Settings/PatchSettingsViewModel.swift:79; DeactivatePatchViewModel.swift:23,40,77
  487. Row(
  488. identifier: "PatchSettingsViewModel.errorMessage",
  489. message: "Authentication failure",
  490. role: "errorMessage",
  491. taxonomy: "N10",
  492. expected: .other("Authentication failure")
  493. ),
  494. // MedtrumKitUI/AuthorizeBiometrics.swift:11-13
  495. Row(
  496. identifier: "AuthorizeBiometrics.evaluatePolicyReason",
  497. message: "We need to unlock your data.",
  498. role: "notificationBody",
  499. taxonomy: "N10",
  500. expected: .other("We need to unlock your data.")
  501. ),
  502. // MedtrumKit/PumpManager/MedtrumPumpManager.swift:585
  503. Row(
  504. identifier: "NSError.syncBasalRateSchedule",
  505. message: "Basal schedule is empty...",
  506. role: "validation",
  507. taxonomy: "N13",
  508. expected: .other("Basal schedule is empty...")
  509. ),
  510. // MedtrumKitUI/ViewModels/Onboarding/PumpBaseSettingsViewModel.swift:32
  511. Row(
  512. identifier: "PumpBaseSettingsViewModel.errorMessage",
  513. message: "Serial Number is too short",
  514. role: "validation",
  515. taxonomy: "N13",
  516. expected: .other("Serial Number is too short")
  517. ),
  518. // MedtrumKitUI/ViewModels/Onboarding/PumpBaseSettingsViewModel.swift:38
  519. Row(
  520. identifier: "PumpBaseSettingsViewModel.errorMessage",
  521. message: "Serial Number is invalid hex format",
  522. role: "validation",
  523. taxonomy: "N13",
  524. expected: .other("Serial Number is invalid hex format")
  525. ),
  526. // MedtrumKitUI/ViewModels/Onboarding/PumpBaseSettingsViewModel.swift:55
  527. Row(
  528. identifier: "PumpBaseSettingsViewModel.errorMessage",
  529. message: "Incorrect serial number received",
  530. role: "validation",
  531. taxonomy: "N13",
  532. expected: .other("Incorrect serial number received")
  533. ),
  534. // MedtrumKit/Packets/Enums/PatchState.swift:73
  535. Row(
  536. identifier: "PatchState.noCalibration",
  537. message: "No Calibration",
  538. role: "errorMessage",
  539. taxonomy: "N11",
  540. expected: .other("No Calibration")
  541. ),
  542. // MedtrumKit/Packets/Enums/PatchState.swift:31
  543. Row(
  544. identifier: "PatchState.none",
  545. message: "None",
  546. role: "errorMessage",
  547. taxonomy: "N14",
  548. expected: .other("None")
  549. ),
  550. // MedtrumKit/Packets/Enums/PatchState.swift:33
  551. Row(
  552. identifier: "PatchState.idle",
  553. message: "Idle",
  554. role: "errorMessage",
  555. taxonomy: "N14",
  556. expected: .other("Idle")
  557. ),
  558. // MedtrumKit/Packets/Enums/PatchState.swift:35
  559. Row(
  560. identifier: "PatchState.filled",
  561. message: "Filled",
  562. role: "errorMessage",
  563. taxonomy: "N14",
  564. expected: .other("Filled")
  565. ),
  566. // MedtrumKit/Packets/Enums/PatchState.swift:37
  567. Row(
  568. identifier: "PatchState.priming",
  569. message: "Priming",
  570. role: "errorMessage",
  571. taxonomy: "N14",
  572. expected: .other("Priming")
  573. ),
  574. // MedtrumKit/Packets/Enums/PatchState.swift:39
  575. Row(
  576. identifier: "PatchState.primed",
  577. message: "Primed",
  578. role: "errorMessage",
  579. taxonomy: "N14",
  580. expected: .other("Primed")
  581. ),
  582. // MedtrumKit/Packets/Enums/PatchState.swift:41
  583. Row(
  584. identifier: "PatchState.ejecting",
  585. message: "Ejecting",
  586. role: "errorMessage",
  587. taxonomy: "N14",
  588. expected: .other("Ejecting")
  589. ),
  590. // MedtrumKit/Packets/Enums/PatchState.swift:43
  591. Row(
  592. identifier: "PatchState.ejected",
  593. message: "Ejected",
  594. role: "errorMessage",
  595. taxonomy: "N14",
  596. expected: .other("Ejected")
  597. ),
  598. // MedtrumKit/Packets/Enums/PatchState.swift:46
  599. Row(
  600. identifier: "PatchState.active",
  601. message: "Active",
  602. role: "errorMessage",
  603. taxonomy: "N14",
  604. expected: .other("Active")
  605. ),
  606. // MedtrumKit/Packets/Enums/PatchState.swift:49
  607. Row(
  608. identifier: "PatchState.lowBgSuspended",
  609. message: "Suspended - Low BG",
  610. role: "errorMessage",
  611. taxonomy: "N2",
  612. expected: .other("Suspended - Low BG")
  613. ),
  614. // MedtrumKit/Packets/Enums/PatchState.swift:51
  615. Row(
  616. identifier: "PatchState.autoSuspended",
  617. message: "Suspended - Auto",
  618. role: "errorMessage",
  619. taxonomy: "N2",
  620. expected: .other("Suspended - Auto")
  621. ),
  622. // MedtrumKit/Packets/Enums/PatchState.swift:53
  623. Row(
  624. identifier: "PatchState.hourlyMaxSuspended",
  625. message: "Suspended - Hourly Max",
  626. role: "errorMessage",
  627. taxonomy: "N2",
  628. expected: .other("Suspended - Hourly Max")
  629. ),
  630. // MedtrumKit/Packets/Enums/PatchState.swift:55
  631. Row(
  632. identifier: "PatchState.dailyMaxSuspended",
  633. message: "Suspended - Daily Max",
  634. role: "errorMessage",
  635. taxonomy: "N2",
  636. expected: .other("Suspended - Daily Max")
  637. ),
  638. // MedtrumKit/Packets/Enums/PatchState.swift:57
  639. Row(
  640. identifier: "PatchState.suspended",
  641. message: "Suspended",
  642. role: "errorMessage",
  643. taxonomy: "N2",
  644. expected: .other("Suspended")
  645. ),
  646. // MedtrumKit/Packets/Enums/PatchState.swift:59
  647. Row(
  648. identifier: "PatchState.paused",
  649. message: "Paused",
  650. role: "errorMessage",
  651. taxonomy: "N2",
  652. expected: .other("Paused")
  653. ),
  654. // MedtrumKit/Packets/Enums/PatchState.swift:61
  655. // Contains "Occlusion" -> .occlusion.
  656. Row(
  657. identifier: "PatchState.occlusion",
  658. message: "Occlusion",
  659. role: "errorMessage",
  660. taxonomy: "N1",
  661. expected: .occlusion
  662. ),
  663. // MedtrumKit/Packets/Enums/PatchState.swift:63
  664. Row(
  665. identifier: "PatchState.expired",
  666. message: "Expired",
  667. role: "errorMessage",
  668. taxonomy: "N6",
  669. expected: .other("Expired")
  670. ),
  671. // MedtrumKit/Packets/Enums/PatchState.swift:65
  672. Row(
  673. identifier: "PatchState.reservoirEmpty",
  674. message: "Reservoir Empty",
  675. role: "errorMessage",
  676. taxonomy: "N4",
  677. expected: .other("Reservoir Empty")
  678. ),
  679. // MedtrumKit/Packets/Enums/PatchState.swift:69
  680. // Contains "Fault" -> .hardwareFault.
  681. Row(
  682. identifier: "PatchState.baseFault",
  683. message: "Fault",
  684. role: "errorMessage",
  685. taxonomy: "N1",
  686. expected: .hardwareFault
  687. ),
  688. // MedtrumKit/Packets/Enums/PatchState.swift:71
  689. Row(
  690. identifier: "PatchState.batteryOut",
  691. message: "Battery Empty",
  692. role: "errorMessage",
  693. taxonomy: "N5",
  694. expected: .other("Battery Empty")
  695. ),
  696. // MedtrumKit/Packets/Enums/PatchState.swift:75
  697. Row(
  698. identifier: "PatchState.stopped",
  699. message: "Stopped",
  700. role: "errorMessage",
  701. taxonomy: "N14",
  702. expected: .other("Stopped")
  703. ),
  704. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:85-91
  705. Row(
  706. identifier: "MedtrumKitSettings.gracePeriodBanner",
  707. message: "Change your Patch now. Insulin delivery will stop in %@ or when no more insulin remains.",
  708. role: "alertBody",
  709. taxonomy: "F3",
  710. expected: .other("Change your Patch now. Insulin delivery will stop in %@ or when no more insulin remains.")
  711. ),
  712. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:96-107
  713. Row(
  714. identifier: "MedtrumKitSettings.hourlyMaxBanner",
  715. message: "Alert: Hourly max insulin / Patch is suspended. Limit of %lld U exceeded. If you increase the limit, you can clear the alert now. If you wait, patch will resume when enough time passes. / Clear alert",
  716. role: "alertBody",
  717. taxonomy: "N2",
  718. expected: .other(
  719. "Alert: Hourly max insulin / Patch is suspended. Limit of %lld U exceeded. If you increase the limit, you can clear the alert now. If you wait, patch will resume when enough time passes. / Clear alert"
  720. )
  721. ),
  722. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:126-137
  723. Row(
  724. identifier: "MedtrumKitSettings.dailyMaxBanner",
  725. message: "Alert: Daily max insulin / Patch is suspended. Limit of %lld U exceeded. If you increase the limit, you can clear the alert now. If you wait, patch will resume when enough time passes. / Clear alert",
  726. role: "alertBody",
  727. taxonomy: "N2",
  728. expected: .other(
  729. "Alert: Daily max insulin / Patch is suspended. Limit of %lld U exceeded. If you increase the limit, you can clear the alert now. If you wait, patch will resume when enough time passes. / Clear alert"
  730. )
  731. ),
  732. // MedtrumKitUI/Views/Onboarding/PatchDeactivationView.swift:43-48
  733. Row(
  734. identifier: "PatchDeactivationView.confirmAlert",
  735. message: "Are you sure? / It is recommended to deactivate first / Confirm",
  736. role: "alertBody",
  737. taxonomy: "N14",
  738. expected: .other("Are you sure? / It is recommended to deactivate first / Confirm")
  739. ),
  740. // MedtrumKitUI/Views/DeleteDriverActionSheet.swift:3-19
  741. Row(
  742. identifier: "DeleteDriverActionSheet",
  743. message: "Remove Pump / Are you sure you want to stop using Medtrum TouchCare Nano 200u/300u? / Delete Pump",
  744. role: "alertBody",
  745. taxonomy: "N14",
  746. expected: .other("Remove Pump / Are you sure you want to stop using Medtrum TouchCare Nano 200u/300u? / Delete Pump")
  747. ),
  748. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:14-28
  749. Row(
  750. identifier: "MedtrumKitSettings.timeSyncActionSheet",
  751. message: "Time Change Detected / The time on your pump is different from the current time. Do you want to update the time on your pump to the current time? / Yes, Sync to Current Time / No, Keep Pump As Is",
  752. role: "alertBody",
  753. taxonomy: "N14",
  754. expected: .other(
  755. "Time Change Detected / The time on your pump is different from the current time. Do you want to update the time on your pump to the current time? / Yes, Sync to Current Time / No, Keep Pump As Is"
  756. )
  757. ),
  758. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:30-45
  759. Row(
  760. identifier: "MedtrumKitSettings.suspendPickerActionSheet",
  761. message: "Suspend Insulin Delivery / How long you wish to suspend your patch maximum? It will resume automaticly after this time.",
  762. role: "alertBody",
  763. taxonomy: "N14",
  764. expected: .other(
  765. "Suspend Insulin Delivery / How long you wish to suspend your patch maximum? It will resume automaticly after this time."
  766. )
  767. ),
  768. // MedtrumKitUI/Views/Settings/MedtrumKitSettings.swift:74-81
  769. Row(
  770. identifier: "MedtrumKitSettings.timeSyncBanner",
  771. message: "Time Change Detected / The time on your pump is different from the current time. Your pump's time controls your scheduled therapy settings. Scroll down to Pump Time row...",
  772. role: "alertBody",
  773. taxonomy: "N14",
  774. expected: .other(
  775. "Time Change Detected / The time on your pump is different from the current time. Your pump's time controls your scheduled therapy settings. Scroll down to Pump Time row..."
  776. )
  777. )
  778. ]
  779. @Test("each (identifier, message) classifies as pinned", arguments: rows) func eachMessageClassifiesAsPinned(_ row: Row) {
  780. #expect(TrioAlertClassifier.categorize(error: StubError(description: row.message)) == row.expected)
  781. }
  782. // MARK: - Classifier coverage gaps (ratchet)
  783. /// `"identifier — message"` keys where the audit marks `isGap == true`:
  784. /// the message currently falls through to `.other` but SHOULD route to a
  785. /// meaningful bucket. This ratchet stays green today and FAILS when the
  786. /// classifier is improved to catch the real prose — prompting an update.
  787. ///
  788. /// Per gap: bucket it SHOULD hit, and why the substring tokens miss it.
  789. /// - occlusionNotification: SHOULD .occlusion. Body is misspelled
  790. /// "occlussion" (double-s) — does NOT contain "occlusion"
  791. /// (NotificationManager.swift:61).
  792. /// - reservoirEmptyNotification ("...out of insulin!"): SHOULD
  793. /// .reservoirEmpty. Prose has no "reservoirempty"/"emptyreservoir"
  794. /// token (NotificationManager.swift:81).
  795. /// - reservoirEmptyNotification ("Reservoir low..."): SHOULD
  796. /// .reservoirLow. "Reservoir low" (with space) != "lowreservoir"
  797. /// (NotificationManager.swift:91).
  798. /// - PumpStatusHighlight "No Insulin": SHOULD .reservoirEmpty. No
  799. /// "reservoirempty" token (MedtrumKitPumpManager+UI.swift:85).
  800. /// - PumpStatusHighlight "Patch expired. Basal only.": SHOULD
  801. /// .deviceExpired. Only "podexpired"/"sensorexpired" tokens match,
  802. /// not "patch expired" (MedtrumKitPumpManager+UI.swift:100).
  803. /// - PumpStatusHighlight "Signal Loss": SHOULD .commsTransient. No
  804. /// "communication"/"timeout"/etc. token (MedtrumKitPumpManager+UI.swift:109).
  805. /// - PumpStatusHighlight "Patch Error": SHOULD .hardwareFault. "Error"
  806. /// is not "fault" (MedtrumKitPumpManager+UI.swift:118).
  807. /// - failedToDiscoverServices: SHOULD .commsTransient. No comms token
  808. /// in the prose (PeripheralManager.swift:209).
  809. /// - failedToDiscoverCharacteristics: SHOULD .commsTransient. No comms
  810. /// token (PeripheralManager.swift:231).
  811. /// - failedToFindDevice "Failed to connect to patch": SHOULD
  812. /// .commsTransient. No "timeout"/"notconnected" token; "connect" is
  813. /// not "notconnected" (BluetoothManager.swift:114,128).
  814. /// - invalidBluetoothState: SHOULD .commsTransient. No comms token
  815. /// (BluetoothManager.swift:45).
  816. /// - noData "No data": SHOULD .commsTransient. No comms token
  817. /// (PeripheralManager.swift:39,71).
  818. /// - noWriteCharacteristic: SHOULD .commsTransient. "disconnected" !=
  819. /// "notconnected" (PeripheralManager.swift:58).
  820. /// - invalidData "Invalid data received": SHOULD .commsTransient. No
  821. /// comms token (PeripheralManager.swift:314,321).
  822. /// - noManager "No peripheral manager": SHOULD .commsTransient. No
  823. /// comms token (BluetoothManager.swift:170).
  824. /// - uncertainDelivery "uncertain delivery": SHOULD .deliveryUncertain.
  825. /// "uncertain delivery" (with space) != "uncertaindelivery"
  826. /// (MedtrumPumpManager.swift:992).
  827. static let classifierCoverageGaps: Set<String> = [
  828. "NotificationManager.Identifiers.occlusionNotification — Replace your patch now! / Your patch has detected an occlussion!",
  829. "NotificationManager.Identifiers.reservoirEmptyNotification — Replace your patch now! / Your patch is out of insulin!",
  830. "NotificationManager.Identifiers.reservoirEmptyNotification — Reservoir low (%lld U) / Your patch is running out of insulin!",
  831. "PumpStatusHighlight — No Insulin",
  832. "PumpStatusHighlight — Patch expired. Basal only.",
  833. "PumpStatusHighlight — Signal Loss",
  834. "PumpStatusHighlight — Patch Error",
  835. "MedtrumConnectError.failedToDiscoverServices — No Medtrum service found - <discovered service UUIDs>",
  836. "MedtrumConnectError.failedToDiscoverCharacteristics — Failed to discover read, write or config characteristic - <UUIDs>",
  837. "MedtrumConnectError.failedToFindDevice — Failed to connect to patch",
  838. "MedtrumScanError.invalidBluetoothState — Invalid Bluetooth state: <state>",
  839. "MedtrumWriteError.noData — No data",
  840. "MedtrumWriteError.noWriteCharacteristic — No write characteristic. Device might be disconnected",
  841. "MedtrumWriteError.invalidData — Invalid data received",
  842. "MedtrumWriteError.noManager — No peripheral manager",
  843. "PumpManagerError.uncertainDelivery — uncertain delivery"
  844. ]
  845. @Test("classifier coverage gaps are exactly as documented") func classifierCoverageGapsAreExactlyAsDocumented() {
  846. // A coverage gap = a row that currently resolves to `.other(message)`
  847. // even though the audit says it SHOULD route to a meaningful bucket
  848. // (taxonomyBucket != "other"). The bucket-vs-other distinction lives
  849. // in the audit, encoded here as `classifierCoverageGaps`. Recompute
  850. // the falls-through-to-other set among gap-flagged rows and confirm
  851. // it matches verbatim; this FAILS when the classifier is improved to
  852. // catch the prose, forcing the documented set to be updated.
  853. let fellThroughToOther = Set(
  854. Self.rows
  855. .filter { row in
  856. TrioAlertClassifier.categorize(error: StubError(description: row.message)) == .other(row.message)
  857. }
  858. .map { "\($0.identifier) — \($0.message)" }
  859. )
  860. let documentedGapsThatFellThrough = fellThroughToOther.intersection(Self.classifierCoverageGaps)
  861. #expect(documentedGapsThatFellThrough == Self.classifierCoverageGaps)
  862. }
  863. }