Improve Home Assistant connection reliability
This commit is contained in:
184
XIONControlPanelTests/HomeAssistantIntegrationTests.swift
Normal file
184
XIONControlPanelTests/HomeAssistantIntegrationTests.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
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<Void, Error>) 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<Void, Error>) in
|
||||
server.toggleDevice(device, state: state) { error in
|
||||
if let error {
|
||||
continuation.resume(throwing: error)
|
||||
} else {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user