diff --git a/G7SensorKit.xcodeproj/project.pbxproj b/G7SensorKit.xcodeproj/project.pbxproj index e7c0f76..642bc34 100644 --- a/G7SensorKit.xcodeproj/project.pbxproj +++ b/G7SensorKit.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */; }; C109F14A291ECCE2008EA5B6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C109F149291ECCE2008EA5B6 /* Assets.xcassets */; }; C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */; }; + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */; }; C1409A07291EC21C006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17F5126291EAF2F00555EB5 /* OSLog.swift */; }; C1409A09291EC22F006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A08291EC22F006BE8D0 /* OSLog.swift */; }; C1409A0B291EC258006BE8D0 /* OSLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1409A0A291EC258006BE8D0 /* OSLog.swift */; }; @@ -116,6 +117,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtendedVersionMessageTests.swift; sourceTree = ""; }; C109F149291ECCE2008EA5B6 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = G7GlucoseMessageTests.swift; sourceTree = ""; }; + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = G7CGMManagerTests.swift; sourceTree = ""; }; C1409A08291EC22F006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C1409A0A291EC258006BE8D0 /* OSLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLog.swift; sourceTree = ""; }; C17F50C6291EAC3800555EB5 /* G7SensorKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = G7SensorKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -244,6 +246,7 @@ C10760802F05B412008B2B39 /* ExtendedVersionMessageTests.swift */, C17F50D3291EAC3800555EB5 /* G7SensorKitTests.swift */, C109F14B291ED66F008EA5B6 /* G7GlucoseMessageTests.swift */, + C1D0C0DE2F0700020000CAFE /* G7CGMManagerTests.swift */, ); path = G7SensorKitTests; sourceTree = ""; @@ -597,6 +600,7 @@ buildActionMask = 2147483647; files = ( C109F14C291ED66F008EA5B6 /* G7GlucoseMessageTests.swift in Sources */, + C1D0C0DE2F0700010000CAFE /* G7CGMManagerTests.swift in Sources */, C10760812F05B41B008B2B39 /* ExtendedVersionMessageTests.swift in Sources */, C17F50D4291EAC3800555EB5 /* G7SensorKitTests.swift in Sources */, ); diff --git a/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme new file mode 100644 index 0000000..89bed09 --- /dev/null +++ b/G7SensorKit.xcodeproj/xcshareddata/xcschemes/G7SensorKit.xcscheme @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift index 156bb80..3edb0e2 100644 --- a/G7SensorKit/G7CGMManager/G7BluetoothManager.swift +++ b/G7SensorKit/G7CGMManager/G7BluetoothManager.swift @@ -128,10 +128,17 @@ class G7BluetoothManager: NSObject { super.init() managerQueue.sync { - self.centralManager = CBCentralManager(delegate: self, queue: managerQueue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + self.centralManager = self.makeCentralManager(queue: self.managerQueue) } } + /// Factory seam so tests can substitute a central manager without the state + /// restoration option, which raises an exception outside an app with the + /// bluetooth-central background mode. + func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"]) + } + // MARK: - Actions func scanForPeripheral() { diff --git a/G7SensorKit/G7CGMManager/G7CGMManager.swift b/G7SensorKit/G7CGMManager/G7CGMManager.swift index d940208..e2b7d64 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManager.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManager.swift @@ -28,6 +28,12 @@ public class G7CGMManager: CGMManager { private let log = OSLog(category: "G7CGMManager") + /// How long to wait for communication to resume after a suspected session end + /// before forgetting the sensor and scanning for a new one. BLE handshake + /// failures are indistinguishable from a stopped session at disconnect time; + /// readings normally resume on the sensor's next 5-minute connection cycle. + var suspectedSessionEndGracePeriod: TimeInterval = TimeInterval(minutes: 15) + public var state: G7CGMManagerState { return lockedState.value } @@ -209,18 +215,22 @@ public class G7CGMManager: CGMManager { completion(.noData) } - public init() { - lockedState = Locked(G7CGMManagerState()) - sensor = G7Sensor(sensorID: nil) - sensor.delegate = self + public convenience init() { + self.init(state: G7CGMManagerState(), sensor: G7Sensor(sensorID: nil)) } - public required init?(rawState: RawStateValue) { + public required convenience init?(rawState: RawStateValue) { let state = G7CGMManagerState(rawValue: rawState) + self.init(state: state, sensor: G7Sensor(sensorID: state.sensorID)) + sensor.needsVersionInfo = state.extendedVersion == nil + } + + init(state: G7CGMManagerState, sensor: G7Sensor) { lockedState = Locked(state) - sensor = G7Sensor(sensorID: state.sensorID) + self.sensor = sensor sensor.delegate = self - sensor.needsVersionInfo = state.extendedVersion == nil + // A grace period may have been in flight when the app was last terminated. + restorePendingSuspectedSessionEnd() } public var rawState: RawStateValue { @@ -256,6 +266,8 @@ public class G7CGMManager: CGMManager { } public func scanForNewSensor() { + cancelSuspectedSessionEndScan() + logDeviceCommunication("Forgetting existing sensor and starting scan for new sensor.", type: .connection) mutateState { state in @@ -344,8 +356,88 @@ extension G7CGMManager: G7SensorDelegate { public func sensorDisconnected(_ sensor: G7Sensor, suspectedEndOfSession: Bool) { logDeviceCommunication("Sensor disconnected: suspectedEndOfSession=\(suspectedEndOfSession)", type: .connection) if suspectedEndOfSession { + scheduleScanAfterSuspectedSessionEnd() + } + } + + /// A disconnect before authentication usually means the session was stopped, + /// but the same signature occurs on transient BLE handshake failures, where + /// forgetting the sensor immediately causes a long re-discovery outage. + /// Instead, keep tracking the current sensor and only scan for a new one if + /// communication does not resume within the grace period. + private func scheduleScanAfterSuspectedSessionEnd() { + // `suspectedSessionEndAt` is the single record of a live grace period: it + // says whether one is running, identifies it, and survives termination. + guard state.suspectedSessionEndAt == nil else { + logDeviceCommunication("Suspected session end during active grace period; original deadline unchanged.", type: .connection) + return + } + + let graceStart = Date() + mutateState { state in + state.suspectedSessionEndAt = graceStart + } + + logDeviceCommunication("Suspected session end; waiting \(suspectedSessionEndGracePeriod.minutes) minutes for communication to resume before scanning for new sensor.", type: .connection) + scheduleGraceExpiry(graceStart: graceStart, after: suspectedSessionEndGracePeriod) + } + + private func scheduleGraceExpiry(graceStart: Date, after delay: TimeInterval) { + // Wall-clock deadline: a mach-time deadline pauses while the device + // sleeps, which could postpone detection of a genuinely ended session. + // Not cancellable, and does not need to be -- the expiry re-reads + // `suspectedSessionEndAt` and no-ops unless it still owns the window. + DispatchQueue.global(qos: .utility).asyncAfter(wallDeadline: .now() + delay) { [weak self] in + self?.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) + } + } + + func handleSuspectedSessionEndGraceExpiry(graceStart: Date) { + // Cleared by resumed communication, or replaced by a later grace period. + guard state.suspectedSessionEndAt == graceStart else { + logDeviceCommunication("Communication received during suspected session end grace period; keeping sensor.", type: .connection) + return + } + + logDeviceCommunication("No sensor communication since suspected session end.", type: .connection) + scanForNewSensor() + } + + /// Clearing the marker is the cancellation: a pending expiry finds a grace + /// start that is no longer current and does nothing. + private func cancelSuspectedSessionEndScan() { + // Guarded because this runs on every glucose and backfill message, and + // mutateState notifies observers and persists. + guard state.suspectedSessionEndAt != nil else { return } + mutateState { state in + state.suspectedSessionEndAt = nil + } + } + + /// Re-establish a grace period that was in flight when the app was last + /// terminated. The expiry is dispatched in memory and does not survive, so + /// without this a genuinely ended session would be tracked forever -- the + /// sensor never advertises again and nothing re-arms the scan. + private func restorePendingSuspectedSessionEnd() { + guard let graceStart = state.suspectedSessionEndAt else { return } + + // Normally resumed communication has already cleared the marker. This + // covers the case where that clear was not persisted before we exited. + if let latestReadingTimestamp = state.latestReadingTimestamp, latestReadingTimestamp > graceStart { + cancelSuspectedSessionEndScan() + return + } + + let remaining = graceStart.addingTimeInterval(suspectedSessionEndGracePeriod).timeIntervalSinceNow + guard remaining > 0 else { + // The window elapsed while we were not running, with nothing heard since. + logDeviceCommunication("Grace period for suspected session end expired while app was not running.", type: .connection) scanForNewSensor() + return } + + logDeviceCommunication("Resuming suspected session end grace period; \(Int(remaining / 60)) minutes remaining.", type: .connection) + scheduleGraceExpiry(graceStart: graceStart, after: remaining) } public func sensor(_ sensor: G7Sensor, logComms comms: String) { @@ -359,6 +451,9 @@ extension G7CGMManager: G7SensorDelegate { public func sensor(_ sensor: G7Sensor, didRead message: G7GlucoseMessage) { + // Receiving any glucose message proves the session is still active. + cancelSuspectedSessionEndScan() + guard message != latestReading else { logDeviceCommunication("Sensor reading duplicate: \(message)", type: .error) updateDelegate(with: .noData) @@ -427,6 +522,9 @@ extension G7CGMManager: G7SensorDelegate { } public func sensor(_ sensor: G7Sensor, didReadBackfill backfill: [G7BackfillMessage]) { + // Backfill likewise proves the session is still active. + cancelSuspectedSessionEndScan() + for msg in backfill { logDeviceCommunication("Sensor didReadBackfill \(msg)", type: .receive) } diff --git a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift index af88759..c51d69b 100644 --- a/G7SensorKit/G7CGMManager/G7CGMManagerState.swift +++ b/G7SensorKit/G7CGMManager/G7CGMManagerState.swift @@ -20,6 +20,11 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { public var latestReadingTimestamp: Date? public var latestConnect: Date? public var uploadReadings: Bool = true + /// When a suspected session end started its grace period, or nil if none is + /// pending. Persisted so a grace period survives app termination: the deferred + /// scan is an in-memory work item, so without this a genuinely ended session + /// would leave the manager tracking a sensor that will never advertise again. + public var suspectedSessionEndAt: Date? init() { } @@ -36,6 +41,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { self.latestReadingTimestamp = rawValue["latestReadingTimestamp"] as? Date self.latestConnect = rawValue["latestConnect"] as? Date self.uploadReadings = rawValue["uploadReadings"] as? Bool ?? true + self.suspectedSessionEndAt = rawValue["suspectedSessionEndAt"] as? Date } public var rawValue: RawValue { @@ -47,6 +53,7 @@ public struct G7CGMManagerState: RawRepresentable, Equatable { rawValue["latestReadingTimestamp"] = latestReadingTimestamp rawValue["latestConnect"] = latestConnect rawValue["uploadReadings"] = uploadReadings + rawValue["suspectedSessionEndAt"] = suspectedSessionEndAt return rawValue } } diff --git a/G7SensorKit/G7CGMManager/G7Sensor.swift b/G7SensorKit/G7CGMManager/G7Sensor.swift index 3507d17..70578c7 100644 --- a/G7SensorKit/G7CGMManager/G7Sensor.swift +++ b/G7SensorKit/G7CGMManager/G7Sensor.swift @@ -90,14 +90,19 @@ public final class G7Sensor: G7BluetoothManagerDelegate { private let log = OSLog(category: "G7Sensor") - private let bluetoothManager = G7BluetoothManager() + private let bluetoothManager: G7BluetoothManager private let delegateQueue = DispatchQueue(label: "com.loopkit.G7Sensor.delegateQueue", qos: .unspecified) private var sensorID: String? - public init(sensorID: String?) { + public convenience init(sensorID: String?) { + self.init(sensorID: sensorID, bluetoothManager: G7BluetoothManager()) + } + + init(sensorID: String?, bluetoothManager: G7BluetoothManager) { self.sensorID = sensorID + self.bluetoothManager = bluetoothManager bluetoothManager.delegate = self } @@ -127,7 +132,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { private func handleGlucoseMessage(message: G7GlucoseMessage, peripheralManager: G7PeripheralManager) { activationDate = Date().addingTimeInterval(-TimeInterval(message.messageTimestamp)) peripheralManager.perform { (peripheral) in - self.log.debug("Listening for backfill responses") + self.log.default("Listening for backfill responses") // Subscribe to backfill updates do { try peripheral.listenToCharacteristic(.backfill) @@ -194,7 +199,10 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } peripheralManager.perform { (peripheral) in - self.log.info("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) + // .default so this survives into a sysdiagnose: info and debug are + // memory-only and are not written to the log archive, which makes the + // auth handshake invisible in field diagnostics. + self.log.default("Listening for authentication responses for %{public}@", String(describing: peripheralManager.peripheral.name)) do { try peripheral.listenToCharacteristic(.authentication) self.pendingAuth = true @@ -314,7 +322,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { func bluetoothManager(_ manager: G7BluetoothManager, peripheralManager: G7PeripheralManager, didReceiveAuthenticationResponse response: Data) { if let message = AuthChallengeRxMessage(data: response), message.isBonded, message.isAuthenticated { - log.debug("Observed authenticated session. enabling notifications for control characteristic.") + log.default("Observed authenticated session. enabling notifications for control characteristic.") pendingAuth = false peripheralManager.perform { (peripheral) in do { @@ -327,7 +335,7 @@ public final class G7Sensor: G7BluetoothManagerDelegate { } } } else { - log.debug("Ignoring authentication response: %{public}@", response.hexadecimalString) + log.default("Ignoring authentication response: %{public}@", response.hexadecimalString) } } diff --git a/G7SensorKitTests/G7CGMManagerTests.swift b/G7SensorKitTests/G7CGMManagerTests.swift new file mode 100644 index 0000000..2efad79 --- /dev/null +++ b/G7SensorKitTests/G7CGMManagerTests.swift @@ -0,0 +1,201 @@ +// +// G7CGMManagerTests.swift +// G7SensorKitTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +import CoreBluetooth +@testable import G7SensorKit + +/// CBCentralManager with the state restoration option raises an exception in a +/// test bundle, which lacks the bluetooth-central background mode. +private class TestBluetoothManager: G7BluetoothManager { + override func makeCentralManager(queue: DispatchQueue) -> CBCentralManager { + return CBCentralManager(delegate: self, queue: queue) + } +} + +final class G7CGMManagerTests: XCTestCase { + + private static let sensorID = "DXCM99" + + private func makeManager(gracePeriod: TimeInterval) -> G7CGMManager { + var state = G7CGMManagerState() + state.sensorID = Self.sensorID + state.activatedAt = Date(timeIntervalSinceNow: -54000) // ~15h old session + let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + let manager = G7CGMManager(state: state, sensor: sensor) + manager.suspectedSessionEndGracePeriod = gracePeriod + return manager + } + + private var okGlucoseMessage: G7GlucoseMessage { + // Same sample as G7GlucoseMessageTests: glucose 138, algorithm state ok + return G7GlucoseMessage(data: Data(hexadecimalString: "4e00c35501002601000106008a00060187000f")!)! + } + + /// A grace period in flight when the app is terminated must be re-established + /// on restore: the deferred scan is an in-memory work item and does not survive. + /// Restore runs during init, so this goes through the internal init to pick up + /// the bluetooth seam, and uses the production default grace period. + private func makeRestoredManager(suspectedSessionEndAt: Date?, + latestReadingTimestamp: Date?) -> G7CGMManager { + var state = G7CGMManagerState() + state.sensorID = Self.sensorID + state.activatedAt = Date(timeIntervalSinceNow: -54000) + state.suspectedSessionEndAt = suspectedSessionEndAt + state.latestReadingTimestamp = latestReadingTimestamp + + let sensor = G7Sensor(sensorID: state.sensorID, bluetoothManager: TestBluetoothManager()) + return G7CGMManager(state: state, sensor: sensor) + } + + func testRestoreForgetsSensorWhenGraceExpiredWhileNotRunning() { + // Grace started an hour ago, nothing heard since: the session really ended. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -3600), + latestReadingTimestamp: Date(timeIntervalSinceNow: -7200) + ) + + XCTAssertNil(manager.state.sensorID) + } + + func testRestoreKeepsSensorWhenReadingArrivedAfterGraceStart() { + // A reading after the grace period began proves the session survived. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -3600), + latestReadingTimestamp: Date(timeIntervalSinceNow: -60) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + XCTAssertNil(manager.state.suspectedSessionEndAt) + } + + func testRestoreKeepsSensorWhileGraceStillRunning() { + // Terminated one minute into a 15-minute grace period: still within the + // window, so keep the sensor and let the re-armed deferral decide. + let manager = makeRestoredManager( + suspectedSessionEndAt: Date(timeIntervalSinceNow: -60), + latestReadingTimestamp: Date(timeIntervalSinceNow: -120) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + } + + func testRestoreWithNoPendingGraceLeavesSensorAlone() { + let manager = makeRestoredManager( + suspectedSessionEndAt: nil, + latestReadingTimestamp: Date(timeIntervalSinceNow: -7200) + ) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testSuspectedSessionEndPersistsGraceStart() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + } + + func testReadingClearsPersistedGraceStart() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + XCTAssertNotNil(manager.state.suspectedSessionEndAt) + + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + XCTAssertNil(manager.state.suspectedSessionEndAt) + } + + func testSuspectedSessionEndKeepsSensorDuringGracePeriod() { + let manager = makeManager(gracePeriod: 10) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testSuspectedSessionEndForgetsSensorAfterGracePeriodWithoutReadings() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + let forgotten = XCTNSPredicateExpectation( + predicate: NSPredicate { _, _ in manager.state.sensorID == nil }, + object: nil + ) + wait(for: [forgotten], timeout: 5) + } + + func testReadingDuringGracePeriodPreventsForgettingSensor() { + let manager = makeManager(gracePeriod: 0.5) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 1.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testGraceExpiryAfterCommsSinceGraceStartKeepsSensor() throws { + // A reading races the expiry: it is already dispatched when the reading + // arrives and clears the marker. Expiry must notice it no longer owns + // the window and leave the sensor alone. + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + let graceStart = try XCTUnwrap(manager.state.suspectedSessionEndAt) + + manager.sensor(manager.sensor, didRead: okGlucoseMessage) + manager.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testGraceExpiryWithoutCommsForgetsSensor() throws { + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + let graceStart = try XCTUnwrap(manager.state.suspectedSessionEndAt) + + manager.handleSuspectedSessionEndGraceExpiry(graceStart: graceStart) + + XCTAssertNil(manager.state.sensorID) + } + + /// An expiry from a superseded grace period must not forget the sensor. + func testStaleGraceExpiryDoesNotForgetSensor() { + let manager = makeManager(gracePeriod: 100) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: true) + + manager.handleSuspectedSessionEndGraceExpiry(graceStart: Date(timeIntervalSinceNow: -3600)) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } + + func testNonSuspectedDisconnectDoesNotForgetSensor() { + let manager = makeManager(gracePeriod: 0.1) + + manager.sensorDisconnected(manager.sensor, suspectedEndOfSession: false) + + let graceElapsed = expectation(description: "grace period elapsed") + DispatchQueue.global().asyncAfter(deadline: .now() + 0.5) { + graceElapsed.fulfill() + } + wait(for: [graceElapsed], timeout: 5) + + XCTAssertEqual(Self.sensorID, manager.state.sensorID) + } +}