TrioAlertClassifierTests.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Trio Alerts: TrioAlertClassifier — alertIdentifier round-trip") struct TrioAlertClassifierIdentifierTests {
  5. /// Ground-truth slugs emitted by bundled pump managers. The classifier is
  6. /// substring-matched and case-insensitive, so the test inputs reproduce the
  7. /// real source-of-truth casing.
  8. // MARK: - OmnipodKit (covers Eros + DASH)
  9. @Test("Omnipod: lowReservoir → reservoirLow") func omnipodLowReservoir() {
  10. #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowReservoir") == .reservoirLow)
  11. }
  12. @Test("Omnipod: podExpiring is a reminder, not an expiration") func omnipodPodExpiring() {
  13. #expect(TrioAlertClassifier.categorize(alertIdentifier: "podExpiring") == .deviceExpirationReminder)
  14. }
  15. @Test("Omnipod: userPodExpiration → reminder") func omnipodUserPodExpiration() {
  16. #expect(TrioAlertClassifier.categorize(alertIdentifier: "userPodExpiration") == .deviceExpirationReminder)
  17. }
  18. @Test("Omnipod: podExpireImminent → shutdown imminent") func omnipodExpireImminent() {
  19. #expect(TrioAlertClassifier.categorize(alertIdentifier: "podExpireImminent") == .podShutdownImminent)
  20. }
  21. @Test("Omnipod: suspendEnded → suspendTimeExpired") func omnipodSuspendEnded() {
  22. #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendEnded") == .suspendTimeExpired)
  23. }
  24. @Test("Omnipod: suspendEnded-repeating → suspendTimeExpired") func omnipodSuspendEndedRepeating() {
  25. #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendEnded-repeating") == .suspendTimeExpired)
  26. }
  27. @Test("Omnipod: unexpectedAlert → hardwareFault") func omnipodUnexpectedAlert() {
  28. #expect(TrioAlertClassifier.categorize(alertIdentifier: "unexpectedAlert") == .hardwareFault)
  29. }
  30. @Test("Omnipod: timeOffsetChangeDetected → other") func omnipodTimeOffset() {
  31. // Not a clinically actionable category — falls through to .other.
  32. if case .other = TrioAlertClassifier.categorize(alertIdentifier: "timeOffsetChangeDetected") {
  33. // ok
  34. } else {
  35. Issue.record("Expected .other for timeOffsetChangeDetected")
  36. }
  37. }
  38. @Test("Omnipod: lowRLBattery → batteryLow") func omnipodLowRLBattery() {
  39. #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowRLBattery") == .batteryLow)
  40. }
  41. @Test("Omnipod: finishSetupReminder → other") func omnipodFinishSetup() {
  42. if case .other = TrioAlertClassifier.categorize(alertIdentifier: "finishSetupReminder") {
  43. // ok
  44. } else {
  45. Issue.record("Expected .other for finishSetupReminder")
  46. }
  47. }
  48. @Test("Omnipod: suspendInProgress → other") func omnipodSuspendInProgress() {
  49. if case .other = TrioAlertClassifier.categorize(alertIdentifier: "suspendInProgress") {
  50. // ok
  51. } else {
  52. Issue.record("Expected .other for suspendInProgress")
  53. }
  54. }
  55. // MARK: - DanaKit
  56. @Test("Dana: batteryZeroPercent → batteryEmpty") func danaBatteryZero() {
  57. #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryZeroPercent") == .batteryEmpty)
  58. }
  59. @Test("Dana: pumpError → hardwareFault") func danaPumpError() {
  60. #expect(TrioAlertClassifier.categorize(alertIdentifier: "pumpError") == .hardwareFault)
  61. }
  62. @Test("Dana: occlusion") func danaOcclusion() {
  63. #expect(TrioAlertClassifier.categorize(alertIdentifier: "occlusion") == .occlusion)
  64. }
  65. @Test("Dana: lowBattery → batteryLow") func danaLowBattery() {
  66. #expect(TrioAlertClassifier.categorize(alertIdentifier: "lowBattery") == .batteryLow)
  67. }
  68. @Test("Dana: shutdown → hardwareFault") func danaShutdown() {
  69. // The classifier matches the exact lowercased "shutdown" to hardwareFault.
  70. #expect(TrioAlertClassifier.categorize(alertIdentifier: "shutdown") == .hardwareFault)
  71. }
  72. @Test("Dana: emptyReservoir → reservoirEmpty") func danaEmptyReservoir() {
  73. #expect(TrioAlertClassifier.categorize(alertIdentifier: "emptyReservoir") == .reservoirEmpty)
  74. }
  75. @Test("Dana: remainingInsulinLevel → reservoirLow") func danaRemainingInsulin() {
  76. #expect(TrioAlertClassifier.categorize(alertIdentifier: "remainingInsulinLevel") == .reservoirLow)
  77. }
  78. @Test("Dana: checkShaft → hardwareFault") func danaCheckShaft() {
  79. #expect(TrioAlertClassifier.categorize(alertIdentifier: "checkShaft") == .hardwareFault)
  80. }
  81. @Test("Dana: ble5InvalidKeys → other") func danaInvalidKeys() {
  82. if case .other = TrioAlertClassifier.categorize(alertIdentifier: "ble5InvalidKeys") {
  83. // ok
  84. } else {
  85. Issue.record("Expected .other for ble5InvalidKeys")
  86. }
  87. }
  88. // MARK: - MedtrumKit (jbr7rr/MedtrumKit PR #147)
  89. @Test("Medtrum: patch-expired → reminder (misnamed, fires PRE-expiry via .delayed)") func medtrumPatchExpiredIsReminder() {
  90. #expect(
  91. TrioAlertClassifier
  92. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-expired") == .deviceExpirationReminder
  93. )
  94. }
  95. @Test("Medtrum: patch-occlusion → occlusion") func medtrumOcclusion() {
  96. #expect(
  97. TrioAlertClassifier
  98. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-occlusion") == .occlusion
  99. )
  100. }
  101. @Test("Medtrum: patch-fault → hardwareFault") func medtrumPatchFault() {
  102. #expect(
  103. TrioAlertClassifier
  104. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-fault") == .hardwareFault
  105. )
  106. }
  107. @Test("Medtrum: patch-empty → reservoirEmpty") func medtrumPatchEmpty() {
  108. #expect(
  109. TrioAlertClassifier
  110. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-empty") == .reservoirEmpty
  111. )
  112. }
  113. @Test("Medtrum: reservoir-low → reservoirLow") func medtrumReservoirLow() {
  114. #expect(
  115. TrioAlertClassifier
  116. .categorize(alertIdentifier: "com.nightscout.medtrumkit.reservoir-low") == .reservoirLow
  117. )
  118. }
  119. @Test("Medtrum: patch-daily-limit → suspendTimeExpired (auto-suspend on cap)") func medtrumPatchDailyLimit() {
  120. #expect(
  121. TrioAlertClassifier
  122. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-daily-limit") == .suspendTimeExpired
  123. )
  124. }
  125. @Test("Medtrum: patch-hourly-limit → suspendTimeExpired") func medtrumPatchHourlyLimit() {
  126. #expect(
  127. TrioAlertClassifier
  128. .categorize(alertIdentifier: "com.nightscout.medtrumkit.patch-hourly-limit") == .suspendTimeExpired
  129. )
  130. }
  131. // MARK: - MinimedKit
  132. @Test("Minimed: PumpBatteryLow → batteryLow") func minimedBatteryLow() {
  133. #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpBatteryLow") == .batteryLow)
  134. }
  135. @Test("Minimed: PumpReservoirEmpty → reservoirEmpty") func minimedReservoirEmpty() {
  136. #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpReservoirEmpty") == .reservoirEmpty)
  137. }
  138. @Test("Minimed: PumpReservoirLow → reservoirLow") func minimedReservoirLow() {
  139. #expect(TrioAlertClassifier.categorize(alertIdentifier: "PumpReservoirLow") == .reservoirLow)
  140. }
  141. // MARK: - Trio internal slugs (APSManager + NotLoopingMonitor + GlucoseAlertCoordinator)
  142. @Test("Trio: occlusion") func trioOcclusion() {
  143. #expect(TrioAlertClassifier.categorize(alertIdentifier: "occlusion") == .occlusion)
  144. }
  145. @Test("Trio: reservoirLow") func trioReservoirLow() {
  146. #expect(TrioAlertClassifier.categorize(alertIdentifier: "reservoirLow") == .reservoirLow)
  147. }
  148. @Test("Trio: reservoirEmpty") func trioReservoirEmpty() {
  149. #expect(TrioAlertClassifier.categorize(alertIdentifier: "reservoirEmpty") == .reservoirEmpty)
  150. }
  151. @Test("Trio: batteryLow") func trioBatteryLow() {
  152. #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryLow") == .batteryLow)
  153. }
  154. @Test("Trio: batteryEmpty") func trioBatteryEmpty() {
  155. #expect(TrioAlertClassifier.categorize(alertIdentifier: "batteryEmpty") == .batteryEmpty)
  156. }
  157. @Test("Trio: hardwareFault") func trioHardwareFault() {
  158. #expect(TrioAlertClassifier.categorize(alertIdentifier: "hardwareFault") == .hardwareFault)
  159. }
  160. @Test("Trio: deliveryUncertain") func trioDeliveryUncertain() {
  161. #expect(TrioAlertClassifier.categorize(alertIdentifier: "deliveryUncertain") == .deliveryUncertain)
  162. }
  163. @Test("Trio: deviceExpirationReminder") func trioReminder() {
  164. #expect(TrioAlertClassifier.categorize(alertIdentifier: "deviceExpirationReminder") == .deviceExpirationReminder)
  165. }
  166. @Test("Trio: deviceExpired") func trioExpired() {
  167. #expect(TrioAlertClassifier.categorize(alertIdentifier: "deviceExpired") == .deviceExpired)
  168. }
  169. @Test("Trio: podShutdownImminent") func trioShutdownImminent() {
  170. #expect(TrioAlertClassifier.categorize(alertIdentifier: "podShutdownImminent") == .podShutdownImminent)
  171. }
  172. @Test("Trio: suspendTimeExpired") func trioSuspendTimeExpired() {
  173. #expect(TrioAlertClassifier.categorize(alertIdentifier: "suspendTimeExpired") == .suspendTimeExpired)
  174. }
  175. @Test("Trio: bolusFailed") func trioBolusFailed() {
  176. #expect(TrioAlertClassifier.categorize(alertIdentifier: "bolusFailed") == .bolusFailed)
  177. }
  178. @Test("Trio: manualTempBasalActive") func trioManualTempBasal() {
  179. #expect(TrioAlertClassifier.categorize(alertIdentifier: "manualTempBasalActive") == .manualTempBasalActive)
  180. }
  181. @Test("Trio: notLooping") func trioNotLooping() {
  182. #expect(TrioAlertClassifier.categorize(alertIdentifier: "notLooping") == .notLooping)
  183. }
  184. @Test("Trio: loop.notActive (NotLoopingMonitor slug)") func trioLoopNotActive() {
  185. #expect(TrioAlertClassifier.categorize(alertIdentifier: "loop.notActive") == .notLooping)
  186. }
  187. @Test("Trio: sensorFailure") func trioSensorFailure() {
  188. #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorFailure") == .sensorFailure)
  189. }
  190. @Test("Trio: algorithmError") func trioAlgorithmError() {
  191. #expect(TrioAlertClassifier.categorize(alertIdentifier: "algorithmError") == .algorithmError)
  192. }
  193. @Test("Trio: glucose.urgentLow.<uuid>") func trioGlucoseUrgentLow() {
  194. #expect(
  195. TrioAlertClassifier
  196. .categorize(alertIdentifier: "glucose.urgentLow.5C9C3D2A-1234-4321-9876-ABCDEF012345") == .glucoseUrgentLow
  197. )
  198. }
  199. @Test("Trio: glucose.low.<uuid>") func trioGlucoseLow() {
  200. #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.low.uuid-1") == .glucoseLow)
  201. }
  202. @Test("Trio: glucose.forecastedLow.<uuid>") func trioGlucoseForecastedLow() {
  203. #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.forecastedLow.uuid-2") == .glucoseForecastedLow)
  204. }
  205. @Test("Trio: glucose.high.<uuid>") func trioGlucoseHigh() {
  206. #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucose.high.uuid-3") == .glucoseHigh)
  207. }
  208. // MARK: - CGM driver slugs the classifier should recognize when bridges land
  209. @Test("CGM: sensorFailed → sensorFailure") func cgmSensorFailed() {
  210. #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorFailed") == .sensorFailure)
  211. }
  212. @Test("CGM: sensorStopped → sensorFailure") func cgmSensorStopped() {
  213. #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorStopped") == .sensorFailure)
  214. }
  215. @Test("CGM: transmitterDisconnected → sensorFailure") func cgmTransmitterDisconnected() {
  216. #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterDisconnected") == .sensorFailure)
  217. }
  218. @Test("CGM: sensorExpired → deviceExpired") func cgmSensorExpired() {
  219. #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorExpired") == .deviceExpired)
  220. }
  221. @Test("CGM: transmitterEoL → deviceExpired") func cgmTransmitterEoL() {
  222. #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterEoL") == .deviceExpired)
  223. }
  224. @Test("CGM: sensorGrace → reminder, not expired") func cgmSensorGrace() {
  225. #expect(TrioAlertClassifier.categorize(alertIdentifier: "sensorGrace") == .deviceExpirationReminder)
  226. }
  227. @Test("CGM: gracePeriod → reminder") func cgmGracePeriod() {
  228. #expect(TrioAlertClassifier.categorize(alertIdentifier: "gracePeriod") == .deviceExpirationReminder)
  229. }
  230. @Test("CGM: transmitterError → hardwareFault") func cgmTransmitterError() {
  231. #expect(TrioAlertClassifier.categorize(alertIdentifier: "transmitterError") == .hardwareFault)
  232. }
  233. @Test("CGM: criticalFault → hardwareFault") func cgmCriticalFault() {
  234. #expect(TrioAlertClassifier.categorize(alertIdentifier: "criticalFault") == .hardwareFault)
  235. }
  236. // MARK: - Glucose family wins over substring overlap
  237. @Test("Glucose stale beats the generic 'glucose' fallthrough") func glucoseDataStale() {
  238. #expect(TrioAlertClassifier.categorize(alertIdentifier: "glucoseDataStale") == .glucoseDataStale)
  239. }
  240. // MARK: - Unrecognized slug
  241. @Test("Unrecognized slug → .other with original identifier") func unrecognizedFallsThrough() {
  242. let category = TrioAlertClassifier.categorize(alertIdentifier: "totallyMadeUpSlug")
  243. if case let .other(id) = category {
  244. #expect(id == "totallyMadeUpSlug")
  245. } else {
  246. Issue.record("Expected .other(\"totallyMadeUpSlug\")")
  247. }
  248. }
  249. }
  250. @Suite("Trio Alerts: TrioAlertClassifier — error round-trip") struct TrioAlertClassifierErrorTests {
  251. @Test("APSError.invalidPumpState → hardwareFault") func invalidPumpState() {
  252. let err = APSError.invalidPumpState(message: "x")
  253. #expect(TrioAlertClassifier.categorize(error: err) == .hardwareFault)
  254. }
  255. @Test("APSError.glucoseError → glucoseDataStale") func glucoseError() {
  256. let err = APSError.glucoseError(message: "x")
  257. #expect(TrioAlertClassifier.categorize(error: err) == .glucoseDataStale)
  258. }
  259. @Test("APSError.apsError → algorithmError") func apsErrorCase() {
  260. let err = APSError.apsError(message: "x")
  261. #expect(TrioAlertClassifier.categorize(error: err) == .algorithmError)
  262. }
  263. @Test("APSError.manualBasalTemp → manualTempBasalActive") func manualBasalTemp() {
  264. let err = APSError.manualBasalTemp(message: "x")
  265. #expect(TrioAlertClassifier.categorize(error: err) == .manualTempBasalActive)
  266. }
  267. /// `categorize(pumpError:)` keys on `String(describing:)`, not
  268. /// `localizedDescription`. Match that contract with `CustomStringConvertible`
  269. /// so the fake errors actually expose the substring the classifier looks for.
  270. private struct PumpFake: Error, CustomStringConvertible {
  271. let description: String
  272. }
  273. @Test("APSError.pumpError(occlusion) descends into pumpError categorize") func pumpErrorOcclusion() {
  274. let err = APSError.pumpError(PumpFake(description: "Pump occlusion detected"))
  275. #expect(TrioAlertClassifier.categorize(error: err) == .occlusion)
  276. }
  277. @Test("APSError.pumpError(uncertain) → deliveryUncertain") func pumpErrorUncertain() {
  278. let err = APSError.pumpError(PumpFake(description: "UncertainDelivery: bolus may have failed"))
  279. #expect(TrioAlertClassifier.categorize(error: err) == .deliveryUncertain)
  280. }
  281. @Test("APSError.pumpError(timeout) → commsTransient") func pumpErrorComms() {
  282. let err = APSError.pumpError(PumpFake(description: "Communication timeout, no response"))
  283. #expect(TrioAlertClassifier.categorize(error: err) == .commsTransient)
  284. }
  285. @Test("Generic error → .other") func genericError() {
  286. struct OddError: Error {}
  287. let category = TrioAlertClassifier.categorize(error: OddError())
  288. if case .other = category {
  289. // ok
  290. } else {
  291. Issue.record("Expected .other for unrecognized error")
  292. }
  293. }
  294. }
  295. @Suite("Trio Alerts: TrioAlertClassifier — surfacing rules") struct TrioAlertCategorySurfacingTests {
  296. @Test("commsTransient is the only category dwell-gated at both boundaries") func commsTransientNotImmediate() {
  297. #expect(!TrioAlertCategory.commsTransient.shouldFireImmediately)
  298. // Spot-check that everything else surfaces immediately.
  299. let immediate: [TrioAlertCategory] = [
  300. .occlusion, .reservoirLow, .reservoirEmpty, .batteryLow, .batteryEmpty,
  301. .hardwareFault, .deliveryUncertain, .deviceExpirationReminder, .deviceExpired,
  302. .podShutdownImminent, .suspendTimeExpired, .bolusFailed, .manualTempBasalActive,
  303. .notLooping, .sensorFailure, .glucoseUrgentLow, .glucoseLow, .glucoseForecastedLow,
  304. .glucoseHigh, .glucoseDataStale, .algorithmError, .other("any")
  305. ]
  306. for category in immediate {
  307. #expect(category.shouldFireImmediately, "Expected \(category) to surface immediately")
  308. }
  309. }
  310. @Test("sensorFailure default tier is Critical") func sensorFailureCritical() {
  311. #expect(PumpAlertCategory.sensorFailure.defaultSeverity == .critical)
  312. }
  313. @Test("algorithmError defaults to Normal") func algorithmErrorNormal() {
  314. #expect(PumpAlertCategory.algorithmError.defaultSeverity == .normal)
  315. }
  316. @Test("Identifier slug round-trips through the classifier for every category") func slugRoundTrip() {
  317. for category in PumpAlertCategory.allCases {
  318. // Build the canonical slug from the matching TrioAlertCategory if available.
  319. let trioCategory = TrioAlertCategory.allCasesForRoundTripTest
  320. .first(where: { PumpAlertCategory(trioCategory: $0) == category })
  321. guard let trioCategory else { continue }
  322. let slug = trioCategory.alertIdentifier
  323. let reclassified = TrioAlertClassifier.categorize(alertIdentifier: slug)
  324. #expect(
  325. reclassified == trioCategory,
  326. "Slug \"\(slug)\" should reclassify as \(trioCategory) but got \(reclassified)"
  327. )
  328. }
  329. }
  330. }
  331. // Helper: TrioAlertCategory is not CaseIterable because of `.other(String)`,
  332. // so we enumerate the round-trippable cases explicitly for the round-trip test.
  333. extension TrioAlertCategory {
  334. static var allCasesForRoundTripTest: [TrioAlertCategory] {
  335. [
  336. .occlusion, .reservoirLow, .reservoirEmpty, .batteryLow, .batteryEmpty,
  337. .hardwareFault, .deliveryUncertain, .deviceExpirationReminder, .deviceExpired,
  338. .podShutdownImminent, .suspendTimeExpired, .bolusFailed, .manualTempBasalActive,
  339. .notLooping, .sensorFailure, .glucoseUrgentLow, .glucoseLow, .glucoseForecastedLow,
  340. .glucoseHigh, .glucoseDataStale, .algorithmError, .commsTransient
  341. ]
  342. }
  343. }