import Foundation import Testing import RemoteCore import WatchBridgeCommon import WatchBridgePhone @MainActor 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 } func sendMessage(_ message: [String: Any], replyHandler: @escaping ([String: Any]) -> Void, errorHandler: @escaping (Error) -> Void) { sent.append(message) reply = replyHandler error = errorHandler } func updateApplicationContext(_ context: [String: Any]) throws { contexts.append(context) } func answer(_ value: [String: Any]) { reply?(value) } } private actor RecordingPhoneCoordinator: PhoneCommandCoordinating { var activated: [WatchPeerGeneration] = [] var deactivated: [WatchPeerGeneration] = [] var commands: [CommandEnvelope] = [] let sessionID = UUID() let televisionID = UUID() func watchBridgeDidActivate(peer: WatchPeerGeneration) { activated.append(peer) } func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { deactivated.append(peer) } func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) -> Result { .success(RemoteSessionGrant(sessionID: sessionID, televisionID: televisionID, issuedAt: Date(timeIntervalSince1970: 1_700_000_000))) } func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result { .success(RemoteSessionEnded(sessionID: request.sessionID)) } func submitWatchCommand(_ envelope: CommandEnvelope, peer: WatchPeerGeneration) -> CommandReceipt { commands.append(envelope) return CommandReceipt(id: envelope.id, status: .sentToTransport) } } @MainActor @Test func phoneWaitsForActorActivationAndForwardsCommandWithoutAdmission() async throws { let session = FakePhoneSession() let actor = RecordingPhoneCoordinator() let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor)) bridge.activate() bridge.activationDidComplete(error: nil) await Task.yield() #expect({ if case .ready = bridge.state { return true } return false }()) let begin = try RelayContainer(kind: .beginSession, value: BeginRemoteSessionRequest()).dictionary() bridge.receive(message: begin) { _ in } await Task.yield() #expect(session.sent.isEmpty) let peerActivated = await actor.activated #expect(peerActivated.count == 1) bridge.receive(message: begin) { _ in } await Task.yield() #expect(session.sent.isEmpty) } @MainActor @Test func phoneRejectsMalformedAndWrongDirectionRequests() async throws { let session = FakePhoneSession() let actor = RecordingPhoneCoordinator() let bridge = PhoneRelayBridge(session: session, dependency: PhoneRelayDependency(coordinator: actor)) bridge.activate(); bridge.activationDidComplete(error: nil) await Task.yield() var replies = 0 bridge.receive(message: ["schemaVersion": true, "kind": "command", "payload": Data()]) { _ in replies += 1 } await Task.yield() #expect(replies == 1) } private actor SuspendedCoordinator: PhoneCommandCoordinating { var begins = 0 var active = false var waiting: [CheckedContinuation, Never>] = [] func watchBridgeDidActivate(peer: WatchPeerGeneration) { active = true } func watchBridgeDidDeactivate(peer: WatchPeerGeneration) { active = false } func beginWatchSession(_ request: BeginRemoteSessionRequest, peer: WatchPeerGeneration) async -> Result { begins += 1 return await withCheckedContinuation { waiting.append($0) } } func endWatchSession(_ request: EndRemoteSessionRequest, peer: WatchPeerGeneration) -> Result { .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) }