import XCTest @testable import XION final class HomeAssistantIntegrationTests: XCTestCase, ServerDelegate { private var expectedEntityID: String? private var expectedState: DeviceState? private var stateExpectation: XCTestExpectation? private var connectionErrorExpectation: XCTestExpectation? private var reconnectionExpectation: XCTestExpectation? @MainActor func testLiveAsteroidsRoundTrip() async throws { guard ProcessInfo.processInfo.environment["RUN_LIVE_HOME_ASSISTANT_TESTS"] == "1" else { throw XCTSkip("Set RUN_LIVE_HOME_ASSISTANT_TESTS=1 to run live integration tests.") } guard let token = ProcessInfo.processInfo.environment["HOME_ASSISTANT_ACCESS_TOKEN"], !token.isEmpty else { XCTFail("HOME_ASSISTANT_ACCESS_TOKEN is required for the live test.") return } let server = HomeAssistantServer(URL(string: "https://ha.xaibatsu.com/api")!) server.delegate = self try await connect(server) let devices = try await fetchDevices(from: server) guard let asteroids = devices.first(where: { $0.name.caseInsensitiveCompare("Asteroids") == .orderedSame }) else { server.disconnect { _ in } XCTFail("The Home Assistant response did not include Asteroids.") return } let originalState = asteroids.state let testState: DeviceState = originalState == .on ? .off : .on var restorationRequired = false do { restorationRequired = true try await toggle( asteroids, to: testState, using: server, expectationDescription: "Asteroids changed to \(testState.rawValue)" ) try await toggle( asteroids, to: originalState, using: server, expectationDescription: "Asteroids restored to \(originalState.rawValue)" ) restorationRequired = false let connectionError = expectation( description: "The simulated socket failure was detected" ) let reconnection = expectation( description: "The server reconnected after the socket failure" ) connectionErrorExpectation = connectionError reconnectionExpectation = reconnection server.simulateConnectionFailureForTesting() await fulfillment( of: [connectionError, reconnection], timeout: 8, enforceOrder: true ) for _ in 0..<3 { server.disconnect { error in XCTAssertNil(error) } try await connect(server) } let reconnectedDevices = try await fetchDevices(from: server) XCTAssertEqual( reconnectedDevices.first(where: { $0.serial == asteroids.serial })?.state, originalState ) } catch { if restorationRequired { try? await toggleWithoutWaiting(asteroids, to: originalState, using: server) } server.disconnect { _ in } throw error } server.disconnect { error in XCTAssertNil(error) } XCTAssertEqual(server.connectionStatus, .disconnected) } func server(_ server: Server, deviceChangedState device: AnyDevice) { guard device.serial == expectedEntityID, device.state == expectedState else { return } stateExpectation?.fulfill() stateExpectation = nil } func server(_ server: Server, connectionStatusChanged status: ConnectionStatus) { if status == .error { connectionErrorExpectation?.fulfill() connectionErrorExpectation = nil } else if status == .connected, connectionErrorExpectation == nil { reconnectionExpectation?.fulfill() reconnectionExpectation = nil } } @MainActor private func connect(_ server: HomeAssistantServer) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in server.connect { error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } } @MainActor private func fetchDevices(from server: HomeAssistantServer) async throws -> [AnyDevice] { try await withCheckedThrowingContinuation { continuation in server.fetchDevices { result in continuation.resume(with: result) } } } @MainActor private func toggle( _ device: AnyDevice, to state: DeviceState, using server: HomeAssistantServer, expectationDescription: String ) async throws { expectedEntityID = device.serial expectedState = state let expectation = expectation(description: expectationDescription) stateExpectation = expectation try await toggleWithoutWaiting(device, to: state, using: server) await fulfillment(of: [expectation], timeout: 10) XCTAssertEqual(device.state, state) } @MainActor private func toggleWithoutWaiting( _ device: AnyDevice, to state: DeviceState, using server: HomeAssistantServer ) async throws { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in server.toggleDevice(device, state: state) { error in if let error { continuation.resume(throwing: error) } else { continuation.resume() } } } } }