From ac92127bc5f0368353d213c4b657a5eb62a89dd8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 7 Nov 2024 11:48:04 -0300 Subject: [PATCH] Integrate lifecycle manager into existing room operations Replace the existing temporary implementations of room attach / detach / status with those provided by the room lifecycle manager. Part of #47. --- Sources/AblyChat/ChatClient.swift | 3 +- .../DefaultRoomLifecycleContributor.swift | 48 ++++ Sources/AblyChat/Room.swift | 66 +++--- Sources/AblyChat/RoomFeature.swift | 6 +- Sources/AblyChat/RoomLifecycleManager.swift | 52 ++++- Sources/AblyChat/Rooms.swift | 10 +- Sources/AblyChat/SimpleClock.swift | 6 + .../DefaultChatClientTests.swift | 2 +- Tests/AblyChatTests/DefaultRoomTests.swift | 216 ++++++++---------- Tests/AblyChatTests/DefaultRoomsTests.swift | 14 +- .../Mocks/MockRoomLifecycleManager.swift | 53 +++++ .../MockRoomLifecycleManagerFactory.swift | 13 ++ 12 files changed, 302 insertions(+), 187 deletions(-) create mode 100644 Sources/AblyChat/DefaultRoomLifecycleContributor.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift create mode 100644 Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 917a22f1..2e657eee 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -20,7 +20,8 @@ public actor DefaultChatClient: ChatClient { self.realtime = realtime self.clientOptions = clientOptions ?? .init() logger = DefaultInternalLogger(logHandler: self.clientOptions.logHandler, logLevel: self.clientOptions.logLevel) - rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger) + let roomLifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() + rooms = DefaultRooms(realtime: realtime, clientOptions: self.clientOptions, logger: logger, lifecycleManagerFactory: roomLifecycleManagerFactory) } public nonisolated var connection: any Connection { diff --git a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift new file mode 100644 index 00000000..a236061b --- /dev/null +++ b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift @@ -0,0 +1,48 @@ +import Ably + +internal actor DefaultRoomLifecycleContributor: RoomLifecycleContributor { + internal let channel: DefaultRoomLifecycleContributorChannel + internal let feature: RoomFeature + + internal init(channel: DefaultRoomLifecycleContributorChannel, feature: RoomFeature) { + self.channel = channel + self.feature = feature + } + + // MARK: - Discontinuities + + internal func emitDiscontinuity(_: ARTErrorInfo) { + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/47 + } +} + +internal final class DefaultRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { + private let underlyingChannel: any RealtimeChannelProtocol + + internal init(underlyingChannel: any RealtimeChannelProtocol) { + self.underlyingChannel = underlyingChannel + } + + internal func attach() async throws(ARTErrorInfo) { + try await underlyingChannel.attachAsync() + } + + internal func detach() async throws(ARTErrorInfo) { + try await underlyingChannel.detachAsync() + } + + internal var state: ARTRealtimeChannelState { + underlyingChannel.state + } + + internal var errorReason: ARTErrorInfo? { + underlyingChannel.errorReason + } + + internal func subscribeToState() async -> Subscription { + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + let subscription = Subscription(bufferingPolicy: .unbounded) + underlyingChannel.on { subscription.emit($0) } + return subscription + } +} diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index f096f3d7..6aa09379 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -19,7 +19,7 @@ public protocol Room: AnyObject, Sendable { var options: RoomOptions { get } } -public struct RoomStatusChange: Sendable { +public struct RoomStatusChange: Sendable, Equatable { public var current: RoomStatus public var previous: RoomStatus @@ -29,7 +29,7 @@ public struct RoomStatusChange: Sendable { } } -internal actor DefaultRoom: Room { +internal actor DefaultRoom: Room where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions private let chatAPI: ChatAPI @@ -39,8 +39,7 @@ internal actor DefaultRoom: Room { // Exposed for testing. private nonisolated let realtime: RealtimeClient - /// The channels that contribute to this room. - private let channels: [RoomFeature: RealtimeChannelProtocol] + private let lifecycleManager: any RoomLifecycleManager #if DEBUG internal nonisolated var testsOnly_realtime: RealtimeClient { @@ -48,12 +47,9 @@ internal actor DefaultRoom: Room { } #endif - internal private(set) var status: RoomStatus = .initialized - // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) - private var statusSubscriptions: [Subscription] = [] private let logger: InternalLogger - internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) async throws { + internal init(realtime: RealtimeClient, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) async throws { self.realtime = realtime self.roomID = roomID self.options = options @@ -64,7 +60,13 @@ internal actor DefaultRoom: Room { throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.") } - channels = Self.createChannels(roomID: roomID, realtime: realtime) + let channels = Self.createChannels(roomID: roomID, realtime: realtime) + let contributors = Self.createContributors(channels: channels) + + lifecycleManager = await lifecycleManagerFactory.createManager( + contributors: contributors, + logger: logger + ) messages = await DefaultMessages( channel: channels[.messages]!, @@ -75,12 +77,20 @@ internal actor DefaultRoom: Room { } private static func createChannels(roomID: String, realtime: RealtimeClient) -> [RoomFeature: RealtimeChannelProtocol] { - .init(uniqueKeysWithValues: [RoomFeature.messages, RoomFeature.typing, RoomFeature.reactions].map { feature in + .init(uniqueKeysWithValues: [RoomFeature.messages].map { feature in let channel = realtime.getChannel(feature.channelNameForRoomID(roomID)) + return (feature, channel) }) } + private static func createContributors(channels: [RoomFeature: RealtimeChannelProtocol]) -> [DefaultRoomLifecycleContributor] { + channels.map { entry in + let (feature, channel) = entry + return .init(channel: .init(underlyingChannel: channel), feature: feature) + } + } + public nonisolated var presence: any Presence { fatalError("Not yet implemented") } @@ -98,44 +108,22 @@ internal actor DefaultRoom: Room { } public func attach() async throws { - for channel in channels.map(\.value) { - do { - try await channel.attachAsync() - } catch { - logger.log(message: "Failed to attach channel \(channel), error \(error)", level: .error) - throw error - } - } - transition(to: .attached) + try await lifecycleManager.performAttachOperation() } public func detach() async throws { - for channel in channels.map(\.value) { - do { - try await channel.detachAsync() - } catch { - logger.log(message: "Failed to detach channel \(channel), error \(error)", level: .error) - throw error - } - } - transition(to: .detached) + try await lifecycleManager.performDetachOperation() } // MARK: - Room status - internal func onStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - let subscription: Subscription = .init(bufferingPolicy: bufferingPolicy) - statusSubscriptions.append(subscription) - return subscription + internal func onStatusChange(bufferingPolicy: BufferingPolicy) async -> Subscription { + await lifecycleManager.onChange(bufferingPolicy: bufferingPolicy) } - /// Sets ``status`` to the given status, and emits a status change to all subscribers added via ``onStatusChange(bufferingPolicy:)``. - internal func transition(to newStatus: RoomStatus) { - logger.log(message: "Transitioning to \(newStatus)", level: .debug) - let statusChange = RoomStatusChange(current: newStatus, previous: status) - status = newStatus - for subscription in statusSubscriptions { - subscription.emit(statusChange) + internal var status: RoomStatus { + get async { + await lifecycleManager.roomStatus } } } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index 2a570196..e2fb70fc 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -15,11 +15,7 @@ internal enum RoomFeature { case .messages: // (CHA-M1) Chat messages for a Room are sent on a corresponding realtime channel ::$chat::$chatMessages. For example, if your room id is my-room then the messages channel will be my-room::$chat::$chatMessages. "chatMessages" - case .typing: - "typingIndicators" - case .reactions: - "reactions" - case .presence, .occupancy: + case .typing, .reactions, .presence, .occupancy: // We’ll add these, with reference to the relevant spec points, as we implement these features fatalError("Don’t know channel name suffix for room feature \(self)") } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 7b0e8e59..44aec8ee 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -40,7 +40,37 @@ internal protocol RoomLifecycleContributor: Identifiable, Sendable { func emitDiscontinuity(_ error: ARTErrorInfo) async } -internal protocol RoomLifecycleManager: Sendable {} +internal protocol RoomLifecycleManager: Sendable { + func performAttachOperation() async throws + func performDetachOperation() async throws + var roomStatus: RoomStatus { get async } + func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription +} + +internal protocol RoomLifecycleManagerFactory: Sendable { + associatedtype Contributor: RoomLifecycleContributor + associatedtype Manager: RoomLifecycleManager + + func createManager( + contributors: [Contributor], + logger: InternalLogger + ) async -> Manager +} + +internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFactory { + private let clock = DefaultSimpleClock() + + internal func createManager( + contributors: [DefaultRoomLifecycleContributor], + logger: InternalLogger + ) async -> DefaultRoomLifecycleManager { + await .init( + contributors: contributors, + logger: logger, + clock: clock + ) + } +} internal actor DefaultRoomLifecycleManager: RoomLifecycleManager { // MARK: - Constant properties @@ -615,11 +645,19 @@ internal actor DefaultRoomLifecycleManager: Rooms where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { private nonisolated let realtime: RealtimeClient private let chatAPI: ChatAPI @@ -19,14 +19,16 @@ internal actor DefaultRooms: Rooms { internal nonisolated let clientOptions: ClientOptions private let logger: InternalLogger + private let lifecycleManagerFactory: LifecycleManagerFactory /// The set of rooms, keyed by room ID. - private var rooms: [String: DefaultRoom] = [:] + private var rooms: [String: DefaultRoom] = [:] - internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger) { + internal init(realtime: RealtimeClient, clientOptions: ClientOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) { self.realtime = realtime self.clientOptions = clientOptions self.logger = logger + self.lifecycleManagerFactory = lifecycleManagerFactory chatAPI = ChatAPI(realtime: realtime) } @@ -41,7 +43,7 @@ internal actor DefaultRooms: Rooms { return existingRoom } else { - let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger) + let room = try await DefaultRoom(realtime: realtime, chatAPI: chatAPI, roomID: roomID, options: options, logger: logger, lifecycleManagerFactory: lifecycleManagerFactory) rooms[roomID] = room return room } diff --git a/Sources/AblyChat/SimpleClock.swift b/Sources/AblyChat/SimpleClock.swift index a0218fdb..e563ffbd 100644 --- a/Sources/AblyChat/SimpleClock.swift +++ b/Sources/AblyChat/SimpleClock.swift @@ -7,3 +7,9 @@ internal protocol SimpleClock: Sendable { /// Behaves like `Task.sleep(nanoseconds:)`. Uses seconds instead of nanoseconds for readability at call site (we have no need for that level of precision). func sleep(timeInterval: TimeInterval) async throws } + +internal final class DefaultSimpleClock: SimpleClock { + internal func sleep(timeInterval: TimeInterval) async throws { + try await Task.sleep(nanoseconds: UInt64(timeInterval * Double(NSEC_PER_SEC))) + } +} diff --git a/Tests/AblyChatTests/DefaultChatClientTests.swift b/Tests/AblyChatTests/DefaultChatClientTests.swift index 569322c4..e433e837 100644 --- a/Tests/AblyChatTests/DefaultChatClientTests.swift +++ b/Tests/AblyChatTests/DefaultChatClientTests.swift @@ -22,7 +22,7 @@ struct DefaultChatClientTests { // Then: Its `rooms` property returns an instance of DefaultRooms with the same realtime client and client options let rooms = client.rooms - let defaultRooms = try #require(rooms as? DefaultRooms) + let defaultRooms = try #require(rooms as? DefaultRooms) #expect(defaultRooms.testsOnly_realtime === realtime) #expect(defaultRooms.clientOptions.isEqualForTestPurposes(options)) } diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 35b98e2f..88135491 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -11,12 +11,10 @@ struct DefaultRoomTests { // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) // Then #expect(room.messages.channel.name == "basketball::$chat::$chatMessages") @@ -24,174 +22,144 @@ struct DefaultRoomTests { // MARK: - Attach - @Test - func attach_attachesAllChannels_andSucceedsIfAllSucceed() async throws { - // Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `attach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - basketball::$chat::$reactions + @Test( + arguments: [ + .success(()), + .failure(ARTErrorInfo.createUnknownError() /* arbitrary */ ), + ] as[Result] + ) + func attach(managerAttachResult: Result) async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let subscription = await room.onStatusChange(bufferingPolicy: .unbounded) - async let attachedStatusChange = subscription.first { $0.current == .attached } + let lifecycleManager = MockRoomLifecycleManager(attachResult: managerAttachResult) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // When: `attach` is called on the room - try await room.attach() + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - // Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED - for channel in channelsList { - #expect(channel.attachCallCounter.isNonZero) + // When: `attach()` is called on the room + let result = await Result { () async throws(ARTErrorInfo) in + do { + try await room.attach() + } catch { + // swiftlint:disable:next force_cast + throw error as! ARTErrorInfo + } } - #expect(await room.status == .attached) - #expect(try #require(await attachedStatusChange).current == .attached) + // Then: It calls through to the `performAttachOperation()` method on the room lifecycle manager + #expect(Result.areIdentical(result, managerAttachResult)) + #expect(await lifecycleManager.attachCallCount == 1) } - @Test - func attach_attachesAllChannels_andFailsIfOneFails() async throws { - // Given: a DefaultRoom instance, with a Realtime client for which `attach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - // and fails when called on channel basketball::$chat::$reactions - let channelAttachError = ARTErrorInfo.createUnknownError() // arbitrary + // MARK: - Detach + + @Test( + arguments: [ + .success(()), + .failure(ARTErrorInfo.createUnknownError() /* arbitrary */ ), + ] as[Result] + ) + func detach(managerDetachResult: Result) async throws { + // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", attachResult: .failure(channelAttachError)), + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - // When: `attach` is called on the room - let roomAttachError: Error? - do { - try await room.attach() - roomAttachError = nil - } catch { - roomAttachError = error + let lifecycleManager = MockRoomLifecycleManager(detachResult: managerDetachResult) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) + + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) + + // When: `detach()` is called on the room + let result = await Result { () async throws(ARTErrorInfo) in + do { + try await room.detach() + } catch { + // swiftlint:disable:next force_cast + throw error as! ARTErrorInfo + } } - // Then: the room `attach` call fails with the same error as the channel `attach(_:)` call - #expect(try #require(roomAttachError as? ARTErrorInfo) === channelAttachError) + // Then: It calls through to the `performDetachOperation()` method on the room lifecycle manager + #expect(Result.areIdentical(result, managerDetachResult)) + #expect(await lifecycleManager.detachCallCount == 1) } - // MARK: - Detach + // MARK: - Room status @Test - func detach_detachesAllChannels_andSucceedsIfAllSucceed() async throws { - // Given: a DefaultRoom instance with ID "basketball", with a Realtime client for which `detach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - basketball::$chat::$reactions + func status() async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .success), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let subscription = await room.onStatusChange(bufferingPolicy: .unbounded) - async let detachedStatusChange = subscription.first { $0.current == .detached } + let lifecycleManagerRoomStatus = RoomStatus.attached // arbitrary - // When: `detach` is called on the room - try await room.detach() + let lifecycleManager = MockRoomLifecycleManager(roomStatus: lifecycleManagerRoomStatus) + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED - for channel in channelsList { - #expect(channel.detachCallCounter.isNonZero) - } + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - #expect(await room.status == .detached) - #expect(try #require(await detachedStatusChange).current == .detached) + // Then: The `status` property returns that of the room lifecycle manager + #expect(await room.status == lifecycleManagerRoomStatus) } @Test - func detach_detachesAllChannels_andFailsIfOneFails() async throws { - // Given: a DefaultRoom instance, with a Realtime client for which `detach(_:)` completes successfully if called on the following channels: - // - // - basketball::$chat::$chatMessages - // - basketball::$chat::$typingIndicators - // - // and fails when called on channel basketball::$chat::$reactions - let channelDetachError = ARTErrorInfo.createUnknownError() // arbitrary + func onStatusChange() async throws { + // Given: a DefaultRoom instance let channelsList = [ MockRealtimeChannel(name: "basketball::$chat::$chatMessages", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", detachResult: .success), - MockRealtimeChannel(name: "basketball::$chat::$reactions", detachResult: .failure(channelDetachError)), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime.create(channels: channels) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - // When: `detach` is called on the room - let roomDetachError: Error? - do { - try await room.detach() - roomDetachError = nil - } catch { - roomDetachError = error - } + let lifecycleManager = MockRoomLifecycleManager() + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) - // Then: the room `detach` call fails with the same error as the channel `detach(_:)` call - #expect(try #require(roomDetachError as? ARTErrorInfo) === channelDetachError) - } + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - // MARK: - Room status + // When: The room lifecycle manager emits a status change through `subscribeToState` + let managerStatusChange = RoomStatusChange(current: .detached, previous: .detaching) // arbitrary + let roomStatusSubscription = await room.onStatusChange(bufferingPolicy: .unbounded) + await lifecycleManager.emitStatusChange(managerStatusChange) - @Test - func status_startsAsInitialized() async throws { - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), - ] - let realtime = MockRealtime.create(channels: .init(channels: channelsList)) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - #expect(await room.status == .initialized) + // Then: The room emits this status change through `onStatusChange` + let roomStatusChange = try #require(await roomStatusSubscription.first { _ in true }) + #expect(roomStatusChange == managerStatusChange) } +} - @Test - func transition() async throws { - // Given: A DefaultRoom - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$typingIndicators"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), - ] - let realtime = MockRealtime.create(channels: .init(channels: channelsList)) - let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger()) - let originalStatus = await room.status - let newStatus = RoomStatus.attached // arbitrary - - let subscription1 = await room.onStatusChange(bufferingPolicy: .unbounded) - let subscription2 = await room.onStatusChange(bufferingPolicy: .unbounded) - - async let statusChange1 = subscription1.first { $0.current == newStatus } - async let statusChange2 = subscription2.first { $0.current == newStatus } - - // When: transition(to:) is called - await room.transition(to: newStatus) - - // Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `status` property to the new state - for statusChange in try await [#require(statusChange1), #require(statusChange2)] { - #expect(statusChange.previous == originalStatus) - #expect(statusChange.current == newStatus) +private extension Result { + /// An async equivalent of the initializer of the same name in the standard library. + init(catching body: () async throws(Failure) -> Success) async { + do { + let success = try await body() + self = .success(success) + } catch { + self = .failure(error) } + } +} - #expect(await room.status == .attached) +private extension Result where Success == Void, Failure == ARTErrorInfo { + static func areIdentical(_ lhs: Result, _ rhs: Result) -> Bool { + switch (lhs, rhs) { + case (.success, .success): + true + case let (.failure(lhsError), .failure(rhsError)): + lhsError === rhsError + default: + fatalError("Mis-implemented") + } } } diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index d3200fbc..69167d41 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -9,10 +9,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) // When: get(roomID:options:) is called let roomID = "basketball" @@ -20,7 +18,7 @@ struct DefaultRoomsTests { let room = try await rooms.get(roomID: roomID, options: options) // Then: It returns a DefaultRoom instance that uses the same Realtime instance, with the given ID and options - let defaultRoom = try #require(room as? DefaultRoom) + let defaultRoom = try #require(room as? DefaultRoom) #expect(defaultRoom.testsOnly_realtime === realtime) #expect(defaultRoom.roomID == roomID) #expect(defaultRoom.options == options) @@ -32,10 +30,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) let roomID = "basketball" let options = RoomOptions() @@ -54,10 +50,8 @@ struct DefaultRoomsTests { // Given: an instance of DefaultRooms, on which get(roomID:options:) has already been called with a given ID and options let realtime = MockRealtime.create(channels: .init(channels: [ .init(name: "basketball::$chat::$chatMessages"), - .init(name: "basketball::$chat::$typingIndicators"), - .init(name: "basketball::$chat::$reactions"), ])) - let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger()) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) let roomID = "basketball" let options = RoomOptions() diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift new file mode 100644 index 00000000..429e0d78 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -0,0 +1,53 @@ +import Ably +@testable import AblyChat + +actor MockRoomLifecycleManager: RoomLifecycleManager { + private let attachResult: Result? + private(set) var attachCallCount = 0 + private let detachResult: Result? + private(set) var detachCallCount = 0 + private let _roomStatus: RoomStatus? + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var subscriptions: [Subscription] = [] + + init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil) { + self.attachResult = attachResult + self.detachResult = detachResult + _roomStatus = roomStatus + } + + func performAttachOperation() async throws { + attachCallCount += 1 + guard let attachResult else { + fatalError("In order to call performAttachOperation, attachResult must be passed to the initializer") + } + try attachResult.get() + } + + func performDetachOperation() async throws { + detachCallCount += 1 + guard let detachResult else { + fatalError("In order to call performDetachOperation, detachResult must be passed to the initializer") + } + try detachResult.get() + } + + var roomStatus: RoomStatus { + guard let roomStatus = _roomStatus else { + fatalError("In order to call roomStatus, roomStatus must be passed to the initializer") + } + return roomStatus + } + + func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription { + let subscription = Subscription(bufferingPolicy: bufferingPolicy) + subscriptions.append(subscription) + return subscription + } + + func emitStatusChange(_ statusChange: RoomStatusChange) { + for subscription in subscriptions { + subscription.emit(statusChange) + } + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift new file mode 100644 index 00000000..b93e9bf2 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift @@ -0,0 +1,13 @@ +@testable import AblyChat + +actor MockRoomLifecycleManagerFactory: RoomLifecycleManagerFactory { + private let manager: MockRoomLifecycleManager + + init(manager: MockRoomLifecycleManager = .init()) { + self.manager = manager + } + + func createManager(contributors _: [DefaultRoomLifecycleContributor], logger _: any InternalLogger) async -> MockRoomLifecycleManager { + manager + } +}