import Foundation import Testing @testable import bitchat struct BLEConnectionSchedulerTests { @Test func discoveryQueuesWeakSignalCandidates() { let scheduler = BLEConnectionScheduler(dynamicRSSIThreshold: -80) let now = Date() let candidate = makeCandidate(id: "p1", rssi: -85, now: now) let decision = scheduler.handleDiscovery( candidate, connectedOrConnectingCount: 0, existingState: nil, peripheralState: .disconnected, now: now ) #expect(decision == .queued) #expect(scheduler.candidateCount == 1) } @Test func discoveryQueuesAndSchedulesRetryWhenRateLimited() { let scheduler = BLEConnectionScheduler(connectRateLimitInterval: 1.0) let now = Date() scheduler.recordConnectionAttempt(at: now) let decision = scheduler.handleDiscovery( makeCandidate(id: "p1", rssi: -50, now: now), connectedOrConnectingCount: 0, existingState: nil, peripheralState: .disconnected, now: now.addingTimeInterval(0.25) ) guard case .scheduleRetry(let delay) = decision else { Issue.record("Expected scheduleRetry, got \(decision)") return } #expect(delay > 0) #expect(scheduler.candidateCount == 1) } @Test func nextCandidateSelectsBestScoredCandidate() { let scheduler = BLEConnectionScheduler() let now = Date() scheduler.enqueue(makeCandidate(id: "weak", rssi: -88, now: now)) scheduler.enqueue(makeCandidate(id: "strong", rssi: -60, now: now.addingTimeInterval(1))) let decision = scheduler.nextCandidate( connectedOrConnectingCount: 0, isAlreadyConnectingOrConnected: { _ in false }, now: now.addingTimeInterval(2) ) guard case .connect(let candidate) = decision else { Issue.record("Expected connect decision") return } #expect(candidate.peripheralID == "strong") } @Test func enqueueReplacesExistingPeripheralCandidate() { let scheduler = BLEConnectionScheduler() let now = Date() scheduler.enqueue(makeCandidate(id: "same", rssi: -90, now: now)) scheduler.enqueue(makeCandidate(id: "same", rssi: -55, now: now.addingTimeInterval(1))) let decision = scheduler.nextCandidate( connectedOrConnectingCount: 0, isAlreadyConnectingOrConnected: { _ in false }, now: now.addingTimeInterval(2) ) guard case .connect(let candidate) = decision else { Issue.record("Expected connect decision") return } #expect(scheduler.candidateCount == 0) #expect(candidate.peripheralID == "same") #expect(candidate.rssi == -55) } @Test func weakTimedOutCandidateIsRequeuedWithRetryDelay() { let scheduler = BLEConnectionScheduler( weakLinkCooldownSeconds: 30, weakLinkRSSICutoff: -90 ) let now = Date() scheduler.recordConnectionTimeout(peripheralID: "weak", at: now) scheduler.enqueue(makeCandidate(id: "weak", rssi: -95, now: now)) let decision = scheduler.nextCandidate( connectedOrConnectingCount: 0, isAlreadyConnectingOrConnected: { _ in false }, now: now.addingTimeInterval(5) ) guard case .retryAfter(let delay) = decision else { Issue.record("Expected retryAfter decision") return } #expect(delay == 15) #expect(scheduler.candidateCount == 1) } @Test func rssiThresholdTightensAfterRepeatedRecentTimeouts() { let scheduler = BLEConnectionScheduler() let now = Date() scheduler.recordConnectionTimeout(peripheralID: "p1", at: now) scheduler.recordConnectionTimeout(peripheralID: "p2", at: now) scheduler.recordConnectionTimeout(peripheralID: "p3", at: now) let threshold = scheduler.updateRSSIThreshold( connectedCount: 1, connectedOrConnectingLinkCount: 1, now: now.addingTimeInterval(1) ) #expect(threshold == TransportConfig.bleRSSIHighTimeoutThreshold) #expect(scheduler.dynamicRSSIThreshold == TransportConfig.bleRSSIHighTimeoutThreshold) } @Test func rssiThresholdTightensWhenCandidateQueueIsFull() { let scheduler = BLEConnectionScheduler(candidateCap: 2) let now = Date() scheduler.enqueue(makeCandidate(id: "p1", rssi: -65, now: now)) scheduler.enqueue(makeCandidate(id: "p2", rssi: -66, now: now)) let threshold = scheduler.updateRSSIThreshold( connectedCount: 1, connectedOrConnectingLinkCount: 1, now: now ) #expect(threshold == TransportConfig.bleRSSIConnectedThreshold) } } private func makeCandidate(id: String, rssi: Int, now: Date) -> BLEConnectionCandidate { BLEConnectionCandidate( peripheral: id, peripheralID: id, rssi: rssi, name: id, isConnectable: true, discoveredAt: now ) }