Integrate live phone and watch remote prototype with guarded motion control
This commit is contained in:
@@ -70,3 +70,13 @@ import WatchBridgeCommon
|
||||
_ = try RelayApplicationContext(dictionary: executable)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func readinessContextRequiresBooleanAndSelectedIdentity() throws {
|
||||
let id = UUID()
|
||||
let context = RelayApplicationContext(selectedTelevisionID: id, displayName: "Living room", isConnectionReady: true)
|
||||
#expect(try RelayApplicationContext(dictionary: context.dictionary()) == context)
|
||||
#expect(!RelayApplicationContext(selectedTelevisionID: nil, displayName: nil, isConnectionReady: true).isConnectionReady)
|
||||
#expect(throws: RelayContainerError.self) {
|
||||
try RelayApplicationContext(dictionary: ["schemaVersion": 1, "isConnectionReady": 1])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,3 +91,60 @@ private enum RelayTestError: Error { case failed }
|
||||
#expect(bridge.state == .inactive)
|
||||
#expect(fake.sent.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func watchReachabilityLossRevokesArmedGrantAndNeverReplays() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
#expect(bridge.grant != nil)
|
||||
fake.isReachable = false; bridge.reachabilityDidChange()
|
||||
#expect(bridge.grant == nil)
|
||||
#expect(bridge.state == .ready(reachable: false))
|
||||
fake.isReachable = true; bridge.reachabilityDidChange()
|
||||
bridge.sendCommand(.right)
|
||||
#expect(fake.sent.count == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func lateGrantAfterStopDoesNotRearmAndRepeatedConnectDoesNotDuplicate() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
bridge.beginSession(); bridge.beginSession()
|
||||
#expect(fake.sent.count == 1)
|
||||
let lateReply = fake.reply
|
||||
bridge.endSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
lateReply?(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
#expect(bridge.grant == nil)
|
||||
#expect(bridge.state == .ready(reachable: true))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func invalidationCompletesCommandOnceAndDiscardsLateSuccess() async throws {
|
||||
let fake = FakeWatchSession()
|
||||
let bridge = WatchRelayBridge(session: fake)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil); bridge.beginSession()
|
||||
let grant = RemoteSessionGrant(sessionID: UUID(), televisionID: UUID(), issuedAt: Date())
|
||||
fake.answer(try RelayContainer(kind: .session, value: grant).dictionary())
|
||||
await Task.yield()
|
||||
var completions = 0
|
||||
var succeeded = false
|
||||
bridge.sendCommand(.right) { result in
|
||||
completions += 1
|
||||
if case .success = result { succeeded = true }
|
||||
}
|
||||
let command = try RelayContainer(dictionary: fake.sent.last!).decode(CommandEnvelope.self, expectedKind: .command)
|
||||
let lateReply = fake.reply
|
||||
bridge.didDeactivate()
|
||||
lateReply?(try RelayContainer(kind: .receipt, value: CommandReceipt(id: command.id, status: .sentToTransport)).dictionary())
|
||||
for _ in 0..<10 { await Task.yield() }
|
||||
#expect(completions == 1)
|
||||
#expect(!succeeded)
|
||||
#expect(bridge.grant == nil)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ private final class FakePhoneSession: RelaySessionSending {
|
||||
var activationState: RelayActivationState = .notActivated
|
||||
var isReachable = true
|
||||
var sent: [[String: Any]] = []
|
||||
var contexts: [[String: Any]] = []
|
||||
var reply: (([String: Any]) -> Void)?
|
||||
var error: ((Error) -> Void)?
|
||||
func activate() { activationState = .activated }
|
||||
@@ -19,7 +20,7 @@ private final class FakePhoneSession: RelaySessionSending {
|
||||
reply = replyHandler
|
||||
error = errorHandler
|
||||
}
|
||||
func updateApplicationContext(_ context: [String: Any]) throws {}
|
||||
func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) }
|
||||
func answer(_ value: [String: Any]) { reply?(value) }
|
||||
}
|
||||
|
||||
@@ -86,3 +87,70 @@ private actor RecordingPhoneCoordinator: PhoneCommandCoordinating {
|
||||
await Task.yield()
|
||||
#expect(replies == 1)
|
||||
}
|
||||
|
||||
private actor SuspendedCoordinator: PhoneCommandCoordinating {
|
||||
var begins = 0
|
||||
var active = false
|
||||
var waiting: [CheckedContinuation<Result<RemoteSessionGrant, RelayFailure>, Never>] = []
|
||||
func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true }
|
||||
func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false }
|
||||
func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result<RemoteSessionGrant, RelayFailure> {
|
||||
begins += 1
|
||||
return await withCheckedContinuation { waiting.append($0) }
|
||||
}
|
||||
func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result<RemoteSessionEnded, RelayFailure> { .failure(RelayFailure(code: .unavailable)) }
|
||||
func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { CommandReceipt(id: envelope.id, status: .sentToTransport) }
|
||||
func release() { for continuation in waiting { continuation.resume(returning: .failure(RelayFailure(code: .unavailable))) }; waiting.removeAll() }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func timeoutDoesNotWaitForUncooperativeActorAndBoundsOutstandingCalls() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = SuspendedCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
let message = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()
|
||||
var replies = 0
|
||||
for _ in 0..<20 { bridge.receive(message: message) { _ in replies += 1 } }
|
||||
try await Task.sleep(for: .milliseconds(1_150))
|
||||
#expect(replies == 20)
|
||||
#expect(await coordinator.begins == 16)
|
||||
bridge.receive(message: message) { _ in replies += 1 }
|
||||
#expect(replies == 21)
|
||||
#expect(await coordinator.begins == 16)
|
||||
await coordinator.release()
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(replies == 21)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func deactivationCompletesPendingRepliesBeforeActorReturns() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = SuspendedCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
var replies = 0
|
||||
bridge.receive(message: try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary()) { _ in replies += 1 }
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
bridge.didDeactivate()
|
||||
#expect(replies == 1)
|
||||
await coordinator.release()
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(replies == 1)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func latestContextPublishedBeforeActivationIsFlushed() async throws {
|
||||
let session = FakePhoneSession()
|
||||
let coordinator = RecordingPhoneCoordinator()
|
||||
let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: coordinator))
|
||||
let context = RelayApplicationContext(selectedTelevisionID: UUID(), displayName: "TV", isConnectionReady: true)
|
||||
bridge.publishContext(context)
|
||||
#expect(session.contexts.isEmpty)
|
||||
bridge.activate(); bridge.activationDidComplete(error: nil)
|
||||
try await Task.sleep(for: .milliseconds(30))
|
||||
#expect(session.contexts.count == 1)
|
||||
#expect(try RelayApplicationContext(dictionary: session.contexts[0]) == context)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user