From b415f2e1ee73e48a43776e0d1c0b14656aae1aeb Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 01:08:03 -0500 Subject: [PATCH 1/3] Retune the stuck-glucose detector against flat readings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detector fired on any current-glucose word repeated across 3 advancing frames, which is ordinary flat glucose at 1 mg/dL resolution. A healthy 5-hour field capture produced 18 hits at 167, 165, 164, 131, 130 and 91, every one with the surrounding frame bytes and the sensor's historic series advancing normally. At that noise level it could never single out the hold it exists to catch, which pinned one value for the better part of an hour. Raise the threshold to 12 advancing frames, clearing the longest run observed in that capture, and repeat only every 5th frame afterwards so an hour-long hold costs ~10 lines instead of ~55. Run length alone still can't separate a hold from flat glucose, so every report now carries the sensor's committed 5-min historic series as a second opinion: its value, lifeCount, lag, drift since the run opened, and its gap from the pinned value. That series is produced independently and lands ~15 minutes behind, so once a run outlasts its own lag the two records describe the same minutes and should agree. When they don't, the live value is the suspect and the line is labelled STUCK-LATCH. When they do agree — which is what the false-low field report looked like — that is itself the finding, and points at the sensor rather than at our decode. Also report the step that ends a hold (flat glucose resumes by a point or two, a released hold jumps), and stop counting runs of error words, which are a different failure already surfaced by the quality-assessment path and never forwarded to Loop. Decision logic moves into a pure StuckGlucoseDetector so it can be tested directly. Replaying all 293 realtime frames from the field capture through it now emits nothing, down from 18 lines. --- LibreLoop/Sensor/LibreLoopSensorMonitor.swift | 188 +++++++++++++++--- .../LibreLoopCGMManagerStateTests.swift | 101 ++++++++++ 2 files changed, 265 insertions(+), 24 deletions(-) diff --git a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift index 5ecbe39..75f7517 100644 --- a/LibreLoop/Sensor/LibreLoopSensorMonitor.swift +++ b/LibreLoop/Sensor/LibreLoopSensorMonitor.swift @@ -78,13 +78,8 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable { private var lastPatchStatusAt: Date? /// Last time a glucose frame arrived. private var lastGlucoseAt: Date? - /// Stuck-value detector state: the last raw current-glucose word, the - /// lifeCount it arrived at, and the count of consecutive *advancing* frames - /// that repeated it. Catches a held/frozen glucose (e.g. after a DQ error) — - /// the repeats carry no error flag, so they look valid and get forwarded. - private var lastGlucoseWord: UInt16? - private var lastGlucoseWordLifeCount: UInt16? - private var stuckGlucoseRun: Int = 0 + /// Watches for a held/frozen current glucose — see `StuckGlucoseDetector`. + private var stuckDetector = StuckGlucoseDetector() private var readingHandler: ReadingHandler? private var disconnectHandler: DisconnectHandler? private var statusHandler: StatusHandler? @@ -555,23 +550,29 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable { lock.lock() let lcHandler = lifeCountHandler lastGlucoseAt = event.receivedAt // feed the silence watchdog - // Stuck-value detector: count consecutive *advancing* frames that - // repeat the raw current-glucose word. A same-minute resend - // (lifeCount unchanged) doesn't count; a new lifeCount carrying - // an identical word is a held/frozen value. - if reading.lifeCount == lastGlucoseWordLifeCount { - // same-minute resend — ignore for the stuck run - } else if lastGlucoseWord == reading.currentWord { - stuckGlucoseRun += 1 - } else { - stuckGlucoseRun = 0 - } - lastGlucoseWord = reading.currentWord - lastGlucoseWordLifeCount = reading.lifeCount - let stuckRun = stuckGlucoseRun + let histLag = Int(reading.lifeCount) - Int(reading.historicalLifeCount) + let stuckReport = stuckDetector.observe(.init( + lifeCount: reading.lifeCount, + currentWord: reading.currentWord, + currentMgDL: reading.currentGlucoseMgDL, + historicMgDL: reading.isHistoricalGlucoseValid ? reading.historicalGlucoseMgDL : nil, + historicLifeCount: reading.historicalLifeCount + )) lock.unlock() - if stuckRun >= 3 { - llog("STUCK: current glucose word \(String(format: "0x%04x", reading.currentWord)) unchanged across \(stuckRun + 1) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) dq=\(reading.dqError))") + switch stuckReport { + case .held(let held): + let histText = held.historicMgDL.map { + "\($0) mg/dL @LC \(held.historicLifeCount) (lag \(held.historicLag))" + } ?? "unavailable" + let driftText = held.historicDrift.map { String(format: "%+d", $0) } ?? "n/a" + let gapText = held.currentVsHistoric.map(String.init) ?? "n/a" + let label = held.diverged ? "STUCK-LATCH" : "STUCK" + llog("\(label): current glucose word \(String(format: "0x%04x", held.word)) unchanged across \(held.frames) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) dq=\(reading.dqError) historic=\(histText) historicDrift=\(driftText) curVsHistoric=\(gapText) mg/dL)") + case .cleared(let frames, let step): + let stepText = step.map { String(format: "%+d", $0) } ?? "n/a" + llog("STUCK cleared after \(frames) advancing frames (lifeCount=\(reading.lifeCount) mgdl=\(mgdlStr) step=\(stepText) mg/dL)") + case nil: + break } lcHandler?(reading.lifeCount) if let sample = Self.makeSample(from: reading, assessment: assessment, receivedAt: event.receivedAt) { @@ -593,7 +594,6 @@ public final class LibreLoopSensorMonitor: @unchecked Sendable { lock.unlock() embedded?(reading.historicalLifeCount, histMgDL) } - let histLag = Int(reading.lifeCount) - Int(reading.historicalLifeCount) emitRead("Realtime", summary: "\(reading.currentGlucoseMgDL.map(String.init) ?? "—") mg/dL LC \(reading.lifeCount)", at: event.receivedAt, [ ("currentGlucose", reading.currentGlucoseMgDL.map { "\($0) mg/dL" } ?? "—"), ("currentValid", "\(reading.isCurrentGlucoseValid)"), @@ -730,3 +730,143 @@ extension LibreLoopSensorMonitor { try LibreLoopSensorMonitor(scanner: scanner, session: session, kEnc: kEnc, ivEnc: ivEnc) } } + + +/// Watches the realtime glucose stream for a *held* current value — the same +/// raw current-glucose word repeating across frames whose lifeCount keeps +/// advancing. The repeats carry no error flag, so a held value looks perfectly +/// valid on the way to Loop. +/// +/// Run length on its own can't separate a held value from genuinely flat +/// glucose. At 1 mg/dL resolution real glucose repeats for a surprisingly long +/// time: a healthy 5-hour field capture contained runs of 4, 6, 7 and 10 +/// advancing frames, every one of them with the surrounding frame bytes and the +/// sensor's historic series advancing normally. So the threshold sits above +/// that observed ceiling, and every report also carries a second opinion: the +/// sensor's committed 5-min historic series, which is produced independently +/// and lands ~15 minutes behind the live value. +/// +/// Once a run has outlasted its own historic lag, the two records are +/// describing the same minutes, so the historic should have converged on the +/// pinned value. If it hasn't, they genuinely disagree and the live value is +/// the suspect (`diverged`). If it has, the whole sensor is reporting the +/// value — which is just as useful to know, and is what the false-low report +/// that motivated this detector actually looked like. +/// +/// Pure and self-contained so it can be exercised directly in tests; the +/// monitor owns one instance and formats the reports into the log. +struct StuckGlucoseDetector { + /// Advancing frames a repeated word must span before it is worth a log line. + static let warnRun = 12 + /// After the first report, repeat only every Nth frame — an hour-long hold + /// then costs ~10 lines instead of ~55. + static let repeatEvery = 5 + /// Gap, in mg/dL, between the pinned current value and the historic series + /// that counts as the two records disagreeing. + static let divergenceMgDL = 15 + + struct Frame { + var lifeCount: UInt16 + var currentWord: UInt16 + /// Normalized displayable value; nil for an error/unavailable word. + var currentMgDL: UInt16? + /// Normalized historic value, or nil when the frame's historic slot + /// isn't valid yet. + var historicMgDL: UInt16? + var historicLifeCount: UInt16 + } + + struct Held: Equatable { + var word: UInt16 + /// Advancing frames the run spans, inclusive of the frame that opened it. + var frames: Int + var mgDL: UInt16? + var historicMgDL: UInt16? + var historicLifeCount: UInt16 + var historicLag: Int + /// How far the historic series moved since the run opened. + var historicDrift: Int? + var currentVsHistoric: Int? + /// The run has outlasted the historic lag and the records still disagree. + var diverged: Bool + } + + enum Report: Equatable { + case held(Held) + /// A run that had been reported just ended. `step` is the size of the + /// move that broke it — diagnostic in itself, since a real flat stretch + /// resumes by ±1-2 while a released hold jumps. + case cleared(frames: Int, step: Int?) + } + + private var lastWord: UInt16? + private var lastLifeCount: UInt16? + private var run = 0 + private var reported = false + private var runStartHistoric: UInt16? + private var runMgDL: UInt16? + + /// Feeds one realtime frame in. Returns a report when the frame crosses the + /// reporting threshold, lands on a repeat interval, or ends a reported run. + mutating func observe(_ frame: Frame) -> Report? { + var cleared: Report? + + if frame.currentMgDL == nil { + // A repeated *error* word is a different failure, already surfaced + // through the quality-assessment path and never forwarded to Loop. + cleared = endRun(brokenBy: nil) + } else if frame.lifeCount == lastLifeCount { + // Same-minute resend — carries no new information about a hold. + } else if lastWord == frame.currentWord { + run += 1 + if runStartHistoric == nil { runStartHistoric = frame.historicMgDL } + } else { + cleared = endRun(brokenBy: frame) + } + + lastWord = frame.currentWord + lastLifeCount = frame.lifeCount + + if let cleared { return cleared } + + guard run >= Self.warnRun, + !reported || (run - Self.warnRun) % Self.repeatEvery == 0 else { return nil } + reported = true + + let lag = Int(frame.lifeCount) - Int(frame.historicLifeCount) + let drift = zip2(frame.historicMgDL, runStartHistoric) { Int($0) - Int($1) } + let gap = zip2(frame.currentMgDL, frame.historicMgDL) { abs(Int($0) - Int($1)) } + return .held(Held(word: frame.currentWord, + frames: run + 1, + mgDL: frame.currentMgDL, + historicMgDL: frame.historicMgDL, + historicLifeCount: frame.historicLifeCount, + historicLag: lag, + historicDrift: drift, + currentVsHistoric: gap, + diverged: run > max(lag, 0) && (gap ?? 0) > Self.divergenceMgDL)) + } + + /// Resets run state, returning a `.cleared` report if the run that just + /// ended had been reported. + private mutating func endRun(brokenBy frame: Frame?) -> Report? { + let wasReported = reported + let length = run + let held = runMgDL + + run = 0 + reported = false + runStartHistoric = frame?.historicMgDL + runMgDL = frame?.currentMgDL + + guard wasReported else { return nil } + let step = zip2(frame?.currentMgDL, held) { Int($0) - Int($1) } + return .cleared(frames: length + 1, step: step) + } +} + +/// Combines two optionals, yielding nil unless both are present. +private func zip2(_ a: A?, _ b: B?, _ transform: (A, B) -> R) -> R? { + guard let a, let b else { return nil } + return transform(a, b) +} diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index 0b6c6c6..4db877b 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -64,3 +64,104 @@ final class LibreLoopSensorLifecycleTests: XCTestCase { ) } } + +/// The stuck-glucose detector exists because a Libre 3 field report showed the +/// current value pinned for the better part of an hour while Loop suspended +/// insulin on it. Its hard problem is the opposite direction: real glucose sits +/// flat at 1 mg/dL resolution often enough that a naive run-length trigger is +/// pure noise. These cover both sides. +final class StuckGlucoseDetectorTests: XCTestCase { + private func frame(_ lifeCount: UInt16, + current: UInt16?, + historic: UInt16?, + lag: UInt16 = 15) -> StuckGlucoseDetector.Frame { + StuckGlucoseDetector.Frame(lifeCount: lifeCount, + currentWord: current ?? 0x8000, + currentMgDL: current, + historicMgDL: historic, + historicLifeCount: lifeCount &- lag) + } + + /// Feeds a run of identical values and returns every report produced. + private func reports(_ frames: [StuckGlucoseDetector.Frame]) -> [StuckGlucoseDetector.Report] { + var detector = StuckGlucoseDetector() + return frames.compactMap { detector.observe($0) } + } + + /// The longest genuinely-flat run in the 5-hour capture this was tuned + /// against was 10 advancing frames, with the historic series tracking + /// normally. Anything at or under that must stay silent. + func testFlatGlucoseBelowThresholdIsSilent() { + let frames = (0..<11).map { frame(1000 + UInt16($0), current: 165, historic: 157) } + XCTAssertTrue(reports(frames).isEmpty) + } + + /// A same-minute resend repeats the word without advancing lifeCount, and + /// must not accumulate toward a run. + func testSameMinuteResendsDoNotAccumulate() { + let frames = (0..<40).map { _ in frame(1000, current: 165, historic: 157) } + XCTAssertTrue(reports(frames).isEmpty) + } + + /// Past the threshold it reports once, then only every fifth frame. + func testLongHoldReportsOnceThenEveryFifthFrame() { + let frames = (0..<28).map { frame(1000 + UInt16($0), current: 53, historic: 55) } + let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap { + if case .held(let h) = $0 { return h } + return nil + } + // Run indices 12, 17, 22, 27 → frame counts 13, 18, 23, 28. + XCTAssertEqual(held.map(\.frames), [13, 18, 23, 28]) + } + + /// Dan's shape: the live value is pinned low and the sensor's own historic + /// series carries the same low. The records agree, so this is not flagged as + /// a latch — which is itself the finding, since it points at the sensor + /// rather than at our decode of the realtime frame. + func testHoldWithAgreeingHistoricIsNotMarkedDiverged() { + let frames = (0..<28).map { frame(2000 + UInt16($0), current: 53, historic: 55) } + let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap { + if case .held(let h) = $0 { return h } + return nil + } + XCTAssertFalse(held.isEmpty) + XCTAssertTrue(held.allSatisfy { !$0.diverged }) + XCTAssertEqual(held.last?.currentVsHistoric, 2) + } + + /// A true latch: the live value sticks while the independently committed + /// historic series keeps tracking real glucose down and away from it. Once + /// the run outlasts the 15-minute historic lag, the disagreement is real. + func testLatchDivergesOnceRunOutlastsHistoricLag() { + let frames = (0..<32).map { i -> StuckGlucoseDetector.Frame in + // Historic starts level with the pinned value, then falls 3/min. + let historic = 120 - 3 * max(0, i - 12) + return frame(3000 + UInt16(i), current: 120, historic: UInt16(max(40, historic))) + } + let held: [StuckGlucoseDetector.Held] = reports(frames).compactMap { + if case .held(let h) = $0 { return h } + return nil + } + XCTAssertFalse(held.first?.diverged ?? true, "not yet past the historic lag") + XCTAssertTrue(held.last?.diverged ?? false, "records disagree well past the lag") + } + + /// The move that ends a hold is diagnostic on its own: flat glucose resumes + /// by a point or two, a released hold jumps. + func testClearedReportCarriesTheStepThatBrokeTheRun() { + var frames = (0..<20).map { frame(4000 + UInt16($0), current: 120, historic: 118) } + frames.append(frame(4020, current: 96, historic: 118)) + guard case .cleared(let count, let step)? = reports(frames).last else { + return XCTFail("expected a cleared report") + } + XCTAssertEqual(count, 20) + XCTAssertEqual(step, -24) + } + + /// A run of unavailable/error words is a different failure, already surfaced + /// through the quality-assessment path and never forwarded to Loop. + func testErrorWordsDoNotFormAStuckRun() { + let frames = (0..<40).map { frame(5000 + UInt16($0), current: nil, historic: 118) } + XCTAssertTrue(reports(frames).isEmpty) + } +} From b2ecddfd8f1276b3065100c88e05d526cf74f7f5 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 01:08:14 -0500 Subject: [PATCH 2/3] Retract standing alerts when the CGM is deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user removed the Libre CGM after their sensor expired and switched back to a G7, then kept getting "sensor about to expire" alerts a week later, once per app launch. Nothing retracted the alerts on the deletion path. retractExpiryAlerts was reachable only from discardSensor, which only Replace Sensor calls; the Delete CGM button went straight to notifyDelegateOfDeletion and skipped the manager's delete entirely. Loop's AlertStore keeps alerts for the whole local-cache window (90 days in this build), and launch-time playback rebuilds any past-due delayed alert as .immediate and presents it again — so the three scheduled expiry alerts re-fired on every launch until acknowledged. delete now retracts and ends with notifyDelegateOfDeletion instead of a bare completion. That was wrong in the other direction too: overriding delete without re-issuing the notification meant the debug-menu delete tore down BLE but never actually removed the manager from Loop. Retraction covers every alert the manager can issue, not just the expiry set. Playback replays anything left unacknowledged and unretracted, so a standing sensorAttention or reconnectNeedsReScan notice produces the same symptom. They are listed in one place, allAlertIdentifiers, so a future alert can't be missed. discardSensor retracts the same set and clears the re-scan and sensor-attention state, since those notices belong to the sensor going away. Retraction is issued before the delegate notification and captures the delegate strongly, so it still lands once Loop releases the manager. retractAlert needs only the identifier, and both unschedules the pending user notification and records the retraction — which closes the launch-replay path and the rescheduleMutedAlerts path together. --- .../LibreLoopCGMManager.swift | 31 +++++++++++++++++-- .../Pairing/LibreLoopCGMManager+Pairing.swift | 16 ++++++++-- .../LibreLoopUICoordinator.swift | 8 ++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift index fb31b9e..a3b0118 100644 --- a/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift +++ b/LibreLoop/LibreLoopCGMManager/LibreLoopCGMManager.swift @@ -251,6 +251,19 @@ public final class LibreLoopCGMManager: CGMManager { /// One-shot guard so the re-scan alert fires once per failure run, not every /// failed attempt. var hasIssuedReScanAlert = false + + /// Every alert identifier this manager can issue. + /// + /// Alerts outlive the manager: Loop's AlertStore keeps them for the whole + /// local-cache window (90 days in this build), and launch-time playback + /// rebuilds any past-due `.delayed` alert as `.immediate` and presents it + /// again. So an alert left standing when the sensor is discarded or the CGM + /// is deleted re-fires on *every* app launch until the user acknowledges it. + /// Retract the whole set at both of those points — keep this list complete + /// when adding an alert. + static var allAlertIdentifiers: [Alert.AlertIdentifier] { + LibreLoopExpiryAlerts.allIdentifiers + [sensorAttentionAlertID, needsReScanAlertID] + } /// Cap on the exponential reconnect backoff (seconds) so a persistently /// failing/marginal link doesn't hammer the radio and drain the battery. static let maxReconnectBackoff: TimeInterval = 300 @@ -332,7 +345,11 @@ public final class LibreLoopCGMManager: CGMManager { monitor = nil isReconnecting = false recentSamples = [] - retractExpiryAlerts() + // Every standing alert belongs to the sensor we're discarding — the + // expiry schedule, and any sensor-attention / re-scan notice. + hasIssuedReScanAlert = false + lastSensorAttention = nil + retractAllAlerts() // Emit .sensorEnd before we blank state so the event's // deviceIdentifier still resolves to the session that's ending. // Matches the .sensorStart we emitted at pairing time so Loop's @@ -809,7 +826,17 @@ public final class LibreLoopCGMManager: CGMManager { scanner.cancelConnection(peripheral) } } - completion() + // Alerts outlive the manager. Anything still standing in Loop's + // AlertStore is replayed at every launch — a deleted CGM's expiry + // reminder re-firing days later is exactly the bug this prevents. + // Retract before notifying the delegate, while our delegate reference + // is still good. + retractAllAlerts() + // Notify last: `cgmManagerWantsDeletion` is what actually drops this + // manager from Loop, and it releases us. The LoopKit default `delete` + // does only this; we override it to add the teardown above, so the + // notification has to be re-issued here or the manager is never removed. + notifyDelegateOfDeletion(completion: completion) } } diff --git a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift index d6b16a0..1b1fa6d 100644 --- a/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift +++ b/LibreLoop/Pairing/LibreLoopCGMManager+Pairing.swift @@ -47,12 +47,22 @@ extension LibreLoopCGMManager { return activatedAt } - func retractExpiryAlerts() { + /// Retract every alert this manager could have standing — the scheduled + /// expiry set plus the sensor-attention and re-scan alerts. + /// + /// Must be called whenever the sensor goes away (discard) or the manager + /// does (delete). An alert left in Loop's AlertStore unacknowledged and + /// unretracted is replayed at every app launch for the life of the cache + /// window — see `LibreLoopCGMManager.allAlertIdentifiers`. + /// + /// The delegate is captured strongly up front so the retraction still + /// lands if this manager is released immediately afterwards. + func retractAllAlerts() { let delegate = cgmManagerDelegate - let identifiers = LibreLoopExpiryAlerts.allIdentifiers.map { + let identifiers = Self.allAlertIdentifiers.map { Alert.Identifier(managerIdentifier: pluginIdentifier, alertIdentifier: $0) } - llog("expiry alerts: retracting \(identifiers.count) identifier(s)") + llog("alerts: retracting \(identifiers.count) identifier(s)") Task { for identifier in identifiers { await delegate?.retractAlert(identifier: identifier) diff --git a/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift b/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift index d035265..403f7f3 100644 --- a/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift +++ b/LibreLoopUI/LibreLoopCGMManager/LibreLoopUICoordinator.swift @@ -152,8 +152,14 @@ final class LibreLoopUICoordinator: UINavigationController, CGMManagerOnboarding self.completionDelegate?.completionNotifyingDidComplete(self) }, replaceSensor: { [weak self] in self?.startReplacementPairing() }, + // `delete` — not `notifyDelegateOfDeletion` — so the manager's own + // teardown runs: dropping the shared central's listener/link, and + // retracting standing alerts. Loop's AlertStore replays anything + // left behind on every launch, so skipping this leaves a deleted + // sensor's expiry reminder firing for weeks. `delete` notifies the + // delegate itself. deleteCGM: { [weak self] in - self?.cgmManager?.notifyDelegateOfDeletion { + self?.cgmManager?.delete { DispatchQueue.main.async { guard let self = self else { return } self.completionDelegate?.completionNotifyingDidComplete(self) From 58a63ce391b76fa0e734f54f51f98df6926712da Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 8 Sep 2026 01:30:27 -0500 Subject: [PATCH 3/3] Make LibreLoopTests runnable, and cover the alert retraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test target was wired up correctly but unreachable. The project had no shared scheme, so its schemes were per-user and invisible at workspace level, and building the project standalone can't resolve LibreCRKit, LoopKit or LoopAlgorithm — those come from the workspace. Tests could only be verified by extracting the type under test and running it as a script. Add a shared LibreLoop.xcscheme carrying build and test actions, following G7SensorKit's layout, so the tests run through the workspace: xcodebuild -workspace LoopWorkspace.xcworkspace -scheme LibreLoop \ -destination 'platform=iOS Simulator,name=iPhone 17' test With that in place, cover the deletion bug that prompted this: a stale sensor-expiry alert re-firing on every launch for a week after the CGM was removed. LibreLoopAlertRetractionTests asserts that both exit paths — delete and discardSensor — retract every identifier in allAlertIdentifiers, that delete still notifies the delegate, and that the identifier list stays complete. Verified against the pre-fix delete: it fails on both the missing retractions and the missing delegate notification. 15 tests pass. --- .../xcshareddata/xcschemes/LibreLoop.xcscheme | 68 ++++++++++ .../LibreLoopCGMManagerStateTests.swift | 121 ++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme diff --git a/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme b/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme new file mode 100644 index 0000000..0ac610d --- /dev/null +++ b/LibreLoop.xcodeproj/xcshareddata/xcschemes/LibreLoop.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift index 4db877b..e2addde 100644 --- a/LibreLoopTests/LibreLoopCGMManagerStateTests.swift +++ b/LibreLoopTests/LibreLoopCGMManagerStateTests.swift @@ -1,4 +1,5 @@ import XCTest +import LoopKit @testable import LibreLoop final class LibreLoopCGMManagerStateTests: XCTestCase { @@ -165,3 +166,123 @@ final class StuckGlucoseDetectorTests: XCTestCase { XCTAssertTrue(reports(frames).isEmpty) } } + +/// Records the alert identifiers retracted through it. `cgmManagerDelegate` is +/// weak, so tests must hold this strongly for the duration. +private nonisolated final class RetractionRecordingDelegate: CGMManagerDelegate { + private let lock = NSLock() + private var _retracted: [Alert.Identifier] = [] + var retracted: [Alert.Identifier] { + lock.lock() + defer { lock.unlock() } + return _retracted + } + + private let retractionExpectation: XCTestExpectation + private let deletionExpectation: XCTestExpectation? + + init(retractionExpectation: XCTestExpectation, deletionExpectation: XCTestExpectation? = nil) { + self.retractionExpectation = retractionExpectation + self.deletionExpectation = deletionExpectation + } + + @MainActor func retractAlert(identifier: Alert.Identifier) async { + lock.lock() + _retracted.append(identifier) + lock.unlock() + retractionExpectation.fulfill() + } + + func cgmManagerWantsDeletion(_ manager: CGMManager) async { + deletionExpectation?.fulfill() + } + + // Unused by these tests. + @MainActor func issueAlert(_ alert: Alert) async {} + func doesIssuedAlertExist(identifier: Alert.Identifier) async throws -> Bool { false } + func lookupAllUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + func lookupAllUnacknowledgedUnretracted(managerIdentifier: String) async throws -> [PersistedAlert] { [] } + func recordRetractedAlert(_ alert: Alert, at date: Date) async throws {} + func deviceManager(_ manager: DeviceManager, logEventForDeviceIdentifier deviceIdentifier: String?, type: DeviceLogEntryType, message: String, completion: ((Error?) -> Void)?) {} + func cgmManager(_ manager: CGMManager, hasNew readingResult: CGMReadingResult) {} + func cgmManager(_ manager: CGMManager, hasNew events: [PersistedCgmEvent]) {} + func cgmManagerDidUpdateState(_ manager: CGMManager) {} + func cgmManager(_ manager: CGMManager, didUpdate status: CGMManagerStatus) {} + func startDateToFilterNewData(for manager: CGMManager) -> Date? { nil } + func credentialStoragePrefix(for manager: CGMManager) -> String { "test" } +} + +/// Alerts outlive the manager: Loop's AlertStore keeps them for the whole +/// local-cache window, and launch-time playback rebuilds a past-due `.delayed` +/// alert as `.immediate` and presents it again. An alert left standing when the +/// sensor or the CGM goes away therefore re-fires on every app launch — which is +/// what a user hit, getting sensor-expiry alerts for a week after switching to a +/// different CGM. These pin down the retraction on both exit paths. +final class LibreLoopAlertRetractionTests: XCTestCase { + private func makeManager(delegate: CGMManagerDelegate) -> LibreLoopCGMManager { + let manager = LibreLoopCGMManager() + manager.delegateQueue = DispatchQueue(label: "LibreLoopAlertRetractionTests") + manager.cgmManagerDelegate = delegate + return manager + } + + private func expectRetractions() -> XCTestExpectation { + let expectation = expectation(description: "every alert identifier retracted") + expectation.expectedFulfillmentCount = LibreLoopCGMManager.allAlertIdentifiers.count + return expectation + } + + private func assertRetractedEverything(_ delegate: RetractionRecordingDelegate) { + XCTAssertEqual(Set(delegate.retracted.map(\.alertIdentifier)), + Set(LibreLoopCGMManager.allAlertIdentifiers)) + XCTAssertTrue(delegate.retracted.allSatisfy { + $0.managerIdentifier == LibreLoopCGMManager.pluginIdentifier + }) + } + + /// Deleting the CGM must clear every alert. It must also still notify the + /// delegate — overriding `delete` without re-issuing that notification + /// leaves the manager attached to Loop. + func testDeleteRetractsEveryAlertAndNotifiesDelegate() { + let retractions = expectRetractions() + let deletion = expectation(description: "delegate notified of deletion") + let completed = expectation(description: "delete completion called") + let delegate = RetractionRecordingDelegate(retractionExpectation: retractions, + deletionExpectation: deletion) + let manager = makeManager(delegate: delegate) + + manager.delete { completed.fulfill() } + + wait(for: [retractions, deletion, completed], timeout: 5) + assertRetractedEverything(delegate) + } + + /// Discarding the sensor leaves the CGM configured, but every standing alert + /// belonged to the sensor that just went away. + func testDiscardSensorRetractsEveryAlert() { + let retractions = expectRetractions() + let delegate = RetractionRecordingDelegate(retractionExpectation: retractions) + let manager = makeManager(delegate: delegate) + manager.hasIssuedReScanAlert = true + + manager.discardSensor() + + wait(for: [retractions], timeout: 5) + assertRetractedEverything(delegate) + XCTAssertFalse(manager.hasIssuedReScanAlert) + XCTAssertNil(manager.lastSensorAttention) + } + + /// The retraction is only as complete as this list. Anything issuable and + /// missing from it silently re-fires forever. + func testAllAlertIdentifiersCoversEveryIssuableAlert() { + let identifiers = Set(LibreLoopCGMManager.allAlertIdentifiers) + for expiryIdentifier in LibreLoopExpiryAlerts.allIdentifiers { + XCTAssertTrue(identifiers.contains(expiryIdentifier), "missing \(expiryIdentifier)") + } + XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.sensorAttentionAlertID)) + XCTAssertTrue(identifiers.contains(LibreLoopCGMManager.needsReScanAlertID)) + XCTAssertEqual(identifiers.count, LibreLoopCGMManager.allAlertIdentifiers.count, + "duplicate identifiers in allAlertIdentifiers") + } +}