diff --git a/.github/workflows/ci_cd.yml b/.github/workflows/ci_cd.yml index 4cb40a4..e8626ab 100644 --- a/.github/workflows/ci_cd.yml +++ b/.github/workflows/ci_cd.yml @@ -66,9 +66,22 @@ jobs: swiftlint lint --no-cache --quiet --config .swiftlint.yml --reporter json ORLib > swiftlint.json || true - name: Build and test + timeout-minutes: 20 run: | xcodebuild test -project ORLib.xcodeproj -sdk iphoneos \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.5' -scheme ORLib -resultBundlePath ORLib.xcresult + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.6' -scheme ORLib \ + -test-timeouts-enabled YES \ + -default-test-execution-time-allowance 60 \ + -maximum-test-execution-time-allowance 60 \ + -resultBundlePath ORLib.xcresult + + - name: Upload XCResult bundle + uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3 + if: ${{ always() && !cancelled() && hashFiles('ORLib.xcresult/**') != '' }} + with: + name: ORLib-xcresult + path: ORLib.xcresult + if-no-files-found: error - name: Extract JUnit test report run: | diff --git a/ORLib.xcodeproj/project.pbxproj b/ORLib.xcodeproj/project.pbxproj index ae89f7b..d27d36c 100644 --- a/ORLib.xcodeproj/project.pbxproj +++ b/ORLib.xcodeproj/project.pbxproj @@ -71,15 +71,18 @@ 91F2165C2D887BCF008F9CA7 /* ORConfigChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */; }; 91F2165E2D887BCF008F9CA7 /* DeviceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */; }; 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */; }; - 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */; }; - 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */; }; - 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; - 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216512D887BCF008F9CA7 /* DeviceConnection.swift */; }; - 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216502D887BCF008F9CA7 /* CallbackChannel.swift */; }; - 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; - 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; + 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */; }; + 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */; }; + 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */; }; + 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216512D887BCF008F9CA7 /* DeviceConnection.swift */; }; + 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216502D887BCF008F9CA7 /* CallbackChannel.swift */; }; + 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216712D887BCF008F9CA7 /* TimeSource.swift */; }; + 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */; }; + 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */; }; + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */; }; + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */; }; + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */; }; + 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 91F216732D887C53008F9CA7 /* TestTimeSource.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -174,14 +177,17 @@ 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceRegistry.swift; sourceTree = ""; }; 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProvider.swift; sourceTree = ""; }; 91F216542D887BCF008F9CA7 /* LoopDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopDetector.swift; sourceTree = ""; }; - 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannel.swift; sourceTree = ""; }; - 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; - 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; - 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; - 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; + 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannel.swift; sourceTree = ""; }; + 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelProtocol.swift; sourceTree = ""; }; + 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDevice.swift; sourceTree = ""; }; + 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WifiProvisioner.swift; sourceTree = ""; }; + 91F216712D887BCF008F9CA7 /* TimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeSource.swift; sourceTree = ""; }; + 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceProvisionAPIMock.swift; sourceTree = ""; }; + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ESPProvisionProviderTest.swift; sourceTree = ""; }; + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORConfigChannelTest.swift; sourceTree = ""; }; + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPDeviceMock.swift; sourceTree = ""; }; + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ORESPProvisionManagerMock.swift; sourceTree = ""; }; + 91F216732D887C53008F9CA7 /* TestTimeSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestTimeSource.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -345,13 +351,15 @@ 91A9A8FA28BF6A4900DF8928 /* ConfigManagerTest.swift */, 91A9A90028BF6EA000DF8928 /* FileApiManager.swift */, 91AA79F228D628E9005B9913 /* Fixture.swift */, - 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */, - 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, - 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, - 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, - 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, - 9154E2A12D9EB3220055E565 /* URLTest.swift */, - ); + 91F216652D887C53008F9CA7 /* DeviceProvisionAPIMock.swift */, + 91F216662D887C53008F9CA7 /* ESPProvisionProviderTest.swift */, + 91F216672D887C53008F9CA7 /* ORConfigChannelTest.swift */, + 91F216682D887C53008F9CA7 /* ORESPDeviceMock.swift */, + 91F2166E2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift */, + 91F216732D887C53008F9CA7 /* TestTimeSource.swift */, + 9154E29F2D9EB0D50055E565 /* StringUtilsTest.swift */, + 9154E2A12D9EB3220055E565 /* URLTest.swift */, + ); path = Tests; sourceTree = ""; }; @@ -363,13 +371,14 @@ 91F216522D887BCF008F9CA7 /* DeviceRegistry.swift */, 91F216532D887BCF008F9CA7 /* ESPProvisionProvider.swift */, 91F216542D887BCF008F9CA7 /* LoopDetector.swift */, - 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */, - 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */, - 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */, - 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */, - 91DD55A02DE7306100957233 /* BLEPermissionsChecker.swift */, - 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */, - 915CCC962EF16456006B65AA /* DeviceProvision.swift */, + 91F216552D887BCF008F9CA7 /* ORConfigChannel.swift */, + 91F216562D887BCF008F9CA7 /* ORConfigChannelProtocol.swift */, + 91F216572D887BCF008F9CA7 /* ORESPDevice.swift */, + 91F216582D887BCF008F9CA7 /* WifiProvisioner.swift */, + 91F216712D887BCF008F9CA7 /* TimeSource.swift */, + 91DD55A02DE7306100957233 /* BLEPermissionsChecker.swift */, + 915CCC942EF163E8006B65AA /* DeviceProvisionAPI.swift */, + 915CCC962EF16456006B65AA /* DeviceProvision.swift */, ); path = ESPProvision; sourceTree = ""; @@ -547,13 +556,14 @@ 91F2165C2D887BCF008F9CA7 /* ORConfigChannel.swift in Sources */, 91DD55A12DE7306100957233 /* BLEPermissionsChecker.swift in Sources */, 91F2165E2D887BCF008F9CA7 /* DeviceRegistry.swift in Sources */, - 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */, - 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */, - 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */, - 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */, - 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */, - 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */, - 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */, + 91F2165F2D887BCF008F9CA7 /* ORESPDevice.swift in Sources */, + 91F216602D887BCF008F9CA7 /* WifiProvisioner.swift in Sources */, + 91F216612D887BCF008F9CA7 /* ESPProvisionProvider.swift in Sources */, + 91F216622D887BCF008F9CA7 /* ORConfigChannelProtocol.swift in Sources */, + 91F216632D887BCF008F9CA7 /* DeviceConnection.swift in Sources */, + 91F216702D887BCF008F9CA7 /* TimeSource.swift in Sources */, + 915CCC952EF163E8006B65AA /* DeviceProvisionAPI.swift in Sources */, + 91F216642D887BCF008F9CA7 /* CallbackChannel.swift in Sources */, 4CBDF2CA2AE2869D00C7D94C /* ApiManager.swift in Sources */, 4CBDF2CB2AE2869D00C7D94C /* ORTextInput.swift in Sources */, 914348322F05400200D4B3D3 /* ORConfigChannelProtocol.proto in Sources */, @@ -576,11 +586,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */, - 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, - 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, - 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, - 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, + 91F216692D887C53008F9CA7 /* DeviceProvisionAPIMock.swift in Sources */, + 91F2166A2D887C53008F9CA7 /* ESPProvisionProviderTest.swift in Sources */, + 91F2166B2D887C53008F9CA7 /* ORESPDeviceMock.swift in Sources */, + 91F2166C2D887C53008F9CA7 /* ORConfigChannelTest.swift in Sources */, + 91F2166D2D887C53008F9CA7 /* ORESPProvisionManagerMock.swift in Sources */, + 91F216722D887C53008F9CA7 /* TestTimeSource.swift in Sources */, + 91A9A90128BF6EA000DF8928 /* FileApiManager.swift in Sources */, 9154E2A22D9EB3220055E565 /* URLTest.swift in Sources */, 9154E2A02D9EB0D50055E565 /* StringUtilsTest.swift in Sources */, 91A9A8FB28BF6A4900DF8928 /* ConfigManagerTest.swift in Sources */, diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift index 1fe8ed8..dd568f5 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceProvision.swift @@ -34,14 +34,16 @@ class DeviceProvision { private var deviceConnection: DeviceConnection? var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource var apiURL: URL var deviceProvisionAPI: DeviceProvisionAPI var backendConnectionTimeout: TimeInterval = 60 - init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, apiURL: URL) { + init (deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, apiURL: URL, timeSource: any TimeSource = SystemTimeSource()) { self.deviceConnection = deviceConnection self.callbackChannel = callbackChannel + self.timeSource = timeSource self.apiURL = apiURL self.deviceProvisionAPI = DeviceProvisionAPIREST(apiURL: apiURL) } @@ -64,22 +66,21 @@ class DeviceProvision { try await deviceConnection!.sendOpenRemoteConfig(mqttBrokerUrl: mqttURL, mqttUser: userName, mqttPassword: password, assetId: assetId) var status = BackendConnectionStatus.connecting - // TODO: what about other status values ? Is status connecting while it connects ? or disconnected ? -> test with real device - let startTime = Date.now + let startTime = timeSource.now while status != .connected { - if Date.now.timeIntervalSince(startTime) > backendConnectionTimeout { + if timeSource.now - startTime > backendConnectionTimeout { sendProvisionDeviceStatus(connected: false, error: .timeoutError, errorMessage: "Timeout waiting for backend to get connected") return } status = try await deviceConnection!.getBackendConnectionStatus() Self.logger.info("ModuleOne reported connection status \(String(describing: status))") + if status == .failed { + sendProvisionDeviceStatus(connected: false, error: .communicationError, errorMessage: "Backend connection failed") + return + } } - // TODO: review, if we're out of the loop, this is always true - - if status == .connected { - sendProvisionDeviceStatus(connected: true) - } + sendProvisionDeviceStatus(connected: true) } catch let error as ESPProviderError { sendProvisionDeviceStatus(connected: false, error: error.errorCode, errorMessage: error.errorMessage) } catch let error as RandomPasswordGeneratorError { diff --git a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift index 68e4371..3137a23 100644 --- a/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift +++ b/ORLib/ConsoleProviders/ESPProvision/DeviceRegistry.swift @@ -64,13 +64,14 @@ class DeviceRegistry { var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource private var loopDetector: LoopDetector var searchDeviceTimeout: TimeInterval { get { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchDeviceMaxIterations) + self.loopDetector.timeout = newValue } } @@ -79,7 +80,7 @@ class DeviceRegistry { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: newValue) + self.loopDetector.maxIterations = newValue } } @@ -92,8 +93,9 @@ class DeviceRegistry { public private(set) var bleScanning = false - init(searchDeviceTimeout: TimeInterval, searchDeviceMaxIterations: Int) { - self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: searchDeviceMaxIterations) + init(searchDeviceTimeout: TimeInterval, searchDeviceMaxIterations: Int, timeSource: any TimeSource = SystemTimeSource()) { + self.timeSource = timeSource + self.loopDetector = LoopDetector(timeout: searchDeviceTimeout, maxIterations: searchDeviceMaxIterations, timeSource: timeSource) } func enable() { diff --git a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift index 75aeeff..12b3ac6 100644 --- a/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift +++ b/ORLib/ConsoleProviders/ESPProvision/ESPProvisionProvider.swift @@ -36,6 +36,7 @@ class ESPProvisionProvider: NSObject { private var searchWifiTimeout: TimeInterval = 120 private var searchWifiMaxIterations = 25 + private let timeSource: any TimeSource private let deviceRegistry: DeviceRegistry private var deviceConnection: DeviceConnection? @@ -48,7 +49,7 @@ class ESPProvisionProvider: NSObject { typealias DeviceProvisionFactory = () -> DeviceProvision private lazy var deviceProvisionFactory: DeviceProvisionFactory = { - DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: self.apiURL) + DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: self.apiURL, timeSource: self.timeSource) } private var callbackChannel: CallbackChannel? @@ -63,13 +64,20 @@ class ESPProvisionProvider: NSObject { } } - public override init() { - self.deviceRegistry = DeviceRegistry(searchDeviceTimeout: searchDeviceTimeout, searchDeviceMaxIterations: searchDeviceMaxIterations) + init(timeSource: any TimeSource = SystemTimeSource()) { + self.timeSource = timeSource + self.deviceRegistry = DeviceRegistry(searchDeviceTimeout: searchDeviceTimeout, + searchDeviceMaxIterations: searchDeviceMaxIterations, + timeSource: timeSource) super.init() } + public override convenience init() { + self.init(timeSource: SystemTimeSource()) + } + public convenience init(apiURL: URL = URL(string: "http://localhost:8080/api/master")!) { - self.init() + self.init(timeSource: SystemTimeSource()) self.apiURL = apiURL } @@ -174,7 +182,8 @@ class ESPProvisionProvider: NSObject { wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, callbackChannel: callbackChannel, searchWifiTimeout: searchWifiTimeout, - searchWifiMaxIterations: searchWifiMaxIterations) + searchWifiMaxIterations: searchWifiMaxIterations, + timeSource: timeSource) } wifiProvisioner!.startWifiScan() } @@ -188,7 +197,8 @@ class ESPProvisionProvider: NSObject { wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, callbackChannel: callbackChannel, searchWifiTimeout: searchWifiTimeout, - searchWifiMaxIterations: searchWifiMaxIterations) + searchWifiMaxIterations: searchWifiMaxIterations, + timeSource: timeSource) } wifiProvisioner?.sendWifiConfiguration(ssid: ssid, password: password) } @@ -228,8 +238,9 @@ extension ESPProvisionProvider { public convenience init(searchDeviceTimeout: TimeInterval = 120, searchDeviceMaxIterations: Int = 25, searchWifiTimeout: TimeInterval = 120, searchWifiMaxIterations: Int = 25, deviceProvisionAPI: DeviceProvisionAPI? = nil, backendConnectionTimeout: TimeInterval? = nil, - apiURL: URL = URL(string: "http://localhost:8080/api/master")!) { - self.init() + apiURL: URL = URL(string: "http://localhost:8080/api/master")!, + timeSource: (any TimeSource)? = nil) { + self.init(timeSource: timeSource ?? SystemTimeSource()) self.searchDeviceTimeout = searchDeviceTimeout self.searchDeviceMaxIterations = searchDeviceMaxIterations self.deviceRegistry.searchDeviceMaxIterations = searchDeviceMaxIterations @@ -239,7 +250,7 @@ extension ESPProvisionProvider { self.searchWifiMaxIterations = searchWifiMaxIterations self.deviceProvisionFactory = { - let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: apiURL) + let deviceProvision = DeviceProvision(deviceConnection: self.deviceConnection, callbackChannel: self.callbackChannel, apiURL: apiURL, timeSource: self.timeSource) if let deviceProvisionAPI { deviceProvision.deviceProvisionAPI = deviceProvisionAPI } diff --git a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift index 92cc50f..c6aa132 100644 --- a/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift +++ b/ORLib/ConsoleProviders/ESPProvision/LoopDetector.swift @@ -20,19 +20,21 @@ import Foundation class LoopDetector { - let timeout: TimeInterval - let maxIterations: Int + var timeout: TimeInterval + var maxIterations: Int - private var startTime: Date? + private let timeSource: any TimeSource + private var startTime: TimeInterval? private var iterationCount = 0 - init(timeout: TimeInterval = 120, maxIterations: Int = 25) { + init(timeout: TimeInterval = 120, maxIterations: Int = 25, timeSource: any TimeSource = SystemTimeSource()) { self.timeout = timeout self.maxIterations = maxIterations + self.timeSource = timeSource } func reset() { - startTime = .now + startTime = timeSource.now iterationCount = 0 } @@ -44,7 +46,7 @@ class LoopDetector { guard let startTime else { return true } - if Date.now.timeIntervalSince(startTime) > timeout { + if timeSource.now - startTime > timeout { return true } return false diff --git a/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift b/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift new file mode 100644 index 0000000..0bc292d --- /dev/null +++ b/ORLib/ConsoleProviders/ESPProvision/TimeSource.swift @@ -0,0 +1,40 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation +import Darwin + +protocol TimeSource { + var now: TimeInterval { get } +} + +struct SystemTimeSource: TimeSource { + private static let timebaseInfo: mach_timebase_info_data_t = { + var timebaseInfo = mach_timebase_info_data_t() + mach_timebase_info(&timebaseInfo) + return timebaseInfo + }() + + // Use continuous system time so elapsed-time checks continue to advance while the device sleeps. + var now: TimeInterval { + let elapsedTicks = mach_continuous_time() + let elapsedNanoseconds = Double(elapsedTicks) * Double(Self.timebaseInfo.numer) / Double(Self.timebaseInfo.denom) + return elapsedNanoseconds / 1_000_000_000 + } +} diff --git a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift index a863f5c..26c4c7a 100644 --- a/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift +++ b/ORLib/ConsoleProviders/ESPProvision/WifiProvisioner.swift @@ -30,13 +30,14 @@ class WifiProvisioner { private var deviceConnection: DeviceConnection? var callbackChannel: CallbackChannel? + private let timeSource: any TimeSource private var loopDetector: LoopDetector var searchWifiTimeout: TimeInterval { get { loopDetector.timeout } set { - self.loopDetector = LoopDetector(timeout: newValue, maxIterations: searchWifiMaxIterations) + self.loopDetector.timeout = newValue } } @@ -45,7 +46,7 @@ class WifiProvisioner { loopDetector.maxIterations } set { - self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: newValue) + self.loopDetector.maxIterations = newValue } } @@ -59,10 +60,15 @@ class WifiProvisioner { private var wifiNetworks = [ESPWifiNetwork]() - init(deviceConnection: DeviceConnection?, callbackChannel: CallbackChannel?, searchWifiTimeout: TimeInterval = 120, searchWifiMaxIterations: Int = 25) { + init(deviceConnection: DeviceConnection?, + callbackChannel: CallbackChannel?, + searchWifiTimeout: TimeInterval = 120, + searchWifiMaxIterations: Int = 25, + timeSource: any TimeSource = SystemTimeSource()) { self.deviceConnection = deviceConnection self.callbackChannel = callbackChannel - self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: searchWifiMaxIterations) + self.timeSource = timeSource + self.loopDetector = LoopDetector(timeout: searchWifiTimeout, maxIterations: searchWifiMaxIterations, timeSource: timeSource) } public func startWifiScan() { diff --git a/Tests/ESPProvisionProviderTest.swift b/Tests/ESPProvisionProviderTest.swift index a449e39..3ab0961 100644 --- a/Tests/ESPProvisionProviderTest.swift +++ b/Tests/ESPProvisionProviderTest.swift @@ -24,24 +24,178 @@ import Testing @testable import ORLib -class ESPORProvisionManagerMock: ORESPProvisionManager { - var searchESPDevicesCallCount = 0 - var stopESPDevicesSearchCallCount = 0 +private final class CallbackRecorder { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 - var scanDevicesDuration: TimeInterval = 0 + private enum WaitCondition { + case action(name: String, count: Int) + case orderedActions([String]) + } + + private struct WaitResult { + let matchedMessages: [[String:Any]] + let allMessagesAtMatchTime: [[String:Any]] + } + + private let lock = NSLock() + private var messages = [[String:Any]]() + private var waitCondition: WaitCondition? + private var continuation: CheckedContinuation? + private var waiterId: UUID? + + func record(_ data: [String:Any]) { + var continuationToResume: CheckedContinuation? + var waitResult: WaitResult? + + lock.lock() + messages.append(data) + if let waitCondition, + let continuation, + let matchedMessages = matchedMessages(for: waitCondition, in: messages) { + self.waitCondition = nil + self.continuation = nil + continuationToResume = continuation + waitResult = WaitResult(matchedMessages: matchedMessages, allMessagesAtMatchTime: messages) + } + lock.unlock() + + if let continuationToResume, let waitResult { + continuationToResume.resume(returning: waitResult) + } + } + + func waitForFirstMessage(matchingAction action: String, after trigger: @escaping () -> Void) async -> [String:Any] { + let messages = await waitForMessages(matchingAction: action, count: 1, after: trigger) + return messages[0] + } + + func waitForMessages(matchingAction action: String, count: Int, after trigger: (() -> Void)? = nil) async -> [[String:Any]] { + let waitResult = await wait(until: .action(name: action, count: count), after: trigger) + return waitResult.matchedMessages + } + + func waitForMessages(matchingActions actions: [String], after trigger: (() -> Void)? = nil) async -> [[String:Any]] { + let waitResult = await wait(until: .orderedActions(actions), after: trigger) + recordUnexpectedMessages(in: waitResult.allMessagesAtMatchTime, whileMatchingActions: actions) + return waitResult.matchedMessages + } - var mockDevices = [ORESPDeviceMock()] + func messageCount() -> Int { + lock.lock() + defer { lock.unlock() } + return messages.count + } + + func messageCount(matchingAction action: String) -> Int { + lock.lock() + defer { lock.unlock() } + return messages.filter { ($0["action"] as? String) == action }.count + } - func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { - searchESPDevicesCallCount += 1 - if scanDevicesDuration > 0 { - try await Task.sleep(nanoseconds: UInt64(scanDevicesDuration * Double(NSEC_PER_SEC))) + private func wait(until waitCondition: WaitCondition, after trigger: (() -> Void)? = nil) async -> WaitResult { + await withCheckedContinuation { continuation in + let waiterId = UUID() + + lock.lock() + if let matchedMessages = matchedMessages(for: waitCondition, in: messages) { + let waitResult = WaitResult(matchedMessages: matchedMessages, allMessagesAtMatchTime: messages) + lock.unlock() + continuation.resume(returning: waitResult) + return + } + self.waitCondition = waitCondition + self.continuation = continuation + self.waiterId = waiterId + lock.unlock() + + Task { [weak self] in + await self?.timeoutWait(waiterId: waiterId, waitCondition: waitCondition) + } + trigger?() } - return mockDevices } - func stopESPDevicesSearch() { - stopESPDevicesSearchCallCount += 1 + private func timeoutWait(waiterId: UUID, waitCondition: WaitCondition) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var continuationToResume: CheckedContinuation? + var waitResult: WaitResult? + var observedActions = [String]() + + lock.lock() + if self.waiterId == waiterId { + let allMessagesAtMatchTime = messages + continuationToResume = continuation + waitResult = WaitResult(matchedMessages: matchedMessages(for: waitCondition, in: messages) ?? [], + allMessagesAtMatchTime: allMessagesAtMatchTime) + observedActions = allMessagesAtMatchTime.compactMap { $0["action"] as? String } + self.waitCondition = nil + self.continuation = nil + self.waiterId = nil + } + lock.unlock() + + if let continuationToResume, let waitResult { + Issue.record("Timed out waiting for \(description(for: waitCondition)); observed actions \(observedActions)") + continuationToResume.resume(returning: waitResult) + } + } + + private func matchedMessages(for waitCondition: WaitCondition, in recordedMessages: [[String:Any]]) -> [[String:Any]]? { + switch waitCondition { + case let .action(name, count): + let matchingMessages = recordedMessages.filter { ($0["action"] as? String) == name } + guard matchingMessages.count >= count else { + return nil + } + return Array(matchingMessages.prefix(count)) + + case let .orderedActions(actions): + return orderedMessages(matchingActions: actions, in: recordedMessages) + } + } + + private func description(for waitCondition: WaitCondition) -> String { + switch waitCondition { + case let .action(name, count): + return "\(count) message(s) matching action \(name)" + case let .orderedActions(actions): + return "ordered actions \(actions)" + } + } + + private func orderedMessages(matchingActions actions: [String], in recordedMessages: [[String:Any]]) -> [[String:Any]]? { + var matchingMessages = [[String:Any]]() + var actionIndex = 0 + + for message in recordedMessages { + guard actionIndex < actions.count else { + break + } + + if (message["action"] as? String) == actions[actionIndex] { + matchingMessages.append(message) + actionIndex += 1 + } + } + + return actionIndex == actions.count ? matchingMessages : nil + } + + private func recordUnexpectedMessages(in recordedMessages: [[String:Any]], whileMatchingActions actions: [String]) { + var actionIndex = 0 + + for message in recordedMessages { + guard actionIndex < actions.count else { + break + } + + if (message["action"] as? String) == actions[actionIndex] { + actionIndex += 1 + } else { + Issue.record("Received an unexpected action: \(message)") + } + } } } @@ -50,35 +204,36 @@ struct ESPProvisionProviderTest { // MARK: device scan @Test func searchDeviceSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() - provider.startDevicesScan() - } + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let receivedData = receivedMessages[0] - // Even if I wait for a moment, if no new devices are discovered, I should only received the callback once - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + espProvisionMock.completeNextDeviceScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 3) + espProvisionMock.completeNextDeviceScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 4) - #expect(espProvisionMock.searchESPDevicesCallCount >= 1) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount >= 3) + #expect(callbackRecorder.messageCount() == 1) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startBleScan) @@ -92,38 +247,37 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesMultipleBatches() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - var firstReceivedData: [String:Any] = [:] + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - firstReceivedData = data - espProvisionMock.mockDevices.append(ORESPDeviceMock(name: "TestDevice2")) - continuation.resume() - } - } - provider.startDevicesScan() - } + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let firstReceivedData = firstReceivedMessages[0] + + espProvisionMock.mockDevices.append(ORESPDeviceMock(name: "TestDevice2")) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + espProvisionMock.completeNextDeviceScan() - // Need to wait a moment for second callback to be received - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 2) + let receivedData = receivedMessages[1] #expect(espProvisionMock.searchESPDevicesCallCount >= 2) - #expect(receivedCallbackCount == 2) + #expect(callbackRecorder.messageCount() == 2) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) #expect(firstReceivedData["action"] as? String == Actions.startBleScan) @@ -149,10 +303,11 @@ struct ESPProvisionProviderTest { } @Test func testDisableStopsDeviceSearch() async throws { - let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -163,15 +318,9 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - receivedData = data - continuation.resume() - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { _ = provider.disable() } @@ -186,10 +335,11 @@ struct ESPProvisionProviderTest { } @Test func testStopDeviceSearch() async throws { - let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) @@ -200,19 +350,9 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) - - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() } @@ -228,24 +368,15 @@ struct ESPProvisionProviderTest { } @Test func testStopDeviceSearchNotStarted() async throws { - let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.5 + let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopBleScan) { provider.stopDevicesScan() } @@ -259,39 +390,32 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesTimesout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true espProvisionMock.mockDevices = [] - espProvisionMock.scanDevicesDuration = 0.05 + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 0.2) + let provider = ESPProvisionProvider(searchDeviceTimeout: 0.2, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startDevicesScan() - #expect(provider.bleScanning) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) + provider.startDevicesScan() + #expect(provider.bleScanning) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + timeSource.advance(by: 0.3) + espProvisionMock.completeNextDeviceScan(with: []) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopBleScan, count: 1) + let receivedData = receivedMessages[0] - // Ideally should be == 4 but too brittle based on timing during test run - #expect(espProvisionMock.searchESPDevicesCallCount >= 2 && espProvisionMock.searchESPDevicesCallCount <= 4) - #expect(receivedCallbackCount == 1) + #expect(espProvisionMock.searchESPDevicesCallCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -300,38 +424,33 @@ struct ESPProvisionProviderTest { } @Test func searchDevicesMaximumIteration() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true espProvisionMock.mockDevices = [] - espProvisionMock.scanDevicesDuration = 0.05 + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 120, searchDeviceMaxIterations: 5) + let provider = ESPProvisionProvider(searchDeviceTimeout: 120, searchDeviceMaxIterations: 5, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - provider.startDevicesScan() - #expect(provider.bleScanning) + provider.startDevicesScan() + #expect(provider.bleScanning) + for i in 1...5 { + await espProvisionMock.waitForDeviceSearchRequests(atLeast: i) + espProvisionMock.completeNextDeviceScan(with: []) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopBleScan, count: 1) + let receivedData = receivedMessages[0] #expect(espProvisionMock.searchESPDevicesCallCount == 5) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.bleScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -339,68 +458,83 @@ struct ESPProvisionProviderTest { #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) } + @Test func searchDeviceSettingsChangeDuringScanDoesNotTimeout() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + espProvisionMock.mockDevices = [] + let timeSource = TestTimeSource() + + let callbackRecorder = CallbackRecorder() + let callbackChannel = CallbackChannel(sendDataCallback: { data in + callbackRecorder.record(data) + }, provider: Providers.espprovision) + + let deviceRegistry = DeviceRegistry(searchDeviceTimeout: 120, + searchDeviceMaxIterations: Int.max, + timeSource: timeSource) + deviceRegistry.callbackChannel = callbackChannel + deviceRegistry.provisionManager = espProvisionMock + defer { deviceRegistry.stopDevicesScan(sendMessage: false) } + + deviceRegistry.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + + deviceRegistry.searchDeviceTimeout = 60 + deviceRegistry.searchDeviceMaxIterations = Int.max + espProvisionMock.completeNextDeviceScan(with: []) + + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + + #expect(deviceRegistry.bleScanning) + #expect(callbackRecorder.messageCount(matchingAction: Actions.stopBleScan) == 0) + } + @Test func multipleSearchDevices() async throws { - let espProvisionMock = ESPORProvisionManagerMock() - espProvisionMock.scanDevicesDuration = 0.05 + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) + defer { provider.stopDevicesScan() } - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startDevicesScan() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let firstReceivedData = firstReceivedMessages[0] #expect(espProvisionMock.searchESPDevicesCallCount >= 1) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 1) - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.startBleScan) + #expect(firstReceivedData["provider"] as? String == Providers.espprovision) + #expect(firstReceivedData["action"] as? String == Actions.startBleScan) - try #require(receivedData["devices"] as? [[String:Any]] != nil) - var devices = receivedData["devices"] as! [[String:Any]] + try #require(firstReceivedData["devices"] as? [[String:Any]] != nil) + var devices = firstReceivedData["devices"] as! [[String:Any]] #expect(devices.count == 1) var device = devices.first! #expect(device["name"] as? String == "TestDevice") #expect(device["id"] != nil) // Calling it a second time while the first is still on-going - receivedData = [:] - receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startDevicesScan() - } + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + espProvisionMock.completeNextDeviceScan() - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 2) + let receivedData = receivedMessages[1] #expect(espProvisionMock.searchESPDevicesCallCount >= 2) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 2) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startBleScan) @@ -416,30 +550,43 @@ struct ESPProvisionProviderTest { // MARK: Device connection @Test func connectToDevice() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + let device = (firstReceivedMessages[0]["devices"] as! [[String:Any]]).first! + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) #expect(provider.bleScanning) - let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { + let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [Actions.startBleScan, Actions.stopBleScan, Actions.connectToDevice]) { provider.connectTo(deviceId: device["id"] as! String) } #expect(provider.bleScanning == false) #expect(espProvisionMock.stopESPDevicesSearchCallCount == 1) - #expect(receivedMessages.count == 2) + #expect(receivedMessages.count == 3) - var receivedData = receivedMessages[0] + var receivedData = receivedMessages[1] #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.stopBleScan) - receivedData = receivedMessages[1] + receivedData = receivedMessages[2] #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.connectToDevice) #expect(receivedData["id"] as? String == device["id"] as? String) @@ -447,27 +594,31 @@ struct ESPProvisionProviderTest { } @Test func connectToDeviceFailsForInvalidId() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) - #expect(provider.bleScanning) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - var receivedData: [String:Any] = [:] + provider.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + espProvisionMock.completeNextDeviceScan() + _ = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 2) + #expect(provider.bleScanning) - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == Actions.connectToDevice { - receivedData = data - continuation.resume() - } - } + let receivedMessages = await callbackRecorder.waitForMessages(matchingActions: [Actions.startBleScan, Actions.stopBleScan, Actions.connectToDevice]) { provider.connectTo(deviceId: "INVALID_ID") } + let receivedData = receivedMessages[2] #expect(espProvisionMock.stopESPDevicesSearchCallCount == 1) #expect(provider.bleScanning == false) @@ -487,14 +638,15 @@ struct ESPProvisionProviderTest { // MARK: Wifi scan @Test func startWifiScanNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -509,38 +661,41 @@ struct ESPProvisionProviderTest { } @Test func wifiScan() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] + defer { provider.stopWifiScan() } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startWifiScan() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - // Even if I wait for a moment, if no new devices are discovered, I should only received the callback once - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() - #expect(mockDevice.scanWifiListCallCount >= 1) - #expect(provider.wifiScanning) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + let receivedData = receivedMessages[0] + + await mockDevice.waitForWifiScanStarts(atLeast: 2) + mockDevice.completeNextWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 3) + mockDevice.completeNextWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 4) + + #expect(mockDevice.scanWifiListCallCount >= 3) #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.startWifiScan) @@ -551,43 +706,52 @@ struct ESPProvisionProviderTest { let network = networks.first! #expect(network["ssid"] as? String == "SSID-1") #expect(network["signalStrength"] as? Int32 == -50) + #expect(callbackRecorder.messageCount() == 1) } @Test func wifiScanUpdatedRssi() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.1 - mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + mockDevice.manualWifiScans = true + let initialNetworks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + let updatedNetworks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] + mockDevice.networks = initialNetworks espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] + defer { provider.stopWifiScan() } - var firstReceivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - firstReceivedData = data - mockDevice.networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -60)] - continuation.resume() - } - } - provider.startWifiScan() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - // I need to wait a moment for the second callback to be received - try await Task.sleep(nanoseconds: UInt64(0.2 * Double(NSEC_PER_SEC))) - #expect(provider.wifiScanning) + provider.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + let firstReceivedData = await callbackRecorder.waitForFirstMessage(matchingAction: Actions.startWifiScan) { + mockDevice.completeNextWifiScan(networks: initialNetworks) + } + + mockDevice.networks = updatedNetworks + await mockDevice.waitForWifiScanStarts(atLeast: 2) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 2) { + mockDevice.completeNextWifiScan(networks: updatedNetworks) + } + let receivedData = receivedMessages[1] + + await mockDevice.waitForWifiScanStarts(atLeast: 3) + mockDevice.completeNextWifiScan(networks: updatedNetworks) + await mockDevice.waitForWifiScanStarts(atLeast: 4) + + #expect(mockDevice.scanWifiListCallCount >= 3) #expect(firstReceivedData["provider"] as? String == Providers.espprovision) #expect(firstReceivedData["action"] as? String == Actions.startWifiScan) @@ -608,47 +772,42 @@ struct ESPProvisionProviderTest { let network2 = networks2.first! #expect(network2["ssid"] as? String == "SSID-1") #expect(network2["signalStrength"] as? Int32 == -60) + #expect(callbackRecorder.messageCount() == 2) } @Test func wifiScanTimesout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.05 + mockDevice.manualWifiScans = true mockDevice.networks = [] espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 0.2) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 0.2, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startWifiScan() - #expect(provider.wifiScanning) + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) + provider.startWifiScan() + #expect(provider.wifiScanning) + await mockDevice.waitForWifiScanStarts(atLeast: 1) + timeSource.advance(by: 0.3) + mockDevice.completeNextWifiScan(networks: []) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopWifiScan, count: 1) + let receivedData = receivedMessages[0] - // Ideally should be == 4 but too brittle based on timing during test run - #expect(mockDevice.scanWifiListCallCount >= 2 && mockDevice.scanWifiListCallCount <= 4) - #expect(receivedCallbackCount == 1) + #expect(mockDevice.scanWifiListCallCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -657,43 +816,39 @@ struct ESPProvisionProviderTest { } @Test func wifiScanMaximumIterations() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.05 + mockDevice.manualWifiScans = true mockDevice.networks = [] espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 120, searchWifiMaxIterations: 5) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 120, searchWifiMaxIterations: 5, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - provider.startWifiScan() - #expect(provider.wifiScanning) + provider.startWifiScan() + #expect(provider.wifiScanning) + for i in 1...5 { + await mockDevice.waitForWifiScanStarts(atLeast: i) + mockDevice.completeNextWifiScan(networks: []) } - // Wait long enough so scan can stop - try await Task.sleep(nanoseconds: UInt64(0.3 * Double(NSEC_PER_SEC))) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.stopWifiScan, count: 1) + let receivedData = receivedMessages[0] #expect(mockDevice.scanWifiListCallCount == 5) - #expect(receivedCallbackCount == 1) + #expect(callbackRecorder.messageCount() == 1) #expect(provider.wifiScanning == false) #expect(receivedData["provider"] as? String == Providers.espprovision) @@ -701,18 +856,71 @@ struct ESPProvisionProviderTest { #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) } + @Test func wifiScanSettingsChangeDuringScanDoesNotTimeout() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + espProvisionMock.manualDeviceScans = true + let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true + espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() + + let callbackRecorder = CallbackRecorder() + let callbackChannel = CallbackChannel(sendDataCallback: { data in + callbackRecorder.record(data) + }, provider: Providers.espprovision) + + let deviceRegistry = DeviceRegistry(searchDeviceTimeout: 1, + searchDeviceMaxIterations: Int.max, + timeSource: timeSource) + deviceRegistry.callbackChannel = callbackChannel + deviceRegistry.provisionManager = espProvisionMock + defer { deviceRegistry.stopDevicesScan(sendMessage: false) } + + deviceRegistry.startDevicesScan() + await espProvisionMock.waitForDeviceSearchRequests(atLeast: 1) + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startBleScan, count: 1) { + espProvisionMock.completeNextDeviceScan() + } + deviceRegistry.stopDevicesScan(sendMessage: false) + + let devices = receivedMessages[0]["devices"] as! [[String:Any]] + let deviceId = devices[0]["id"] as! String + let deviceConnection = DeviceConnection(deviceRegistry: deviceRegistry, callbackChannel: callbackChannel) + deviceConnection.connectTo(deviceId: deviceId) + + let wifiProvisioner = WifiProvisioner(deviceConnection: deviceConnection, + callbackChannel: callbackChannel, + searchWifiTimeout: 120, + searchWifiMaxIterations: Int.max, + timeSource: timeSource) + defer { wifiProvisioner.stopWifiScan(sendMessage: false) } + + wifiProvisioner.startWifiScan() + await mockDevice.waitForWifiScanStarts(atLeast: 1) + + wifiProvisioner.searchWifiTimeout = 60 + wifiProvisioner.searchWifiMaxIterations = Int.max + mockDevice.completeNextWifiScan(networks: []) + + await mockDevice.waitForWifiScanStarts(atLeast: 2) + + #expect(wifiProvisioner.wifiScanning) + #expect(callbackRecorder.messageCount(matchingAction: Actions.stopWifiScan) == 0) + } + @Test func testStopWifiScan() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.5 + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -722,18 +930,9 @@ struct ESPProvisionProviderTest { provider.sendDataCallback = { _ in receivedDeviceInformation = true } - try await Task.sleep(nanoseconds: UInt64(0.1 * Double(NSEC_PER_SEC))) + await mockDevice.waitForWifiScanStarts(atLeast: 1) - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.stopWifiScan) { provider.stopWifiScan() } #expect(mockDevice.scanWifiListCallCount == 1) @@ -748,17 +947,17 @@ struct ESPProvisionProviderTest { } @Test func testStopWifiScanNotStarted() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() - mockDevice.scanWifiDuration = 0.5 espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) @@ -780,34 +979,32 @@ struct ESPProvisionProviderTest { // TODO: start device scan during wifi search @Test func sendWifiConfigurationSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startWifiScan() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } + + provider.startWifiScan() #expect(provider.wifiScanning) + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + var receivedData = firstReceivedMessages[0] let network = (receivedData["networks"] as! [[String:Any]]).first! @@ -851,34 +1048,33 @@ struct ESPProvisionProviderTest { ]) func sendWifiConfigurationProvisionErrors(errorTupple: (ESPProvisionError, ESPProviderErrorCode)) async throws { let (provisionError, providerErrorCode) = errorTupple - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualWifiScans = true mockDevice.provisionError = provisionError espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - - provider.startWifiScan() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } + + provider.startWifiScan() #expect(provider.wifiScanning) + await mockDevice.waitForWifiScanStarts(atLeast: 1) + mockDevice.completeNextWifiScan() + + let firstReceivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.startWifiScan, count: 1) + var receivedData = firstReceivedMessages[0] let network = (receivedData["networks"] as! [[String:Any]]).first! @@ -908,16 +1104,17 @@ struct ESPProvisionProviderTest { } @Test func sendWifiConfigurationNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -934,8 +1131,10 @@ struct ESPProvisionProviderTest { @Test func provisionDeviceSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -947,50 +1146,34 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatus = Response.BackendConnectionStatus() expectedBackendConnectionStatus.status = .connected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatus))) espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.provisionDevice) - #expect(receivedData["connected"] as? Bool == true) + provider.provisionDevice(userToken: "OAUTH_TOKEN") - #expect(mockDevice.receivedData.count == 3) - - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -998,14 +1181,25 @@ struct ESPProvisionProviderTest { #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) #expect(openRemoteConfig.assetID == "AssetID") - } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - request = try Request(serializedBytes: mockDevice.receivedData[2]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 2) #expect(request.id == "2") #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatus))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) + + #expect(mockDevice.receivedData.count == 3) #expect(deviceProvisionAPIMock.provisionCallCount == 1) #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) @@ -1015,8 +1209,10 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceSuccessAfterMultipleStatusRequest() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1031,52 +1227,34 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() expectedBackendConnectionStatusFailure.status = .disconnected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "3", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusSuccess))) espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.provisionDevice) - #expect(receivedData["connected"] as? Bool == true) + provider.provisionDevice(userToken: "OAUTH_TOKEN") - try #require(mockDevice.receivedData.count == 5) - - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -1084,17 +1262,32 @@ struct ESPProvisionProviderTest { #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) #expect(openRemoteConfig.assetID == "AssetID") - } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) for i in 2...4 { - request = try Request(serializedBytes: mockDevice.receivedData[i]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: i) #expect(request.id == String(i)) #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + + let response = i == 4 + ? ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusSuccess)) + : ORConfigChannelTest.responseData(id: String(i), body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)) + mockDevice.completeNextSendDataRequest(data: response) } + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == true) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) + + try #require(mockDevice.receivedData.count == 5) + #expect(deviceProvisionAPIMock.provisionCallCount == 1) #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) #expect(deviceProvisionAPIMock.receivedDeviceId == expectedDeviceInfo.deviceID) @@ -1103,8 +1296,9 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceFailureTimeout() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true var expectedDeviceInfo = Response.DeviceInfo() expectedDeviceInfo.deviceID = "123456789ABC" @@ -1116,54 +1310,117 @@ struct ESPProvisionProviderTest { var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() expectedBackendConnectionStatusFailure.status = .disconnected - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "3", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) - mockDevice.addMockData(ORConfigChannelTest.responseData(id: "4", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure)), delay: 0.2) espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock, backendConnectionTimeout: 0.5) + deviceProvisionAPI: deviceProvisionAPIMock, backendConnectionTimeout: 0.5, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - provider.provisionDevice(userToken: "OAUTH_TOKEN") + provider.provisionDevice(userToken: "OAUTH_TOKEN") + + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) + #expect(request.id == "0") + #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) + + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) + #expect(request.id == "1") + if case let .openRemoteConfig(openRemoteConfig) = request.body { + #expect(openRemoteConfig.realm == "master") + #expect(openRemoteConfig.mqttBrokerURL == "mqtts://localhost:8883") + #expect(openRemoteConfig.user == expectedDeviceInfo.deviceID.lowercased(with: Locale(identifier: "en"))) + #expect(openRemoteConfig.mqttPassword == deviceProvisionAPIMock.receivedPassword) + #expect(openRemoteConfig.assetID == "AssetID") + + } else { + Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) + + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 2) + #expect(request.id == "2") + #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + timeSource.advance(by: 0.6) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.provisionDevice) #expect(receivedData["connected"] as? Bool != nil) #expect(receivedData["connected"] as? Bool == false) #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.timeoutError.rawValue) + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) - try #require(mockDevice.receivedData.count == 5) + #expect(mockDevice.receivedData.count == 3) - var request = try Request(serializedBytes: mockDevice.receivedData[0]) + #expect(deviceProvisionAPIMock.provisionCallCount == 1) + #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) + #expect(deviceProvisionAPIMock.receivedDeviceId == expectedDeviceInfo.deviceID) + #expect(deviceProvisionAPIMock.receivedPassword != nil) + #expect(deviceProvisionAPIMock.receivedToken == "OAUTH_TOKEN") + } + + @Test func provisionDeviceFailureBackendConnectionFailed() async throws { + let espProvisionMock = ORESPProvisionManagerMock() + let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true + + var expectedDeviceInfo = Response.DeviceInfo() + expectedDeviceInfo.deviceID = "123456789ABC" + expectedDeviceInfo.modelName = "My Battery" + + var expectedOpenRemoteConfig = Response.OpenRemoteConfig() + expectedOpenRemoteConfig.status = .success + + var expectedBackendConnectionStatusFailure = Response.BackendConnectionStatus() + expectedBackendConnectionStatusFailure.status = .failed + + espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() + + let deviceProvisionAPIMock = DeviceProvisionAPIMock() + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, + searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) + _ = provider.initialize() + _ = await enable(provider: provider) + provider.setProvisionManager(espProvisionMock) + + let device = await discoverDeviceAndStopScan(provider: provider) + + try await connectToDevice(provider: provider, deviceId: device["id"] as! String) + + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) + } + + provider.provisionDevice(userToken: "OAUTH_TOKEN") + + var request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) #expect(request.id == "0") #expect(request.body == .deviceInfo(Request.DeviceInfo())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .deviceInfo(expectedDeviceInfo))) - request = try Request(serializedBytes: mockDevice.receivedData[1]) + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 1) #expect(request.id == "1") if case let .openRemoteConfig(openRemoteConfig) = request.body { #expect(openRemoteConfig.realm == "master") @@ -1175,12 +1432,24 @@ struct ESPProvisionProviderTest { } else { Issue.record("Received an unexpected response: \(request)") } + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "1", body: .openRemoteConfig(expectedOpenRemoteConfig))) - for i in 2...4 { - request = try Request(serializedBytes: mockDevice.receivedData[i]) - #expect(request.id == String(i)) - #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) - } + request = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 2) + #expect(request.id == "2") + #expect(request.body == .backendConnectionStatus(Request.BackendConnectionStatus())) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(id: "2", body: .backendConnectionStatus(expectedBackendConnectionStatusFailure))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.provisionDevice, count: 1) + let receivedData = receivedMessages[0] + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.provisionDevice) + #expect(receivedData["connected"] as? Bool == false) + #expect(receivedData["errorCode"] as? Int == ESPProviderErrorCode.communicationError.rawValue) + #expect(receivedData["errorMessage"] as? String == "Backend connection failed") + #expect(callbackRecorder.messageCount(matchingAction: Actions.provisionDevice) == 1) + + #expect(mockDevice.receivedData.count == 3) #expect(deviceProvisionAPIMock.provisionCallCount == 1) #expect(deviceProvisionAPIMock.receivedModelName == expectedDeviceInfo.modelName) @@ -1190,30 +1459,19 @@ struct ESPProvisionProviderTest { } @Test func provisionDeviceNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() espProvisionMock.mockDevices = [mockDevice] + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.provisionDevice) { provider.provisionDevice(userToken: "OAUTH_TOKEN") } @@ -1225,54 +1483,53 @@ struct ESPProvisionProviderTest { // MARK: Exit provisioning @Test func exitProvisioningSuccess() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() let mockDevice = ORESPDeviceMock() + mockDevice.manualSendDataResponses = true + let timeSource = TestTimeSource() espProvisionMock.mockDevices = [mockDevice] let deviceProvisionAPIMock = DeviceProvisionAPIMock() let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, - deviceProvisionAPI: deviceProvisionAPIMock) - mockDevice.addMockData(ORConfigChannelTest.responseData(body: .exitProvisioning(Response.ExitProvisioning()))) + deviceProvisionAPI: deviceProvisionAPIMock, + timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - let device = await getDevice(provider: provider) + let device = await discoverDeviceAndStopScan(provider: provider) try await connectToDevice(provider: provider, deviceId: device["id"] as! String) - var receivedData: [String:Any] = [:] - var receivedCallbackCount = 0 - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - receivedCallbackCount += 1 - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - provider.exitProvisioning() + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } + provider.exitProvisioning() + _ = try await waitForNextPendingRequest(on: mockDevice, requestIndex: 0) + mockDevice.completeNextSendDataRequest(data: ORConfigChannelTest.responseData(body: .exitProvisioning(Response.ExitProvisioning()))) + + let receivedMessages = await callbackRecorder.waitForMessages(matchingAction: Actions.exitProvisioning, count: 1) + let receivedData = receivedMessages[0] + #expect(receivedData["provider"] as? String == Providers.espprovision) #expect(receivedData["action"] as? String == Actions.exitProvisioning) #expect(receivedData["exit"] as? Bool == true) } @Test func exitProvisioningNotConnected() async throws { - let espProvisionMock = ESPORProvisionManagerMock() + let espProvisionMock = ORESPProvisionManagerMock() + let timeSource = TestTimeSource() - let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max) + let provider = ESPProvisionProvider(searchDeviceTimeout: 1, searchDeviceMaxIterations: Int.max, searchWifiTimeout: 1, searchWifiMaxIterations: Int.max, timeSource: timeSource) _ = provider.initialize() _ = await enable(provider: provider) provider.setProvisionManager(espProvisionMock) - _ = await getDevice(provider: provider) + _ = await discoverDeviceAndStopScan(provider: provider) provider.stopDevicesScan() @@ -1288,91 +1545,67 @@ struct ESPProvisionProviderTest { // MARK: helpers - private func enable(provider: ESPProvisionProvider) async -> Bool { - var receivedData: [String:Any] = [:] - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } + private func waitForNextPendingRequest(on mockDevice: ORESPDeviceMock, requestIndex: Int) async throws -> Request { + await mockDevice.waitForPendingSendDataRequests(atLeast: 1) + return try Request(serializedBytes: mockDevice.receivedData[requestIndex]) + } + private func enable(provider: ESPProvisionProvider) async -> Bool { + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.providerEnable) { provider.enable() } + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.providerEnable) return (receivedData["success"] as! Bool) } - private func getDevice(provider: ESPProvisionProvider) async -> [String: Any] { - var receivedData: [String:Any] = [:] - - await withCheckedContinuation { continuation in - var continuationCalled = false - provider.sendDataCallback = { data in - receivedData = data - if !continuationCalled { - continuationCalled = true - continuation.resume() - } - } - + private func discoverDeviceAndStopScan(provider: ESPProvisionProvider) async -> [String: Any] { + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.startBleScan) { provider.startDevicesScan() } + provider.stopDevicesScan() return (receivedData["devices"] as! [[String:Any]]).first! } private func connectToDevice(provider: ESPProvisionProvider, deviceId: String) async throws { - let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { - provider.connectTo(deviceId: deviceId) - } + if provider.bleScanning { + let receivedMessages = await waitForMessages(provider: provider, expectingActions: [Actions.stopBleScan, Actions.connectToDevice]) { + provider.connectTo(deviceId: deviceId) + } - #expect(receivedMessages.count == 2) + #expect(receivedMessages.count == 2) - let receivedData = receivedMessages[0] - #expect(receivedData["provider"] as? String == Providers.espprovision) - #expect(receivedData["action"] as? String == Actions.stopBleScan) - #expect(receivedData.count == 2) + let receivedData = receivedMessages[0] + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.stopBleScan) + #expect(receivedData.count == 2) + } else { + let receivedData = await waitForMessage(provider: provider, expectingAction: Actions.connectToDevice) { + provider.connectTo(deviceId: deviceId) + } + + #expect(receivedData["provider"] as? String == Providers.espprovision) + #expect(receivedData["action"] as? String == Actions.connectToDevice) + } } - private func waitForMessage(provider: ESPProvisionProvider, expectingAction action: String, afterCalling trigger: (() -> (Void))) async -> [String:Any] { - var receivedData: [String:Any] = [:] - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == action { - receivedData = data - continuation.resume() - } else { - Issue.record("Received an unexpected action: \(data)") - } - } - trigger() + private func waitForMessage(provider: ESPProvisionProvider, expectingAction action: String, afterCalling trigger: @escaping () -> Void) async -> [String:Any] { + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - return receivedData + + return await callbackRecorder.waitForFirstMessage(matchingAction: action, after: trigger) } - private func waitForMessages(provider: ESPProvisionProvider, expectingActions actions: [String], afterCalling trigger: (() -> (Void))) async -> [[String:Any]] { - var receivedData: [[String:Any]] = [] - var actionsIterator = actions.makeIterator() - var expectedAction = actionsIterator.next() - await withCheckedContinuation { continuation in - provider.sendDataCallback = { data in - if (data["action"] as? String) == expectedAction { - receivedData.append(data) - expectedAction = actionsIterator.next() - if expectedAction == nil { - continuation.resume() - } - } else { - Issue.record("Received an unexpected action: \(data)") - } - } - trigger() + private func waitForMessages(provider: ESPProvisionProvider, expectingActions actions: [String], afterCalling trigger: @escaping () -> Void) async -> [[String:Any]] { + let callbackRecorder = CallbackRecorder() + provider.sendDataCallback = { data in + callbackRecorder.record(data) } - return receivedData + + return await callbackRecorder.waitForMessages(matchingActions: actions, after: trigger) } } diff --git a/Tests/ORESPDeviceMock.swift b/Tests/ORESPDeviceMock.swift index d9fd2f7..a94d61d 100644 --- a/Tests/ORESPDeviceMock.swift +++ b/Tests/ORESPDeviceMock.swift @@ -37,14 +37,351 @@ struct MockResponse { } } -class ORESPDeviceMock: ORESPDevice { +private final class ManualWifiScanController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + + typealias CompletionHandler = ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var manualMode = false + private var pendingScans = [CompletionHandler]() + private var enqueuedScanCount = 0 + private var scanWaiters = [ScanWaiter]() + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func enqueuePendingScan(_ completionHandler: @escaping CompletionHandler) { + var waitersToResume = [ScanWaiter]() + + lock.lock() + pendingScans.append(completionHandler) + enqueuedScanCount += 1 + let readyWaiters = scanWaiters.filter { enqueuedScanCount >= $0.targetCount } + scanWaiters.removeAll { enqueuedScanCount >= $0.targetCount } + waitersToResume = readyWaiters + lock.unlock() + + for waiter in waitersToResume { + waiter.continuation.resume() + } + } + + func dequeuePendingScan() -> CompletionHandler? { + lock.lock() + defer { lock.unlock() } + guard !pendingScans.isEmpty else { + return nil + } + return pendingScans.removeFirst() + } + + func waitForEnqueuedScans(atLeast targetCount: Int) async { + if hasEnqueuedScans(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + lock.lock() + if enqueuedScanCount >= targetCount { + lock.unlock() + continuation.resume() + return + } + scanWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + lock.unlock() - private var mockResponses: [MockResponse] = [] + Task { [weak self] in + await self?.timeoutScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: ScanWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = scanWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = scanWaiters.remove(at: waiterIndex) + observedCount = enqueuedScanCount + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan start(s); observed \(observedCount)") + waiterToResume.continuation.resume() + } + } + + private func hasEnqueuedScans(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return enqueuedScanCount >= targetCount + } +} + +private final class SendDataController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + + typealias CompletionHandler = (Data?, ESPSessionError?) -> Void + + private struct PendingRequest { + let completionHandler: CompletionHandler + } + private struct PendingRequestWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var manualMode = false + private var pendingRequests = [PendingRequest]() + private var pendingRequestWaiters = [PendingRequestWaiter]() + private var mockResponses = [MockResponse]() private var mockResponsesIndex: [MockResponse].Index? = nil + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func resetMockResponses() { + lock.lock() + mockResponses = [] + mockResponsesIndex = nil + lock.unlock() + } + + func addMockResponse(_ response: MockResponse) { + lock.lock() + mockResponses.append(response) + lock.unlock() + } + + func enqueuePendingRequest(_ completionHandler: @escaping CompletionHandler) { + var waitersToResume = [PendingRequestWaiter]() + + lock.lock() + pendingRequests.append(PendingRequest(completionHandler: completionHandler)) + let pendingRequestCount = pendingRequests.count + let readyWaiters = pendingRequestWaiters.filter { pendingRequestCount >= $0.targetCount } + pendingRequestWaiters.removeAll { pendingRequestCount >= $0.targetCount } + waitersToResume = readyWaiters + lock.unlock() + + for waiter in waitersToResume { + waiter.continuation.resume() + } + } + + func waitForPendingRequests(atLeast targetCount: Int) async { + if hasPendingRequests(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + lock.lock() + if pendingRequests.count >= targetCount { + lock.unlock() + continuation.resume() + return + } + pendingRequestWaiters.append(PendingRequestWaiter(id: waiterId, + targetCount: targetCount, + continuation: continuation)) + lock.unlock() + + Task { [weak self] in + await self?.timeoutPendingRequestWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutPendingRequestWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: PendingRequestWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = pendingRequestWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = pendingRequestWaiters.remove(at: waiterIndex) + observedCount = pendingRequests.count + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) pending sendData request(s); observed \(observedCount)") + waiterToResume.continuation.resume() + } + } + + private func hasPendingRequests(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return pendingRequests.count >= targetCount + } + + func dequeuePendingRequest() -> CompletionHandler? { + lock.lock() + defer { lock.unlock() } + guard !pendingRequests.isEmpty else { + return nil + } + return pendingRequests.removeFirst().completionHandler + } + + func getNextMockResponse() -> MockResponse? { + lock.lock() + defer { lock.unlock() } + if mockResponses.isEmpty { + return nil + } + if let currentIndex = mockResponsesIndex { + let nextIndex = mockResponses.index(after: currentIndex) + if nextIndex >= mockResponses.endIndex { + mockResponsesIndex = mockResponses.startIndex + } else { + mockResponsesIndex = nextIndex + } + } else { + mockResponsesIndex = mockResponses.startIndex + } + return mockResponses[mockResponsesIndex!] + } +} + +private actor WifiScanCompletionTracker { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + + private var startedScanCount = 0 + private var completedScanCount = 0 + private var startWaiters = [ScanWaiter]() + private var waiters = [ScanWaiter]() + + func markScanStarted() { + startedScanCount += 1 + + let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } + startWaiters.removeAll { startedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func markScanCompleted() { + completedScanCount += 1 + + let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } + waiters.removeAll { completedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func waitForStartedScans(atLeast targetCount: Int) async { + if startedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + startWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutStartedScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + func waitForCompletedScans(atLeast targetCount: Int) async { + if completedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + waiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutCompletedScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutStartedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = startWaiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = startWaiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan start(s); observed \(startedScanCount)") + waiter.continuation.resume() + } + + private func timeoutCompletedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = waiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = waiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) Wi-Fi scan completion(s); observed \(completedScanCount)") + waiter.continuation.resume() + } +} + +class ORESPDeviceMock: ORESPDevice { + + private let manualWifiScanController = ManualWifiScanController() + private let sendDataController = SendDataController() + private let wifiScanCompletionTracker = WifiScanCompletionTracker() + var scanWifiListCallCount = 0 - var scanWifiDuration: TimeInterval = 0 var networks = [ESPWifiNetwork(ssid: "SSID-1", rssi: -50)] + var manualWifiScans = false { + didSet { + manualWifiScanController.setManualMode(manualWifiScans) + } + } + var manualSendDataResponses = false { + didSet { + sendDataController.setManualMode(manualSendDataResponses) + } + } var provisionError: ESPProvisionError? var provisionCalledCount = 0 @@ -65,16 +402,15 @@ class ORESPDeviceMock: ORESPDevice { var name: String func resetMockResponses() { - mockResponses = [] - mockResponsesIndex = nil + sendDataController.resetMockResponses() } func addMockData(_ data: Data, delay: TimeInterval = 0) { - mockResponses.append(MockResponse(mockData: data, delay: delay)) + sendDataController.addMockResponse(MockResponse(mockData: data, delay: delay)) } func addMockError(_ error: ESPSessionError, delay: TimeInterval = 0) { - mockResponses.append(MockResponse(mockError: error, delay: delay)) + sendDataController.addMockResponse(MockResponse(mockError: error, delay: delay)) } func connect(delegate: (any ESPDeviceConnectionDelegate)?, completionHandler: @escaping (ESPSessionStatus) -> Void) { @@ -90,14 +426,54 @@ class ORESPDeviceMock: ORESPDevice { func scanWifiList(completionHandler: @escaping ([ESPWifiNetwork]?, ESPWiFiScanError?) -> Void) { scanWifiListCallCount += 1 + let scanResult = (networks, error: Optional.none) + let usesManualWifiScans = manualWifiScanController.isManualModeEnabled() Task { - if scanWifiDuration > 0 { - try await Task.sleep(nanoseconds: UInt64(scanWifiDuration * Double(NSEC_PER_SEC))) + await wifiScanCompletionTracker.markScanStarted() + + if usesManualWifiScans { + manualWifiScanController.enqueuePendingScan(completionHandler) + return } - completionHandler(networks, nil) + + await wifiScanCompletionTracker.markScanCompleted() + completionHandler(scanResult.0, scanResult.error) + } + } + + func completeNextWifiScan(networks: [ESPWifiNetwork]? = nil, error: ESPWiFiScanError? = nil) { + guard let completionHandler = manualWifiScanController.dequeuePendingScan() else { + Issue.record("No pending wifi scan to complete") + return + } + let resolvedNetworks = networks ?? self.networks + + Task { + await wifiScanCompletionTracker.markScanCompleted() + completionHandler(resolvedNetworks, error) } } + func waitForWifiScanStarts(atLeast startedScanCount: Int) async { + await manualWifiScanController.waitForEnqueuedScans(atLeast: startedScanCount) + } + + func waitForWifiScanCompletions(atLeast completedScanCount: Int) async { + await wifiScanCompletionTracker.waitForCompletedScans(atLeast: completedScanCount) + } + + func waitForPendingSendDataRequests(atLeast requestCount: Int) async { + await sendDataController.waitForPendingRequests(atLeast: requestCount) + } + + func completeNextSendDataRequest(data: Data? = nil, error: ESPSessionError? = nil) { + guard let completionHandler = sendDataController.dequeuePendingRequest() else { + Issue.record("No pending sendData request to complete") + return + } + completionHandler(data, error) + } + func provision(ssid: String?, passPhrase: String?, threadOperationalDataset: Data?, completionHandler: @escaping (ESPProvisionStatus) -> Void) { provisionCalledCount += 1 provisionCalledParameters = (ssid, passPhrase) @@ -111,36 +487,25 @@ class ORESPDeviceMock: ORESPDevice { func sendData(path: String, data: Data, completionHandler: @escaping (Data?, ESPSessionError?) -> Void) { receivedData.append(data) - let response = getNextMockResponse() + if sendDataController.isManualModeEnabled() { + sendDataController.enqueuePendingRequest(completionHandler) + return + } + + let response = sendDataController.getNextMockResponse() guard let response else { completionHandler(nil, nil) return } - Task { - if response.delay > 0 { - try await Task.sleep(nanoseconds: UInt64(response.delay * Double(NSEC_PER_SEC))) - } + + if response.delay == 0 { completionHandler(response.mockData, response.mockError) + return } - } - private func getNextMockResponse() -> MockResponse? { - if mockResponses.isEmpty { - return nil - } else { - if mockResponsesIndex != nil { - mockResponsesIndex = mockResponses.index(after: mockResponsesIndex!) - if mockResponsesIndex! >= mockResponses.endIndex { - mockResponsesIndex = mockResponses.startIndex - return mockResponses[mockResponsesIndex!] - } else { - return mockResponses[mockResponsesIndex!] - } - } else { - mockResponsesIndex = mockResponses.startIndex - return mockResponses[mockResponsesIndex!] - } + Task { + try await Task.sleep(nanoseconds: UInt64(response.delay * Double(NSEC_PER_SEC))) + completionHandler(response.mockData, response.mockError) } - } } diff --git a/Tests/ORESPProvisionManagerMock.swift b/Tests/ORESPProvisionManagerMock.swift new file mode 100644 index 0000000..118720b --- /dev/null +++ b/Tests/ORESPProvisionManagerMock.swift @@ -0,0 +1,284 @@ +/* + * Copyright 2017, OpenRemote Inc. + * + * See the CONTRIBUTORS.txt file in the distribution for a + * full listing of individual contributors. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +@testable import ESPProvision +import Foundation +import Testing + +@testable import ORLib + +private final class ManualDeviceScanController { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct RequestWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + + private let lock = NSLock() + private var manualMode = false + private var pendingRequests = [CheckedContinuation<[ORESPDevice], Error>]() + private var enqueuedRequestCount = 0 + private var requestWaiters = [RequestWaiter]() + + func setManualMode(_ manualMode: Bool) { + lock.lock() + self.manualMode = manualMode + lock.unlock() + } + + func isManualModeEnabled() -> Bool { + lock.lock() + defer { lock.unlock() } + return manualMode + } + + func enqueuePendingRequest(_ continuation: CheckedContinuation<[ORESPDevice], Error>) { + var waitersToResume = [RequestWaiter]() + + lock.lock() + pendingRequests.append(continuation) + enqueuedRequestCount += 1 + let readyWaiters = requestWaiters.filter { enqueuedRequestCount >= $0.targetCount } + requestWaiters.removeAll { enqueuedRequestCount >= $0.targetCount } + waitersToResume = readyWaiters + lock.unlock() + + for waiter in waitersToResume { + waiter.continuation.resume() + } + } + + func dequeuePendingRequest() -> CheckedContinuation<[ORESPDevice], Error>? { + lock.lock() + defer { lock.unlock() } + guard !pendingRequests.isEmpty else { + return nil + } + return pendingRequests.removeFirst() + } + + func drainPendingRequests() -> [CheckedContinuation<[ORESPDevice], Error>] { + lock.lock() + defer { lock.unlock() } + let continuations = pendingRequests + pendingRequests.removeAll() + return continuations + } + + func waitForEnqueuedRequests(atLeast targetCount: Int) async { + if hasEnqueuedRequests(atLeast: targetCount) { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + lock.lock() + if enqueuedRequestCount >= targetCount { + lock.unlock() + continuation.resume() + return + } + requestWaiters.append(RequestWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + lock.unlock() + + Task { [weak self] in + await self?.timeoutRequestWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutRequestWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + + var waiterToResume: RequestWaiter? + var observedCount = 0 + + lock.lock() + if let waiterIndex = requestWaiters.firstIndex(where: { $0.id == id }) { + waiterToResume = requestWaiters.remove(at: waiterIndex) + observedCount = enqueuedRequestCount + } + lock.unlock() + + if let waiterToResume { + Issue.record("Timed out waiting for at least \(targetCount) device search request(s); observed \(observedCount)") + waiterToResume.continuation.resume() + } + } + + private func hasEnqueuedRequests(atLeast targetCount: Int) -> Bool { + lock.lock() + defer { lock.unlock() } + return enqueuedRequestCount >= targetCount + } +} + +final class ORESPProvisionManagerMock: ORESPProvisionManager { + private actor DeviceScanCompletionTracker { + private static let waitTimeoutNanoseconds: UInt64 = 5_000_000_000 + private struct ScanWaiter { + let id: UUID + let targetCount: Int + let continuation: CheckedContinuation + } + + private var startedScanCount = 0 + private var completedScanCount = 0 + private var startWaiters = [ScanWaiter]() + private var waiters = [ScanWaiter]() + + func markScanStarted() { + startedScanCount += 1 + + let readyWaiters = startWaiters.filter { startedScanCount >= $0.targetCount } + startWaiters.removeAll { startedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func markScanCompleted() { + completedScanCount += 1 + + let readyWaiters = waiters.filter { completedScanCount >= $0.targetCount } + waiters.removeAll { completedScanCount >= $0.targetCount } + + for waiter in readyWaiters { + waiter.continuation.resume() + } + } + + func waitForStartedScans(atLeast targetCount: Int) async { + if startedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + startWaiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutStartedScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + func waitForCompletedScans(atLeast targetCount: Int) async { + if completedScanCount >= targetCount { + return + } + + await withCheckedContinuation { continuation in + let waiterId = UUID() + waiters.append(ScanWaiter(id: waiterId, targetCount: targetCount, continuation: continuation)) + Task { + await self.timeoutCompletedScanWaiter(id: waiterId, targetCount: targetCount) + } + } + } + + private func timeoutStartedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = startWaiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = startWaiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) device scan start(s); observed \(startedScanCount)") + waiter.continuation.resume() + } + + private func timeoutCompletedScanWaiter(id: UUID, targetCount: Int) async { + try? await Task.sleep(nanoseconds: Self.waitTimeoutNanoseconds) + guard let waiterIndex = waiters.firstIndex(where: { $0.id == id }) else { + return + } + + let waiter = waiters.remove(at: waiterIndex) + Issue.record("Timed out waiting for at least \(targetCount) device scan completion(s); observed \(completedScanCount)") + waiter.continuation.resume() + } + } + + var searchESPDevicesCallCount = 0 + var stopESPDevicesSearchCallCount = 0 + + var manualDeviceScans = false { + didSet { + manualDeviceScanController.setManualMode(manualDeviceScans) + } + } + + var mockDevices = [ORESPDeviceMock()] + private let deviceScanCompletionTracker = DeviceScanCompletionTracker() + private let manualDeviceScanController = ManualDeviceScanController() + + func searchESPDevices(devicePrefix: String, transport: ESPTransport, security: ESPSecurity) async throws -> [ORESPDevice] { + searchESPDevicesCallCount += 1 + await deviceScanCompletionTracker.markScanStarted() + if manualDeviceScanController.isManualModeEnabled() { + do { + let devices = try await withCheckedThrowingContinuation { continuation in + manualDeviceScanController.enqueuePendingRequest(continuation) + } + await deviceScanCompletionTracker.markScanCompleted() + return devices + } catch { + await deviceScanCompletionTracker.markScanCompleted() + throw error + } + } + await deviceScanCompletionTracker.markScanCompleted() + return mockDevices + } + + func stopESPDevicesSearch() { + stopESPDevicesSearchCallCount += 1 + let pendingRequests = manualDeviceScanController.drainPendingRequests() + for continuation in pendingRequests { + continuation.resume(returning: mockDevices) + } + } + + func waitForDeviceSearchRequests(atLeast requestCount: Int) async { + await manualDeviceScanController.waitForEnqueuedRequests(atLeast: requestCount) + } + + func waitForCompletedDeviceSearchRequests(atLeast requestCount: Int) async { + await deviceScanCompletionTracker.waitForCompletedScans(atLeast: requestCount) + } + + func completeNextDeviceScan(with devices: [ORESPDevice]? = nil) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to complete") + return + } + continuation.resume(returning: devices ?? mockDevices) + } + + func failNextDeviceScan(with error: Error) { + guard let continuation = manualDeviceScanController.dequeuePendingRequest() else { + Issue.record("No pending device scan to fail") + return + } + continuation.resume(throwing: error) + } +} diff --git a/Tests/TestTimeSource.swift b/Tests/TestTimeSource.swift new file mode 100644 index 0000000..e9c0a08 --- /dev/null +++ b/Tests/TestTimeSource.swift @@ -0,0 +1,43 @@ +/* + * Copyright 2025, OpenRemote Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import Foundation + +@testable import ORLib + +final class TestTimeSource: TimeSource { + private let lock = NSLock() + private var currentTime: TimeInterval + + init(now: TimeInterval = 0) { + self.currentTime = now + } + + var now: TimeInterval { + lock.lock() + defer { lock.unlock() } + return currentTime + } + + func advance(by duration: TimeInterval) { + lock.lock() + currentTime += duration + lock.unlock() + } +}