diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 4c5cb7cc..b9c400ec 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -53,7 +53,7 @@ jobs: xcode-version: 16.2 - name: Spec coverage - run: swift run BuildTool spec-coverage --spec-commit-sha 45f0939 + run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Example/AblyChatExample/ContentView.swift b/Example/AblyChatExample/ContentView.swift index 75648bf9..329f3dbe 100644 --- a/Example/AblyChatExample/ContentView.swift +++ b/Example/AblyChatExample/ContentView.swift @@ -69,10 +69,7 @@ struct ContentView: View { } private func room() async throws -> Room { - try await chatClient.rooms.get( - roomID: roomID, - options: .allFeaturesEnabled - ) + try await chatClient.rooms.get(roomID: roomID) } private var sendTitle: String { @@ -361,7 +358,7 @@ struct ContentView: View { statusInfo = "\(status.current)...".capitalized } else { statusInfo = "\(status.current)".capitalized - if status.current == .attached { + if status.current.isAttached { Task { try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) withAnimation { diff --git a/Example/AblyChatExample/Mocks/MockClients.swift b/Example/AblyChatExample/Mocks/MockClients.swift index 97dd44eb..265d7388 100644 --- a/Example/AblyChatExample/Mocks/MockClients.swift +++ b/Example/AblyChatExample/Mocks/MockClients.swift @@ -52,6 +52,8 @@ class MockRoom: Room { nonisolated let typing: any Typing nonisolated let occupancy: any Occupancy + let channel: any RealtimeChannelProtocol = MockRealtime.Channel() + init(roomID: String, options: RoomOptions) { self.roomID = roomID self.options = options @@ -76,26 +78,28 @@ class MockRoom: Room { private func createSubscription() -> MockSubscription { mockSubscriptions.create(randomElement: { - RoomStatusChange(current: [.attached, .attached, .attached, .attached, .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil)) + RoomStatusChange(current: [.attached(error: nil), .attached(error: nil), .attached(error: nil), .attached(error: nil), .attaching(error: nil), .attaching(error: nil), .suspended(error: .createUnknownError())].randomElement()!, previous: .attaching(error: nil)) }, interval: 8) } func onStatusChange(bufferingPolicy _: BufferingPolicy) -> Subscription { .init(mockAsyncSequence: createSubscription()) } + + func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { + fatalError("Not yet implemented") + } } class MockMessages: Messages { let clientID: String let roomID: String - let channel: any RealtimeChannelProtocol private let mockSubscriptions = MockSubscriptionStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID - channel = MockRealtime.Channel() } private func createSubscription() -> MockSubscription { @@ -179,23 +183,17 @@ class MockMessages: Messages { mockSubscriptions.emit(message) return message } - - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { - fatalError("Not yet implemented") - } } class MockRoomReactions: RoomReactions { let clientID: String let roomID: String - let channel: any RealtimeChannelProtocol private let mockSubscriptions = MockSubscriptionStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID - channel = MockRealtime.Channel() } private func createSubscription() -> MockSubscription { @@ -226,23 +224,17 @@ class MockRoomReactions: RoomReactions { func subscribe(bufferingPolicy _: BufferingPolicy) -> Subscription { .init(mockAsyncSequence: createSubscription()) } - - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { - fatalError("Not yet implemented") - } } class MockTyping: Typing { let clientID: String let roomID: String - let channel: any RealtimeChannelProtocol private let mockSubscriptions = MockSubscriptionStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID - channel = MockRealtime.Channel() } private func createSubscription() -> MockSubscription { @@ -272,10 +264,6 @@ class MockTyping: Typing { func stop() async throws(ARTErrorInfo) { mockSubscriptions.emit(TypingEvent(currentlyTyping: [], change: .init(clientId: clientID, type: .stopped))) } - - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { - fatalError("Not yet implemented") - } } class MockPresence: Presence { @@ -392,23 +380,17 @@ class MockPresence: Presence { func subscribe(events _: [PresenceEventType], bufferingPolicy _: BufferingPolicy) -> Subscription { .init(mockAsyncSequence: createSubscription()) } - - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { - fatalError("Not yet implemented") - } } class MockOccupancy: Occupancy { let clientID: String let roomID: String - let channel: any RealtimeChannelProtocol private let mockSubscriptions = MockSubscriptionStorage() init(clientID: String, roomID: String) { self.clientID = clientID self.roomID = roomID - channel = MockRealtime.Channel() } private func createSubscription() -> MockSubscription { @@ -425,10 +407,6 @@ class MockOccupancy: Occupancy { func get() async throws(ARTErrorInfo) -> OccupancyEvent { OccupancyEvent(connections: 10, presenceMembers: 5) } - - func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { - fatalError("Not yet implemented") - } } class MockConnection: Connection { diff --git a/Package.swift b/Package.swift index 917b8890..9e6bc3dd 100644 --- a/Package.swift +++ b/Package.swift @@ -55,10 +55,6 @@ let package = Package( name: "Ably", package: "ably-cocoa" ), - .product( - name: "AsyncAlgorithms", - package: "swift-async-algorithms" - ), ] ), .testTarget( diff --git a/README.md b/README.md index bdcdc103..dfc204ef 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,9 @@ Task { } } -// Get a chat room for the tutorial - using the defaults to enable all features in the chat room +// Get a chat room for the tutorial let room = try await chatClient.rooms.get( - roomID: "readme-getting-started", options: RoomOptions.allFeaturesEnabled) + roomID: "readme-getting-started") // Add a listener to observe changes to the chat rooms status let statusSubscription = room.onStatusChange() diff --git a/Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift b/Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift index b6bc0d4a..86a41377 100644 --- a/Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift +++ b/Sources/AblyChat/AblyCocoaExtensions/InternalAblyCocoaTypes.swift @@ -7,9 +7,6 @@ import Ably /// - `async` methods instead of callbacks /// - typed throws /// - `JSONValue` instead of `Any` -/// - `AsyncSequence` where helpful -/// -/// Note that the API of this protocol is not currently consistent; for example there are some places in the codebase where we subscribe to Realtime channel state using callbacks, and other places where we subscribe using `AsyncSequence`. We should aim to make this consistent; see https://github.com/ably/ably-chat-swift/issues/245. /// /// Hopefully we will eventually be able to remove this interface once we've improved the experience of using ably-cocoa from Swift (https://github.com/ably/ably-cocoa/issues/1967). /// @@ -66,9 +63,6 @@ internal protocol InternalRealtimeChannelProtocol: AnyObject, Sendable { func subscribe(_ name: String, callback: @escaping @MainActor (ARTMessage) -> Void) -> ARTEventListener? var properties: ARTChannelProperties { get } func off(_ listener: ARTEventListener) - - /// Equivalent to subscribing to a `RealtimeChannelProtocol` object’s state changes via its `on(_:)` method. The subscription should use the ``BufferingPolicy/unbounded`` buffering policy. - func subscribeToState() -> Subscription } /// Expresses the requirements of the object returned by ``InternalRealtimeChannelProtocol/presence``. @@ -95,6 +89,34 @@ internal protocol InternalConnectionProtocol: AnyObject, Sendable { func off(_ listener: ARTEventListener) } +/// Converts a `@MainActor` callback into one that can be passed as a callback to ably-cocoa. +/// +/// The returned callback asserts that it is called on the main thread and then synchronously calls the passed callback. It also allows non-`Sendable` values to be passed from ably-cocoa to the passed callback. +/// +/// The main thread assertion is our way of asserting the requirement, documented in the `DefaultChatClient` initializer, that the ably-cocoa client must be using the main queue as its `dispatchQueue`. (This is the only way we can do it without accessing private ably-cocoa API, since we don't publicly expose the options that a client is using.) +/// +/// - Warning: You must be sure that after ably-cocoa calls the returned callback, it will not modify any of the mutable state contained inside the argument that it passes to the callback. This is true of the two non-`Sendable` types with which we're currently using it; namely `ARTMessage` and `ARTPresenceMessage`. Ideally, we would instead annotate these callback arguments in ably-cocoa with `NS_SWIFT_SENDING`, to allow us to then mark the corresponding argument in these callbacks as `sending` and not have to circumvent compiler sendability checking, but as of Xcode 16.1 this annotation does yet not seem to have any effect; see [ably-cocoa#1967](https://github.com/ably/ably-cocoa/issues/1967). +private func toAblyCocoaCallback(_ callback: @escaping @MainActor (Arg) -> Void) -> (Arg) -> Void { + { arg in + let sendingBox = UnsafeSendingBox(value: arg) + + // We use `preconditionIsolated` in addition to `assumeIsolated` because only the former accepts a message. + MainActor.preconditionIsolated("The Ably Chat SDK requires that your ARTRealtime instance be using the main queue as its dispatchQueue.") + MainActor.assumeIsolated { + callback(sendingBox.value) + } + } +} + +/// A box that makes the compiler ignore that a non-Sendable value is crossing an isolation boundary. Used by `toAblyCocoaCallback`; don't use it elsewhere unless you know what you're doing. +private final class UnsafeSendingBox: @unchecked Sendable { + var value: T + + init(value: T) { + self.value = value + } +} + internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtocol { private let underlying: RealtimeClient internal let channels: Channels @@ -224,11 +246,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc } internal func on(_ cb: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { - underlying.on(cb) + underlying.on(toAblyCocoaCallback(cb)) } internal func on(_ event: ARTChannelEvent, callback cb: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { - underlying.on(event, callback: cb) + underlying.on(event, callback: toAblyCocoaCallback(cb)) } internal func unsubscribe(_ listener: ARTEventListener?) { @@ -242,15 +264,6 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc internal func off(_ listener: ARTEventListener) { underlying.off(listener) } - - internal func subscribeToState() -> Subscription { - let subscription = Subscription(bufferingPolicy: .unbounded) - let eventListener = underlying.on { subscription.emit($0) } - subscription.addTerminationHandler { [weak underlying] in - underlying?.unsubscribe(eventListener) - } - return subscription - } } internal final class Presence: InternalRealtimePresenceProtocol { @@ -345,11 +358,11 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc } internal func subscribe(_ callback: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? { - underlying.subscribe(callback) + underlying.subscribe(toAblyCocoaCallback(callback)) } internal func subscribe(_ action: ARTPresenceAction, callback: @escaping @MainActor (ARTPresenceMessage) -> Void) -> ARTEventListener? { - underlying.subscribe(action, callback: callback) + underlying.subscribe(action, callback: toAblyCocoaCallback(callback)) } internal func unsubscribe(_ listener: ARTEventListener) { @@ -389,7 +402,7 @@ internal final class InternalRealtimeClientAdapter: InternalRealtimeClientProtoc } internal func on(_ cb: @escaping @MainActor (ARTConnectionStateChange) -> Void) -> ARTEventListener { - underlying.on(cb) + underlying.on(toAblyCocoaCallback(cb)) } internal func off(_ listener: ARTEventListener) { diff --git a/Sources/AblyChat/ChatAPI.swift b/Sources/AblyChat/ChatAPI.swift index e7d9ef4f..a04d36bf 100644 --- a/Sources/AblyChat/ChatAPI.swift +++ b/Sources/AblyChat/ChatAPI.swift @@ -3,8 +3,7 @@ import Ably @MainActor internal final class ChatAPI: Sendable { private let realtime: any InternalRealtimeClientProtocol - private let apiVersion = "/chat/v1" - private let apiVersionV2 = "/chat/v2" // TODO: remove v1 after full transition to v2 + private let apiVersionV3 = "/chat/v3" public init(realtime: any InternalRealtimeClientProtocol) { self.realtime = realtime @@ -12,7 +11,7 @@ internal final class ChatAPI: Sendable { // (CHA-M6) Messages should be queryable from a paginated REST API. internal func getMessages(roomId: String, params: QueryOptions) async throws(InternalError) -> any PaginatedResult { - let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages" + let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages" let result: Result, InternalError> = await makePaginatedRequest(endpoint, params: params.asQueryItems()) return try result.get() } @@ -47,7 +46,7 @@ internal final class ChatAPI: Sendable { throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } - let endpoint = "\(apiVersionV2)/rooms/\(roomId)/messages" + let endpoint = "\(apiVersionV3)/rooms/\(roomId)/messages" var body: [String: JSONValue] = ["text": .string(params.text)] // (CHA-M3b) A message may be sent without metadata or headers. When these are not specified by the user, they must be omitted from the REST payload. @@ -86,7 +85,7 @@ internal final class ChatAPI: Sendable { throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } - let endpoint = "\(apiVersionV2)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)" + let endpoint = "\(apiVersionV3)/rooms/\(modifiedMessage.roomID)/messages/\(modifiedMessage.serial)" var body: [String: JSONValue] = [:] let messageObject: [String: JSONValue] = [ "text": .string(modifiedMessage.text), @@ -134,7 +133,7 @@ internal final class ChatAPI: Sendable { // (CHA-M9) A client must be able to delete a message in a room. // (CHA-M9a) A client may delete a message via the Chat REST API by calling the delete method. internal func deleteMessage(message: Message, params: DeleteMessageParams) async throws(InternalError) -> Message { - let endpoint = "\(apiVersionV2)/rooms/\(message.roomID)/messages/\(message.serial)/delete" + let endpoint = "\(apiVersionV3)/rooms/\(message.roomID)/messages/\(message.serial)/delete" var body: [String: JSONValue] = [:] if let description = params.description { @@ -172,7 +171,7 @@ internal final class ChatAPI: Sendable { } internal func getOccupancy(roomId: String) async throws(InternalError) -> OccupancyEvent { - let endpoint = "\(apiVersion)/rooms/\(roomId)/occupancy" + let endpoint = "\(apiVersionV3)/rooms/\(roomId)/occupancy" return try await makeRequest(endpoint, method: "GET") } diff --git a/Sources/AblyChat/ChatClient.swift b/Sources/AblyChat/ChatClient.swift index 1b0b51c5..b4f915fe 100644 --- a/Sources/AblyChat/ChatClient.swift +++ b/Sources/AblyChat/ChatClient.swift @@ -69,7 +69,7 @@ public class DefaultChatClient: ChatClient { * Constructor for Chat * * - Parameters: - * - realtime: The Ably Realtime client. + * - realtime: The Ably Realtime client. If this is an instance of `ARTRealtime` from the ably-cocoa SDK, then its `dispatchQueue` option must be the main queue (this is its default behaviour). * - clientOptions: The client options. */ public convenience init(realtime suppliedRealtime: any SuppliedRealtimeClientProtocol, clientOptions: ChatClientOptions?) { diff --git a/Sources/AblyChat/DefaultMessages.swift b/Sources/AblyChat/DefaultMessages.swift index 3fc9e6b7..db23bd31 100644 --- a/Sources/AblyChat/DefaultMessages.swift +++ b/Sources/AblyChat/DefaultMessages.swift @@ -6,25 +6,15 @@ private struct MessageSubscriptionWrapper { var serial: String } -internal final class DefaultMessages: Messages, EmitsDiscontinuities { - public nonisolated let featureChannel: FeatureChannel +internal final class DefaultMessages: Messages { + private let channel: any InternalRealtimeChannelProtocol private let implementation: Implementation - internal init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, clientID: String, logger: InternalLogger) { - self.featureChannel = featureChannel - implementation = .init(featureChannel: featureChannel, chatAPI: chatAPI, roomID: roomID, clientID: clientID, logger: logger) + internal init(channel: any InternalRealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, clientID: String, logger: InternalLogger) { + self.channel = channel + implementation = .init(channel: channel, chatAPI: chatAPI, roomID: roomID, clientID: clientID, logger: logger) } - internal nonisolated var channel: any RealtimeChannelProtocol { - featureChannel.channel.underlying - } - - #if DEBUG - internal nonisolated var testsOnly_internalChannel: any InternalRealtimeChannelProtocol { - featureChannel.channel - } - #endif - internal func subscribe(bufferingPolicy: BufferingPolicy) async throws(ARTErrorInfo) -> MessageSubscription { try await implementation.subscribe(bufferingPolicy: bufferingPolicy) } @@ -45,10 +35,6 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { try await implementation.delete(message: message, params: params) } - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - internal enum MessagesError: Error { case noReferenceToSelf } @@ -57,7 +43,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { @MainActor private final class Implementation: Sendable { private let roomID: String - public nonisolated let featureChannel: FeatureChannel + private let channel: any InternalRealtimeChannelProtocol private let chatAPI: ChatAPI private let clientID: String private let logger: InternalLogger @@ -65,8 +51,8 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // UUID acts as a unique identifier for each listener/subscription. MessageSubscriptionWrapper houses the subscription and the serial of when it was attached or resumed. private var subscriptionPoints: [UUID: MessageSubscriptionWrapper] = [:] - internal init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, clientID: String, logger: InternalLogger) { - self.featureChannel = featureChannel + internal init(channel: any InternalRealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, clientID: String, logger: InternalLogger) { + self.channel = channel self.chatAPI = chatAPI self.roomID = roomID self.clientID = clientID @@ -95,7 +81,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // (CHA-M4c) When a realtime message with name set to message.created is received, it is translated into a message event, which contains a type field with the event type as well as a message field containing the Message Struct. This event is then broadcast to all subscribers. // (CHA-M4d) If a realtime message with an unknown name is received, the SDK shall silently discard the message, though it may log at DEBUG or TRACE level. // (CHA-M5k) Incoming realtime events that are malformed (unknown field should be ignored) shall not be emitted to subscribers. - let eventListener = featureChannel.channel.subscribe(RealtimeMessageName.chatMessage.rawValue) { message in + let eventListener = channel.subscribe(RealtimeMessageName.chatMessage.rawValue) { message in do { // TODO: Revisit errors thrown as part of https://github.com/ably-labs/ably-chat-swift/issues/32 guard let ablyCocoaData = message.data, @@ -162,7 +148,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { guard let self else { return } - featureChannel.channel.unsubscribe(eventListener) + channel.unsubscribe(eventListener) subscriptionPoints.removeValue(forKey: uuid) } } @@ -207,11 +193,6 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } } - // (CHA-M7) Users may subscribe to discontinuity events to know when there’s been a break in messages that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - private func getBeforeSubscriptionStart(_ uuid: UUID, params: QueryOptions) async throws(InternalError) -> any PaginatedResult { guard let subscriptionPoint = subscriptionPoints[uuid]?.serial else { throw ARTErrorInfo.create( @@ -234,7 +215,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { private func handleChannelEvents(roomId _: String) { // (CHA-M5c) If a channel leaves the ATTACHED state and then re-enters ATTACHED with resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. - _ = featureChannel.channel.on(.attached) { [weak self] stateChange in + _ = channel.on(.attached) { [weak self] stateChange in Task { do { try await self?.handleAttach(fromResume: stateChange.resumed) @@ -245,7 +226,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { } // (CHA-M4d) If a channel UPDATE event is received and resumed=false, then it must be assumed that messages have been missed. The subscription point of any subscribers must be reset to the attachSerial. - _ = featureChannel.channel.on(.update) { [weak self] stateChange in + _ = channel.on(.update) { [weak self] stateChange in Task { do { try await self?.handleAttach(fromResume: stateChange.resumed) @@ -280,8 +261,8 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { private func resolveSubscriptionStart() async throws(InternalError) -> String { logger.log(message: "Resolving subscription start", level: .debug) // (CHA-M5a) If a subscription is added when the underlying realtime channel is ATTACHED, then the subscription point is the current channelSerial of the realtime channel. - if await featureChannel.channel.state == .attached { - if let channelSerial = featureChannel.channel.properties.channelSerial { + if await channel.state == .attached { + if let channelSerial = channel.properties.channelSerial { logger.log(message: "Channel is attached, returning channelSerial: \(channelSerial)", level: .debug) return channelSerial } else { @@ -299,8 +280,8 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { private func serialOnChannelAttach() async throws(InternalError) -> String { logger.log(message: "Resolving serial on channel attach", level: .debug) // If the state is already 'attached', return the attachSerial immediately - if await featureChannel.channel.state == .attached { - if let attachSerial = featureChannel.channel.properties.attachSerial { + if await channel.state == .attached { + if let attachSerial = channel.properties.attachSerial { logger.log(message: "Channel is attached, returning attachSerial: \(attachSerial)", level: .debug) return attachSerial } else { @@ -315,7 +296,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { // avoids multiple invocations of the continuation var nillableContinuation: CheckedContinuation, Never>? = continuation - _ = featureChannel.channel.on { [weak self] stateChange in + _ = channel.on { [weak self] stateChange in guard let self else { return } @@ -323,7 +304,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { switch stateChange.current { case .attached: // Handle successful attachment - if let attachSerial = featureChannel.channel.properties.attachSerial { + if let attachSerial = channel.properties.attachSerial { logger.log(message: "Channel is attached, returning attachSerial: \(attachSerial)", level: .debug) nillableContinuation?.resume(returning: .success(attachSerial)) } else { @@ -334,7 +315,7 @@ internal final class DefaultMessages: Messages, EmitsDiscontinuities { case .failed, .suspended: // TODO: Revisit as part of https://github.com/ably-labs/ably-chat-swift/issues/32 logger.log(message: "Channel failed to attach", level: .error) - let errorCodeCase = ErrorCode.CaseThatImpliesFixedStatusCode.messagesAttachmentFailed + let errorCodeCase = ErrorCode.CaseThatImpliesFixedStatusCode.badRequest nillableContinuation?.resume( returning: .failure( ARTErrorInfo.create( diff --git a/Sources/AblyChat/DefaultOccupancy.swift b/Sources/AblyChat/DefaultOccupancy.swift index 845f14a8..e6ed92fd 100644 --- a/Sources/AblyChat/DefaultOccupancy.swift +++ b/Sources/AblyChat/DefaultOccupancy.swift @@ -1,16 +1,10 @@ import Ably -internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { - public nonisolated let featureChannel: FeatureChannel +internal final class DefaultOccupancy: Occupancy { private let implementation: Implementation - internal nonisolated var channel: any RealtimeChannelProtocol { - featureChannel.channel.underlying - } - - internal init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, logger: InternalLogger) { - self.featureChannel = featureChannel - implementation = .init(featureChannel: featureChannel, chatAPI: chatAPI, roomID: roomID, logger: logger) + internal init(channel: any InternalRealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, logger: InternalLogger, options: OccupancyOptions) { + implementation = .init(channel: channel, chatAPI: chatAPI, roomID: roomID, logger: logger, options: options) } internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { @@ -21,41 +15,44 @@ internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { try await implementation.get() } - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `Occupancy` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @MainActor internal final class Implementation: Sendable { private let chatAPI: ChatAPI private let roomID: String private let logger: InternalLogger - public nonisolated let featureChannel: FeatureChannel + private let channel: any InternalRealtimeChannelProtocol + private let options: OccupancyOptions - internal init(featureChannel: FeatureChannel, chatAPI: ChatAPI, roomID: String, logger: InternalLogger) { - self.featureChannel = featureChannel + internal init(channel: any InternalRealtimeChannelProtocol, chatAPI: ChatAPI, roomID: String, logger: InternalLogger, options: OccupancyOptions) { + self.channel = channel self.chatAPI = chatAPI self.roomID = roomID self.logger = logger + self.options = options } - // (CHA-04a) Users may register a listener that receives occupancy events in realtime. - // (CHA-04c) When a regular occupancy event is received on the channel (a standard PubSub occupancy event per the docs), the SDK will convert it into occupancy event format and broadcast it to subscribers. - // (CHA-04d) If an invalid occupancy event is received on the channel, it shall be dropped. + // (CHA-O4a) Users may register a listener that receives occupancy events in realtime. + // (CHA-O4c) When a regular occupancy event is received on the channel (a standard PubSub occupancy event per the docs), the SDK will convert it into occupancy event format and broadcast it to subscribers. + // (CHA-O4d) If an invalid occupancy event is received on the channel, it shall be dropped. internal func subscribe(bufferingPolicy: BufferingPolicy) -> Subscription { + // CHA-O4e (we use a fatalError for this programmer error, which is the idiomatic thing to do for Swift) + guard options.enableEvents else { + fatalError("In order to be able to subscribe to presence events, please set enableEvents to true in the room's occupancy options.") + } + logger.log(message: "Subscribing to occupancy events", level: .debug) let subscription = Subscription(bufferingPolicy: bufferingPolicy) - let eventListener = featureChannel.channel.subscribe(OccupancyEvents.meta.rawValue) { [logger] message in + let eventListener = channel.subscribe(OccupancyEvents.meta.rawValue) { [logger] message in logger.log(message: "Received occupancy message: \(message)", level: .debug) guard let data = message.data as? [String: Any], let metrics = data["metrics"] as? [String: Any] else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data or metrics") logger.log(message: "Error parsing occupancy message: \(error)", level: .error) - return // (CHA-04d) implies we don't throw an error + return // (CHA-O4d) implies we don't throw an error } let connections = metrics["connections"] as? Int ?? 0 @@ -69,7 +66,7 @@ internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { subscription.addTerminationHandler { [weak self] in if let eventListener { Task { @MainActor in - self?.featureChannel.channel.off(eventListener) + self?.channel.off(eventListener) } } } @@ -86,10 +83,5 @@ internal final class DefaultOccupancy: Occupancy, EmitsDiscontinuities { throw error.toARTErrorInfo() } } - - // (CHA-O5) Users may subscribe to discontinuity events to know when there’s been a break in occupancy. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For occupancy, there shouldn’t need to be user action as most channels will send occupancy updates regularly as clients churn. - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } } } diff --git a/Sources/AblyChat/DefaultPresence.swift b/Sources/AblyChat/DefaultPresence.swift index 4896ae81..fd60c046 100644 --- a/Sources/AblyChat/DefaultPresence.swift +++ b/Sources/AblyChat/DefaultPresence.swift @@ -1,16 +1,10 @@ import Ably -internal final class DefaultPresence: Presence, EmitsDiscontinuities { - private let featureChannel: FeatureChannel +internal final class DefaultPresence: Presence { private let implementation: Implementation - internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger) { - self.featureChannel = featureChannel - implementation = .init(featureChannel: featureChannel, roomID: roomID, clientID: clientID, logger: logger) - } - - internal nonisolated var channel: any RealtimeChannelProtocol { - featureChannel.channel.underlying + internal init(channel: any InternalRealtimeChannelProtocol, roomLifecycleManager: any RoomLifecycleManager, roomID: String, clientID: String, logger: InternalLogger, options: PresenceOptions) { + implementation = .init(channel: channel, roomLifecycleManager: roomLifecycleManager, roomID: roomID, clientID: clientID, logger: logger, options: options) } internal func get() async throws(ARTErrorInfo) -> [PresenceMember] { @@ -57,23 +51,23 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { implementation.subscribe(events: events, bufferingPolicy: bufferingPolicy) } - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `Presence` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @MainActor private final class Implementation: Sendable { - private let featureChannel: FeatureChannel + private let channel: any InternalRealtimeChannelProtocol + private let roomLifecycleManager: any RoomLifecycleManager private let roomID: String private let clientID: String private let logger: InternalLogger + private let options: PresenceOptions - internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger) { + internal init(channel: any InternalRealtimeChannelProtocol, roomLifecycleManager: any RoomLifecycleManager, roomID: String, clientID: String, logger: InternalLogger, options: PresenceOptions) { self.roomID = roomID - self.featureChannel = featureChannel + self.channel = channel + self.roomLifecycleManager = roomLifecycleManager self.clientID = clientID self.logger = logger + self.options = options } // (CHA-PR6) It must be possible to retrieve all the @Members of the presence set. The behaviour depends on the current room status, as presence operations in a Realtime Client cause implicit attaches. @@ -83,7 +77,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR6b to CHA-PR6f do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) throw error @@ -91,7 +85,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let members: [PresenceMessage] do { - members = try await featureChannel.channel.presence.get() + members = try await channel.presence.get() } catch { logger.log(message: error.message, level: .error) throw error @@ -108,7 +102,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR6b to CHA-PR6f do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) throw error @@ -116,7 +110,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let members: [PresenceMessage] do { - members = try await featureChannel.channel.presence.get(params.asARTRealtimePresenceQuery()) + members = try await channel.presence.get(params.asARTRealtimePresenceQuery()) } catch { logger.log(message: error.message, level: .error) throw error @@ -134,7 +128,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR6b to CHA-PR6f do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence get operation: \(error)", level: .error) throw error @@ -142,7 +136,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let members: [PresenceMessage] do { - members = try await featureChannel.channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) + members = try await channel.presence.get(ARTRealtimePresenceQuery(clientId: clientID, connectionId: nil)) } catch { logger.log(message: error.message, level: .error) throw error @@ -169,7 +163,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR3c to CHA-PR3g do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence enter operation: \(error)", level: .error) throw error @@ -178,7 +172,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let dto = PresenceDataDTO(userCustomData: data) do { - try await featureChannel.channel.presence.enterClient(clientID, data: dto.toJSONValue) + try await channel.presence.enterClient(clientID, data: dto.toJSONValue) } catch { logger.log(message: "Error entering presence: \(error)", level: .error) throw error @@ -203,7 +197,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR10c to CHA-PR10g do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence update operation: \(error)", level: .error) throw error @@ -212,7 +206,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let dto = PresenceDataDTO(userCustomData: data) do { - try await featureChannel.channel.presence.update(dto.toJSONValue) + try await channel.presence.update(dto.toJSONValue) } catch { logger.log(message: "Error updating presence: \(error)", level: .error) throw error @@ -237,7 +231,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { // CHA-PR6b to CHA-PR6f do { - try await featureChannel.waitToBeAbleToPerformPresenceOperations(requestedByFeature: RoomFeature.presence) + try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: .presence) } catch { logger.log(message: "Error waiting to be able to perform presence leave operation: \(error)", level: .error) throw error @@ -246,7 +240,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { let dto = PresenceDataDTO(userCustomData: data) do { - try await featureChannel.channel.presence.leave(dto.toJSONValue) + try await channel.presence.leave(dto.toJSONValue) } catch { logger.log(message: "Error leaving presence: \(error)", level: .error) throw error @@ -256,12 +250,21 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { } } + private func fatalErrorIfEnableEventsDisabled() { + // CHA-PR7d (we use a fatalError for this programmer error, which is the idiomatic thing to do for Swift) + guard options.enableEvents else { + fatalError("In order to be able to subscribe to presence events, please set enableEvents to true in the room's presence options.") + } + } + // (CHA-PR7a) Users may provide a listener to subscribe to all presence events in a room. // (CHA-PR7b) Users may provide a listener and a list of selected presence events, to subscribe to just those events in a room. internal func subscribe(event: PresenceEventType, bufferingPolicy: BufferingPolicy) -> Subscription { + fatalErrorIfEnableEventsDisabled() + logger.log(message: "Subscribing to presence events", level: .debug) let subscription = Subscription(bufferingPolicy: bufferingPolicy) - let eventListener = featureChannel.channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in + let eventListener = channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in logger.log(message: "Received presence message: \(message)", level: .debug) do { // processPresenceSubscribe is logging so we don't need to log here @@ -274,7 +277,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { subscription.addTerminationHandler { [weak self] in if let eventListener { Task { @MainActor in - self?.featureChannel.channel.presence.unsubscribe(eventListener) + self?.channel.presence.unsubscribe(eventListener) } } } @@ -282,11 +285,13 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { } internal func subscribe(events: [PresenceEventType], bufferingPolicy: BufferingPolicy) -> Subscription { + fatalErrorIfEnableEventsDisabled() + logger.log(message: "Subscribing to presence events", level: .debug) let subscription = Subscription(bufferingPolicy: bufferingPolicy) let eventListeners = events.map { event in - featureChannel.channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in + channel.presence.subscribe(event.toARTPresenceAction()) { [processPresenceSubscribe, logger] message in logger.log(message: "Received presence message: \(message)", level: .debug) do { let presenceEvent = try processPresenceSubscribe(PresenceMessage(ablyCocoaPresenceMessage: message), event) @@ -301,7 +306,7 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { Task { @MainActor in for eventListener in eventListeners { if let eventListener { - self?.featureChannel.channel.presence.unsubscribe(eventListener) + self?.channel.presence.unsubscribe(eventListener) } } } @@ -310,11 +315,6 @@ internal final class DefaultPresence: Presence, EmitsDiscontinuities { return subscription } - // (CHA-PR8) Users may subscribe to discontinuity events to know when there’s been a break in presence. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For presence, there shouldn’t need to be user action as the underlying core SDK will heal the presence set. - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - private func decodePresenceDataDTO(from presenceData: JSONValue?) throws(InternalError) -> PresenceDataDTO { guard let presenceData else { let error = ARTErrorInfo.create(withCode: 50000, status: 500, message: "Received incoming message without data") diff --git a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift b/Sources/AblyChat/DefaultRoomLifecycleContributor.swift deleted file mode 100644 index 5d504325..00000000 --- a/Sources/AblyChat/DefaultRoomLifecycleContributor.swift +++ /dev/null @@ -1,28 +0,0 @@ -import Ably - -internal class DefaultRoomLifecycleContributor: RoomLifecycleContributor, EmitsDiscontinuities, CustomDebugStringConvertible { - internal nonisolated let channel: any InternalRealtimeChannelProtocol - internal nonisolated let feature: RoomFeature - private var discontinuitySubscriptions = SubscriptionStorage() - - internal init(channel: any InternalRealtimeChannelProtocol, feature: RoomFeature) { - self.channel = channel - self.feature = feature - } - - // MARK: - Discontinuities - - internal func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) { - discontinuitySubscriptions.emit(discontinuity) - } - - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) - } - - // MARK: - CustomDebugStringConvertible - - internal nonisolated var debugDescription: String { - "(\(id): \(feature), \(channel))" - } -} diff --git a/Sources/AblyChat/DefaultRoomReactions.swift b/Sources/AblyChat/DefaultRoomReactions.swift index 48d7e0e9..b044a99c 100644 --- a/Sources/AblyChat/DefaultRoomReactions.swift +++ b/Sources/AblyChat/DefaultRoomReactions.swift @@ -1,22 +1,10 @@ import Ably -internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { - public let featureChannel: FeatureChannel +internal final class DefaultRoomReactions: RoomReactions { private let implementation: Implementation - internal nonisolated var channel: any RealtimeChannelProtocol { - featureChannel.channel.underlying - } - - #if DEBUG - internal nonisolated var testsOnly_internalChannel: any InternalRealtimeChannelProtocol { - featureChannel.channel - } - #endif - - internal init(featureChannel: FeatureChannel, clientID: String, roomID: String, logger: InternalLogger) { - self.featureChannel = featureChannel - implementation = .init(featureChannel: featureChannel, clientID: clientID, roomID: roomID, logger: logger) + internal init(channel: any InternalRealtimeChannelProtocol, clientID: String, roomID: String, logger: InternalLogger) { + implementation = .init(channel: channel, clientID: clientID, roomID: roomID, logger: logger) } internal func send(params: SendReactionParams) async throws(ARTErrorInfo) { @@ -27,34 +15,30 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { implementation.subscribe(bufferingPolicy: bufferingPolicy) } - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `RoomReactions` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @MainActor private final class Implementation: Sendable { - public let featureChannel: FeatureChannel + private let channel: any InternalRealtimeChannelProtocol private let roomID: String private let logger: InternalLogger private let clientID: String - internal init(featureChannel: FeatureChannel, clientID: String, roomID: String, logger: InternalLogger) { + internal init(channel: any InternalRealtimeChannelProtocol, clientID: String, roomID: String, logger: InternalLogger) { self.roomID = roomID - self.featureChannel = featureChannel + self.channel = channel self.logger = logger self.clientID = clientID } // (CHA-ER3) Ephemeral room reactions are sent to Ably via the Realtime connection via a send method. - // (CHA-ER3a) Reactions are sent on the channel using a message in a particular format - see spec for format. + // (CHA-ER3d) Reactions are sent on the channel using a message in a particular format - see spec for format. internal func send(params: SendReactionParams) async throws(ARTErrorInfo) { do { logger.log(message: "Sending reaction with params: \(params)", level: .debug) let dto = RoomReactionDTO(type: params.type, metadata: params.metadata, headers: params.headers) - try await featureChannel.channel.publish( + try await channel.publish( RoomReactionEvents.reaction.rawValue, data: dto.data.toJSONValue, extras: dto.extras.toJSONObject @@ -71,7 +55,7 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { let subscription = Subscription(bufferingPolicy: bufferingPolicy) // (CHA-ER4c) Realtime events with an unknown name shall be silently discarded. - let eventListener = featureChannel.channel.subscribe(RoomReactionEvents.reaction.rawValue) { [clientID, logger] message in + let eventListener = channel.subscribe(RoomReactionEvents.reaction.rawValue) { [clientID, logger] message in logger.log(message: "Received roomReaction message: \(message)", level: .debug) do { guard let ablyCocoaData = message.data else { @@ -113,18 +97,13 @@ internal final class DefaultRoomReactions: RoomReactions, EmitsDiscontinuities { subscription.addTerminationHandler { [weak self] in Task { @MainActor in - self?.featureChannel.channel.unsubscribe(eventListener) + self?.channel.unsubscribe(eventListener) } } return subscription } - // (CHA-ER5) Users may subscribe to discontinuity events to know when there’s been a break in reactions that they need to resolve. Their listener will be called when a discontinuity event is triggered from the room lifecycle. - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - private enum RoomReactionsError: Error { case noReferenceToSelf } diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index 5e1b6637..8e686bd2 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -1,16 +1,10 @@ import Ably internal final class DefaultTyping: Typing { - private let featureChannel: FeatureChannel private let implementation: Implementation - internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, heartbeatThrottle: TimeInterval, clock: some ClockProtocol) { - self.featureChannel = featureChannel - implementation = .init(featureChannel: featureChannel, roomID: roomID, clientID: clientID, logger: logger, heartbeatThrottle: heartbeatThrottle, clock: clock) - } - - internal nonisolated var channel: any RealtimeChannelProtocol { - featureChannel.channel.underlying + internal init(channel: any InternalRealtimeChannelProtocol, roomID: String, clientID: String, logger: InternalLogger, heartbeatThrottle: TimeInterval, clock: some ClockProtocol) { + implementation = .init(channel: channel, roomID: roomID, clientID: clientID, logger: logger, heartbeatThrottle: heartbeatThrottle, clock: clock) } // (CHA-T6) Users may subscribe to typing events – updates to a set of clientIDs that are typing. This operation, like all subscription operations, has no side-effects in relation to room lifecycle. @@ -41,14 +35,10 @@ internal final class DefaultTyping: Typing { } } - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - implementation.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - /// This class exists to make sure that the internals of the SDK only access ably-cocoa via the `InternalRealtimeChannelProtocol` interface. It does this by removing access to the `channel` property that exists as part of the public API of the `Typing` protocol, making it unlikely that we accidentally try to call the `ARTRealtimeChannelProtocol` interface. We can remove this `Implementation` class when we remove the feature-level `channel` property in https://github.com/ably/ably-chat-swift/issues/242. @MainActor private final class Implementation { - private let featureChannel: FeatureChannel + private let channel: any InternalRealtimeChannelProtocol private let roomID: String private let clientID: String private let logger: InternalLogger @@ -65,9 +55,9 @@ internal final class DefaultTyping: Typing { // (CHA-TM14b1) During this time, each new subsequent call to either function shall abort the previously queued call. In doing so, there shall only ever be one pending call and while the mutex is held, thus the most recent call shall "win" and execute once the mutex is released. private let keyboardOperationQueue = TypingOperationQueue() - internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, heartbeatThrottle: TimeInterval, clock: some ClockProtocol) { + internal init(channel: any InternalRealtimeChannelProtocol, roomID: String, clientID: String, logger: InternalLogger, heartbeatThrottle: TimeInterval, clock: some ClockProtocol) { self.roomID = roomID - self.featureChannel = featureChannel + self.channel = channel self.clientID = clientID self.logger = logger self.heartbeatThrottle = heartbeatThrottle @@ -84,7 +74,7 @@ internal final class DefaultTyping: Typing { // (CHA-T6a) Users may provide a listener to subscribe to typing event V2 in a chat room. let subscription = Subscription(bufferingPolicy: bufferingPolicy) - let startedEventListener = featureChannel.channel.subscribe(TypingEvents.started.rawValue) { [weak self] message in + let startedEventListener = channel.subscribe(TypingEvents.started.rawValue) { [weak self] message in guard let self, let messageClientID = message.clientId else { return } @@ -115,7 +105,7 @@ internal final class DefaultTyping: Typing { } } - let stoppedEventListener = featureChannel.channel.subscribe(TypingEvents.stopped.rawValue) { [weak self] message in + let stoppedEventListener = channel.subscribe(TypingEvents.stopped.rawValue) { [weak self] message in guard let self, let messageClientID = message.clientId else { return } @@ -141,10 +131,10 @@ internal final class DefaultTyping: Typing { subscription.addTerminationHandler { [weak self] in Task { @MainActor in if let startedEventListener { - self?.featureChannel.channel.unsubscribe(startedEventListener) + self?.channel.unsubscribe(startedEventListener) } if let stoppedEventListener { - self?.featureChannel.channel.unsubscribe(stoppedEventListener) + self?.channel.unsubscribe(stoppedEventListener) } } } @@ -180,7 +170,7 @@ internal final class DefaultTyping: Typing { logger.log(message: "Starting typing indicator for client: \(clientID)", level: .debug) // (CHA-T4a3) The client shall publish an ephemeral message to the channel with the name field set to typing.started, the format of which is detailed here. // (CHA-T4a5) The client must wait for the publish to succeed or fail before returning the result to the caller. If the publish fails, the client must throw an ErrorInfo. - try await featureChannel.channel.publish( + try await channel.publish( TypingEvents.started.rawValue, data: nil, extras: ["ephemeral": true] @@ -200,7 +190,7 @@ internal final class DefaultTyping: Typing { if typingTimerManager.isHeartbeatTimerActive { logger.log(message: "Stopping typing indicator for client: \(clientID)", level: .debug) // (CHA-T5d) The client shall publish an ephemeral message to the channel with the name field set to typing.stopped, the format of which is detailed here. - try await featureChannel.channel.publish( + try await channel.publish( TypingEvents.stopped.rawValue, data: nil, extras: ["ephemeral": true] @@ -220,10 +210,5 @@ internal final class DefaultTyping: Typing { throw error.toARTErrorInfo() } } - - // (CHA-T7) Users may subscribe to discontinuity events to know when there's been a break in typing indicators. Their listener will be called when a discontinuity event is triggered from the room lifecycle. For typing, there shouldn't need to be user action as the underlying core SDK will heal the presence set. - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - featureChannel.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } } } diff --git a/Sources/AblyChat/DiscontinuityEvent.swift b/Sources/AblyChat/DiscontinuityEvent.swift new file mode 100644 index 00000000..5b518607 --- /dev/null +++ b/Sources/AblyChat/DiscontinuityEvent.swift @@ -0,0 +1,10 @@ +import Ably + +public struct DiscontinuityEvent: Sendable, Equatable { + /// The error associated with this discontinuity. + public var error: ARTErrorInfo + + public init(error: ARTErrorInfo) { + self.error = error + } +} diff --git a/Sources/AblyChat/EmitsDiscontinuities.swift b/Sources/AblyChat/EmitsDiscontinuities.swift deleted file mode 100644 index e0b33a19..00000000 --- a/Sources/AblyChat/EmitsDiscontinuities.swift +++ /dev/null @@ -1,37 +0,0 @@ -import Ably - -public struct DiscontinuityEvent: Sendable, Equatable { - /// The error, if any, associated with this discontinuity. - public var error: ARTErrorInfo? - - public init(error: ARTErrorInfo? = nil) { - self.error = error - } -} - -/** - * An interface to be implemented by objects that can emit discontinuities to listeners. - */ -@MainActor -public protocol EmitsDiscontinuities { - /** - * Subscribes a given listener to a detected discontinuity. - * - * - Parameters: - * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. - * - * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``DiscontinuityEvent`` events. - */ - func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription - - /// Same as calling ``onDiscontinuity(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. - /// - /// The `EmitsDiscontinuities` protocol provides a default implementation of this method. - func onDiscontinuity() -> Subscription -} - -public extension EmitsDiscontinuities { - func onDiscontinuity() -> Subscription { - onDiscontinuity(bufferingPolicy: .unbounded) - } -} diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index 5880e288..fb36b1dc 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -14,56 +14,6 @@ public enum ErrorCode: Int { /// The user attempted to perform an invalid action. case badRequest = 40000 - /** - * The messages feature failed to attach. - */ - case messagesAttachmentFailed = 102_001 - - /** - * The presence feature failed to attach. - */ - case presenceAttachmentFailed = 102_002 - - /** - * The reactions feature failed to attach. - */ - case reactionsAttachmentFailed = 102_003 - - /** - * The occupancy feature failed to attach. - */ - case occupancyAttachmentFailed = 102_004 - - /** - * The typing feature failed to attach. - */ - case typingAttachmentFailed = 102_005 - - /** - * The messages feature failed to detach. - */ - case messagesDetachmentFailed = 102_050 - - /** - * The presence feature failed to detach. - */ - case presenceDetachmentFailed = 102_051 - - /** - * The reactions feature failed to detach. - */ - case reactionsDetachmentFailed = 102_052 - - /** - * The occupancy feature failed to detach. - */ - case occupancyDetachmentFailed = 102_053 - - /** - * The typing feature failed to detach. - */ - case typingDetachmentFailed = 102_054 - /** * Cannot perform operation because the room is in a failed state. */ @@ -86,48 +36,24 @@ public enum ErrorCode: Int { case roomInInvalidState = 102_107 + /** + * The room has experienced a discontinuity. + */ + case roomDiscontinuity = 102_100 + /// Has a case for each of the ``ErrorCode`` cases that imply a fixed status code. internal enum CaseThatImpliesFixedStatusCode { case badRequest - case messagesAttachmentFailed - case presenceAttachmentFailed - case reactionsAttachmentFailed - case occupancyAttachmentFailed - case typingAttachmentFailed - case messagesDetachmentFailed - case presenceDetachmentFailed - case reactionsDetachmentFailed - case occupancyDetachmentFailed - case typingDetachmentFailed case roomInFailedState case roomIsReleasing case roomIsReleased case roomReleasedBeforeOperationCompleted + case roomDiscontinuity internal var toNumericErrorCode: ErrorCode { switch self { case .badRequest: .badRequest - case .messagesAttachmentFailed: - .messagesAttachmentFailed - case .presenceAttachmentFailed: - .presenceAttachmentFailed - case .reactionsAttachmentFailed: - .reactionsAttachmentFailed - case .occupancyAttachmentFailed: - .occupancyAttachmentFailed - case .typingAttachmentFailed: - .typingAttachmentFailed - case .messagesDetachmentFailed: - .messagesDetachmentFailed - case .presenceDetachmentFailed: - .presenceDetachmentFailed - case .reactionsDetachmentFailed: - .reactionsDetachmentFailed - case .occupancyDetachmentFailed: - .occupancyDetachmentFailed - case .typingDetachmentFailed: - .typingDetachmentFailed case .roomInFailedState: .roomInFailedState case .roomIsReleasing: @@ -136,6 +62,8 @@ public enum ErrorCode: Int { .roomIsReleased case .roomReleasedBeforeOperationCompleted: .roomReleasedBeforeOperationCompleted + case .roomDiscontinuity: + .roomDiscontinuity } } @@ -149,17 +77,7 @@ public enum ErrorCode: Int { .roomIsReleased, .roomReleasedBeforeOperationCompleted: 400 - case - .messagesAttachmentFailed, - .presenceAttachmentFailed, - .reactionsAttachmentFailed, - .occupancyAttachmentFailed, - .typingAttachmentFailed, - .messagesDetachmentFailed, - .presenceDetachmentFailed, - .reactionsDetachmentFailed, - .occupancyDetachmentFailed, - .typingDetachmentFailed: + case .roomDiscontinuity: 500 } } @@ -214,14 +132,13 @@ internal enum ErrorCodeAndStatusCode { internal enum ChatError { case nonErrorInfoInternalError(InternalError.Other) case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) - case attachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) - case detachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) case roomInFailedState case roomIsReleasing case roomIsReleased case roomReleasedBeforeOperationCompleted case presenceOperationRequiresRoomAttach(feature: RoomFeature) case roomTransitionedToInvalidStateForPresenceOperation(cause: ARTErrorInfo?) + case roomDiscontinuity(cause: ARTErrorInfo?) internal var codeAndStatusCode: ErrorCodeAndStatusCode { switch self { @@ -230,32 +147,6 @@ internal enum ChatError { .fixedStatusCode(.badRequest) case .inconsistentRoomOptions: .fixedStatusCode(.badRequest) - case let .attachmentFailed(feature, _): - switch feature { - case .messages: - .fixedStatusCode(.messagesAttachmentFailed) - case .occupancy: - .fixedStatusCode(.occupancyAttachmentFailed) - case .presence: - .fixedStatusCode(.presenceAttachmentFailed) - case .reactions: - .fixedStatusCode(.reactionsAttachmentFailed) - case .typing: - .fixedStatusCode(.typingAttachmentFailed) - } - case let .detachmentFailed(feature, _): - switch feature { - case .messages: - .fixedStatusCode(.messagesDetachmentFailed) - case .occupancy: - .fixedStatusCode(.occupancyDetachmentFailed) - case .presence: - .fixedStatusCode(.presenceDetachmentFailed) - case .reactions: - .fixedStatusCode(.reactionsDetachmentFailed) - case .typing: - .fixedStatusCode(.typingDetachmentFailed) - } case .roomInFailedState: .fixedStatusCode(.roomInFailedState) case .roomIsReleasing: @@ -270,6 +161,8 @@ internal enum ChatError { case .presenceOperationRequiresRoomAttach: // CHA-PR3h, CHA-PR10h, CHA-PR6h .variableStatusCode(.roomInInvalidState, statusCode: 400) + case .roomDiscontinuity: + .fixedStatusCode(.roomDiscontinuity) } } @@ -294,20 +187,6 @@ internal enum ChatError { case detach } - private static func localizedDescription( - forFailureOfOperation operation: AttachOrDetach, - feature: RoomFeature - ) -> String { - let operationDescription = switch operation { - case .attach: - "attach" - case .detach: - "detach" - } - - return "The \(descriptionOfFeature(feature)) feature failed to \(operationDescription)." - } - /// The ``ARTErrorInfo/localizedDescription`` that should be returned for this error. internal var localizedDescription: String { switch self { @@ -316,10 +195,6 @@ internal enum ChatError { "\(otherInternalError)" case let .inconsistentRoomOptions(requested, existing): "Rooms.get(roomID:options:) was called with a different set of room options than was used on a previous call. You must first release the existing room instance using Rooms.release(roomID:). Requested options: \(requested), existing options: \(existing)" - case let .attachmentFailed(feature, _): - Self.localizedDescription(forFailureOfOperation: .attach, feature: feature) - case let .detachmentFailed(feature, _): - Self.localizedDescription(forFailureOfOperation: .detach, feature: feature) case .roomInFailedState: "Cannot perform operation because the room is in a failed state." case .roomIsReleasing: @@ -332,18 +207,18 @@ internal enum ChatError { "To perform this \(Self.descriptionOfFeature(feature)) operation, you must first attach the room." case .roomTransitionedToInvalidStateForPresenceOperation: "The room operation failed because the room was in an invalid state." + case .roomDiscontinuity: + "The room has experienced a discontinuity." } } /// The ``ARTErrorInfo/cause`` that should be returned for this error. internal var cause: ARTErrorInfo? { switch self { - case let .attachmentFailed(_, underlyingError): - underlyingError - case let .detachmentFailed(_, underlyingError): - underlyingError case let .roomTransitionedToInvalidStateForPresenceOperation(cause): cause + case let .roomDiscontinuity(cause): + cause case .nonErrorInfoInternalError, .inconsistentRoomOptions, .roomInFailedState, diff --git a/Sources/AblyChat/Messages.swift b/Sources/AblyChat/Messages.swift index 84965bb6..722fc230 100644 --- a/Sources/AblyChat/Messages.swift +++ b/Sources/AblyChat/Messages.swift @@ -7,7 +7,7 @@ import Ably * Get an instance via ``Room/messages``. */ @MainActor -public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { +public protocol Messages: AnyObject, Sendable { /** * Subscribe to new messages in this chat room. * @@ -77,13 +77,6 @@ public protocol Messages: AnyObject, Sendable, EmitsDiscontinuities { * - Note: It is possible to receive your own message via the messages subscription before this method returns. */ func delete(message: Message, params: DeleteMessageParams) async throws(ARTErrorInfo) -> Message - - /** - * Get the underlying Ably realtime channel used for the messages in this chat room. - * - * - Returns: The realtime channel. - */ - nonisolated var channel: any RealtimeChannelProtocol { get } } public extension Messages { diff --git a/Sources/AblyChat/Occupancy.swift b/Sources/AblyChat/Occupancy.swift index a20c1ed4..25059b40 100644 --- a/Sources/AblyChat/Occupancy.swift +++ b/Sources/AblyChat/Occupancy.swift @@ -7,10 +7,12 @@ import Ably * Get an instance via ``Room/occupancy``. */ @MainActor -public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { +public protocol Occupancy: AnyObject, Sendable { /** * Subscribes a given listener to occupancy updates of the chat room. * + * Note that it is a programmer error to call this method if occupancy events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's occupancy options to use this feature. + * * - Parameters: * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. * @@ -29,13 +31,6 @@ public protocol Occupancy: AnyObject, Sendable, EmitsDiscontinuities { * - Returns: A current occupancy of the chat room. */ func get() async throws(ARTErrorInfo) -> OccupancyEvent - - /** - * Get underlying Ably channel for occupancy events. - * - * - Returns: The underlying Ably channel for occupancy events. - */ - nonisolated var channel: any RealtimeChannelProtocol { get } } public extension Occupancy { diff --git a/Sources/AblyChat/Presence.swift b/Sources/AblyChat/Presence.swift index 4951db28..d756edf0 100644 --- a/Sources/AblyChat/Presence.swift +++ b/Sources/AblyChat/Presence.swift @@ -9,7 +9,7 @@ public typealias PresenceData = JSONValue * Get an instance via ``Room/presence``. */ @MainActor -public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { +public protocol Presence: AnyObject, Sendable { /** * Same as ``get(params:)``, but with defaults params. */ @@ -72,6 +72,8 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { /** * Subscribes a given listener to a particular presence event in the chat room. * + * Note that it is a programmer error to call this method if presence events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's presence options to use this feature (this is the default value). + * * - Parameters: * - event: A single presence event type ``PresenceEventType`` to subscribe to. * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. @@ -83,6 +85,8 @@ public protocol Presence: AnyObject, Sendable, EmitsDiscontinuities { /** * Subscribes a given listener to different presence events in the chat room. * + * Note that it is a programmer error to call this method if presence events are not enabled in the room options. Make sure to set `enableEvents: true` in your room's presence options to use this feature (this is the default value). + * * - Parameters: * - events: An array of presence event types ``PresenceEventType`` to subscribe to. * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. diff --git a/Sources/AblyChat/Room.swift b/Sources/AblyChat/Room.swift index 41ca4ca8..df7a19b7 100644 --- a/Sources/AblyChat/Room.swift +++ b/Sources/AblyChat/Room.swift @@ -104,12 +104,38 @@ public protocol Room: AnyObject, Sendable { * - Returns: A copy of the options used to create the room. */ nonisolated var options: RoomOptions { get } + + /** + * Subscribes a given listener to a detected discontinuity. + * + * - Parameters: + * - bufferingPolicy: The ``BufferingPolicy`` for the created subscription. + * + * - Returns: A subscription `AsyncSequence` that can be used to iterate through ``DiscontinuityEvent`` events. + */ + func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription + + /// Same as calling ``onDiscontinuity(bufferingPolicy:)`` with ``BufferingPolicy/unbounded``. + /// + /// The `Room` protocol provides a default implementation of this method. + func onDiscontinuity() -> Subscription + + /** + * Get the underlying Ably realtime channel used for the room. + * + * - Returns: The realtime channel. + */ + nonisolated var channel: any RealtimeChannelProtocol { get } } public extension Room { func onStatusChange() -> Subscription { onStatusChange(bufferingPolicy: .unbounded) } + + func onDiscontinuity() -> Subscription { + onDiscontinuity(bufferingPolicy: .unbounded) + } } /// A ``Room`` that exposes additional functionality for use within the SDK. @@ -147,7 +173,7 @@ internal protocol RoomFactory: Sendable { internal final class DefaultRoomFactory: Sendable, RoomFactory { private let lifecycleManagerFactory = DefaultRoomLifecycleManagerFactory() - internal func createRoom(realtime: any InternalRealtimeClientProtocol, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) throws(InternalError) -> DefaultRoom { + internal func createRoom(realtime: any InternalRealtimeClientProtocol, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger) throws(InternalError) -> DefaultRoom { try DefaultRoom( realtime: realtime, chatAPI: chatAPI, @@ -159,71 +185,37 @@ internal final class DefaultRoomFactory: Sendable, RoomFactory { } } -internal class DefaultRoom: InternalRoom where LifecycleManagerFactory.Contributor == DefaultRoomLifecycleContributor { +internal class DefaultRoom: InternalRoom { internal nonisolated let roomID: String internal nonisolated let options: RoomOptions private let chatAPI: ChatAPI public nonisolated let messages: any Messages - private let _reactions: (any RoomReactions)? - private let _presence: (any Presence)? - private let _occupancy: (any Occupancy)? - private let _typing: (any Typing)? + public nonisolated let reactions: any RoomReactions + public nonisolated let presence: any Presence + public nonisolated let occupancy: any Occupancy + public nonisolated let typing: any Typing // Exposed for testing. private nonisolated let realtime: any InternalRealtimeClientProtocol private let lifecycleManager: any RoomLifecycleManager - private let channels: [any InternalRealtimeChannelProtocol] + private let internalChannel: any InternalRealtimeChannelProtocol - private let logger: InternalLogger + // Note: This property only exists to satisfy the `Room` interface. Do not use this property inside this class; use `internalChannel`. + internal nonisolated var channel: any RealtimeChannelProtocol { + internalChannel.underlying + } - private enum RoomFeatureWithOptions { - case messages - case presence(PresenceOptions) - case typing(TypingOptions) - case reactions(RoomReactionsOptions) - case occupancy(OccupancyOptions) - - var toRoomFeature: RoomFeature { - switch self { - case .messages: - .messages - case .presence: - .presence - case .typing: - .typing - case .reactions: - .reactions - case .occupancy: - .occupancy - } + #if DEBUG + internal nonisolated var testsOnly_internalChannel: any InternalRealtimeChannelProtocol { + internalChannel } + #endif - static func fromRoomOptions(_ roomOptions: RoomOptions) -> [Self] { - var result: [Self] = [.messages] - - if let presenceOptions = roomOptions.presence { - result.append(.presence(presenceOptions)) - } - - if let typingOptions = roomOptions.typing { - result.append(.typing(typingOptions)) - } - - if let reactionsOptions = roomOptions.reactions { - result.append(.reactions(reactionsOptions)) - } - - if let occupancyOptions = roomOptions.occupancy { - result.append(.occupancy(occupancyOptions)) - } - - return result - } - } + private let logger: InternalLogger - internal init(realtime: any InternalRealtimeClientProtocol, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: LifecycleManagerFactory) throws(InternalError) { + internal init(realtime: any InternalRealtimeClientProtocol, chatAPI: ChatAPI, roomID: String, options: RoomOptions, logger: InternalLogger, lifecycleManagerFactory: any RoomLifecycleManagerFactory) throws(InternalError) { self.realtime = realtime self.roomID = roomID self.options = options @@ -234,170 +226,75 @@ internal class DefaultRoom throw ARTErrorInfo.create(withCode: 40000, message: "Ensure your Realtime instance is initialized with a clientId.").toInternalError() } - let featuresWithOptions = RoomFeatureWithOptions.fromRoomOptions(options) - - let featureChannelPartialDependencies = Self.createFeatureChannelPartialDependencies(roomID: roomID, featuresWithOptions: featuresWithOptions, realtime: realtime) - channels = featureChannelPartialDependencies.map(\.featureChannelPartialDependencies.channel) - let contributors = featureChannelPartialDependencies.map(\.featureChannelPartialDependencies.contributor) + internalChannel = Self.createChannel(roomID: roomID, roomOptions: options, realtime: realtime) lifecycleManager = lifecycleManagerFactory.createManager( - contributors: contributors, + channel: internalChannel, logger: logger ) - let featureChannels = Self.createFeatureChannels(partialDependencies: featureChannelPartialDependencies, lifecycleManager: lifecycleManager) - messages = DefaultMessages( - featureChannel: featureChannels[.messages]!, + channel: internalChannel, chatAPI: chatAPI, roomID: roomID, clientID: clientId, logger: logger ) - _reactions = if let featureChannel = featureChannels[.reactions] { - DefaultRoomReactions( - featureChannel: featureChannel, - clientID: clientId, - roomID: roomID, - logger: logger - ) - } else { - nil - } - - _presence = if let featureChannel = featureChannels[.presence] { - DefaultPresence( - featureChannel: featureChannel, - roomID: roomID, - clientID: clientId, - logger: logger - ) - } else { - nil - } - - _occupancy = if let featureChannel = featureChannels[.occupancy] { - DefaultOccupancy( - featureChannel: featureChannel, - chatAPI: chatAPI, - roomID: roomID, - logger: logger - ) - } else { - nil - } - - _typing = if let featureChannel = featureChannels[.typing] { - DefaultTyping( - featureChannel: featureChannel, - roomID: roomID, - clientID: clientId, - logger: logger, - heartbeatThrottle: options.typing?.heartbeatThrottle ?? 10, - clock: SystemClock() - ) - } else { - nil - } - } - - private struct FeatureChannelPartialDependencies { - internal var channel: any InternalRealtimeChannelProtocol - internal var contributor: DefaultRoomLifecycleContributor - } - - /// Each feature in `featuresWithOptions` is guaranteed to appear in the `features` member of precisely one of the returned array’s values. - private static func createFeatureChannelPartialDependencies(roomID: String, featuresWithOptions: [RoomFeatureWithOptions], realtime: any InternalRealtimeClientProtocol) -> [(features: [RoomFeature], featureChannelPartialDependencies: FeatureChannelPartialDependencies)] { - // CHA-RC3a - - // Multiple features can share a realtime channel. We fetch each realtime channel exactly once, merging the channel options for the various features that use this channel. - - // CHA-RL5a1: This spec point requires us to implement a special behaviour to handle the fact that multiple contributors can share a channel. I have decided, instead, to make it so that each channel has precisely one lifecycle contributor. I think this is a simpler, functionally equivalent approach and have suggested it in https://github.com/ably/specification/issues/240. - - let featuresGroupedByChannelName = Dictionary(grouping: featuresWithOptions) { $0.toRoomFeature.channelNameForRoomID(roomID) } - - let unorderedResult = featuresGroupedByChannelName.map { channelName, features in - let channelOptions = ARTRealtimeChannelOptions() - - // CHA-GP2a - channelOptions.attachOnSubscribe = false - - // channel setup for presence and occupancy - for feature in features { - if case /* let */ .presence /* (presenceOptions) */ = feature { - // TODO: Restore this code once we understand weird Realtime behaviour and spec points (https://github.com/ably-labs/ably-chat-swift/issues/133) - /* - if presenceOptions.enter { - channelOptions.modes.insert(.presence) - } - - if presenceOptions.subscribe { - channelOptions.modes.insert(.presenceSubscribe) - } - */ - } else if case .occupancy = feature { - var params: [String: String] = channelOptions.params ?? [:] - params["occupancy"] = "metrics" - channelOptions.params = params - } - } - - let channel = realtime.channels.get(channelName, options: channelOptions) - - // Give the contributor the first of the enabled features that correspond to this channel, using CHA-RC2e ordering. This will determine which feature is used for atttachment and detachment errors. - let contributorFeature = features.map(\.toRoomFeature).sorted { RoomFeature.areInPrecedenceListOrder($0, $1) }[0] + reactions = DefaultRoomReactions( + channel: internalChannel, + clientID: clientId, + roomID: roomID, + logger: logger + ) - let contributor = DefaultRoomLifecycleContributor(channel: channel, feature: contributorFeature) - let featureChannelPartialDependencies = FeatureChannelPartialDependencies(channel: channel, contributor: contributor) + presence = DefaultPresence( + channel: internalChannel, + roomLifecycleManager: lifecycleManager, + roomID: roomID, + clientID: clientId, + logger: logger, + options: options.presence + ) - return (features.map(\.toRoomFeature), featureChannelPartialDependencies) - } + occupancy = DefaultOccupancy( + channel: internalChannel, + chatAPI: chatAPI, + roomID: roomID, + logger: logger, + options: options.occupancy + ) - // Sort the result in CHA-RC2e order - return unorderedResult.sorted { RoomFeature.areInPrecedenceListOrder($0.1.contributor.feature, $1.1.contributor.feature) } + typing = DefaultTyping( + channel: internalChannel, + roomID: roomID, + clientID: clientId, + logger: logger, + heartbeatThrottle: options.typing.heartbeatThrottle, + clock: SystemClock() + ) } - private static func createFeatureChannels(partialDependencies: [(features: [RoomFeature], featureChannelPartialDependencies: FeatureChannelPartialDependencies)], lifecycleManager: RoomLifecycleManager) -> [RoomFeature: DefaultFeatureChannel] { - let pairsOfFeatureAndPartialDependencies = partialDependencies.flatMap { features, partialDependencies in - features.map { (feature: $0, partialDependencies: partialDependencies) } - } + private static func createChannel(roomID: String, roomOptions: RoomOptions, realtime: any InternalRealtimeClientProtocol) -> any InternalRealtimeChannelProtocol { + let channelOptions = ARTRealtimeChannelOptions() - return Dictionary(uniqueKeysWithValues: pairsOfFeatureAndPartialDependencies).mapValues { partialDependencies in - .init( - channel: partialDependencies.channel, - contributor: partialDependencies.contributor, - roomLifecycleManager: lifecycleManager - ) - } - } + // CHA-GP2a + channelOptions.attachOnSubscribe = false - public nonisolated var presence: any Presence { - guard let _presence else { - fatalError("Presence is not enabled for this room") + // CHA-RC3a (Multiple features share a realtime channel. We fetch the channel exactly once, merging the channel options for the various features.) + if !roomOptions.presence.enableEvents { + // CHA-PR9c2 + channelOptions.modes = [.publish, .subscribe, .presence] } - return _presence - } - - public nonisolated var reactions: any RoomReactions { - guard let _reactions else { - fatalError("Reactions are not enabled for this room") + if roomOptions.occupancy.enableEvents { + // CHA-O6a, CHA-O6b + var params: [String: String] = channelOptions.params ?? [:] + params["occupancy"] = "metrics" + channelOptions.params = params } - return _reactions - } - public nonisolated var typing: any Typing { - guard let _typing else { - fatalError("Typing is not enabled for this room") - } - return _typing - } - - public nonisolated var occupancy: any Occupancy { - guard let _occupancy else { - fatalError("Occupancy is not enabled for this room") - } - return _occupancy + // CHA-RC3c + return realtime.channels.get("\(roomID)::$chat", options: channelOptions) } public func attach() async throws(ARTErrorInfo) { @@ -420,9 +317,7 @@ internal class DefaultRoom await lifecycleManager.performReleaseOperation() // CHA-RL3h - for channel in channels { - realtime.channels.release(channel.name) - } + realtime.channels.release(internalChannel.name) } // MARK: - Room status @@ -434,4 +329,10 @@ internal class DefaultRoom internal var status: RoomStatus { lifecycleManager.roomStatus } + + // MARK: - Discontinuities + + internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { + lifecycleManager.onDiscontinuity(bufferingPolicy: bufferingPolicy) + } } diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift index cefed320..bd5b6474 100644 --- a/Sources/AblyChat/RoomFeature.swift +++ b/Sources/AblyChat/RoomFeature.swift @@ -1,76 +1,10 @@ import Ably /// The features offered by a chat room. -internal enum RoomFeature: CaseIterable { - // This list MUST be kept in the same order as the list in CHA-RC2e, in order for the implementation of `areInPrecedenceListOrder` to work. +internal enum RoomFeature { case messages case presence case typing case reactions case occupancy - - internal func channelNameForRoomID(_ roomID: String) -> String { - "\(roomID)::$chat\(channelNameSuffix)" - } - - private var channelNameSuffix: String { - switch self { - case .messages, .presence, .occupancy: - // (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. - // (CHA-PR1) Presence for a Room is exposed on the realtime channel used for chat messages, in the format ::$chat::$chatMessages. For example, if your room id is my-room then the presence channel will be my-room::$chat::$chatMessages. - // (CHA-O1) Occupancy for a room is exposed on the realtime channel used for chat messages, in the format ::$chat::$chatMessages. For example, if your room id is my-room then the presence channel will be my-room::$chat::$chatMessages. - "::$chatMessages" - case .reactions: - // (CHA-ER1) Reactions for a Room are sent on a corresponding realtime channel ::$chat::$reactions. For example, if your room id is my-room then the reactions channel will be my-room::$chat::$reactions. - "::$reactions" - case .typing: - // (CHA-T8) Typing Indicators for a Room are exposed on the main room channel, in the format ::$chat. For example, if your room id is my-room then the channel will be my-room::$chat. - "" - } - } - - /// Returns a `Bool` indicating whether `first` and `second` are in the same order as the list given in CHA-RC2e. - internal static func areInPrecedenceListOrder(_ first: Self, _ second: Self) -> Bool { - let allCases = Self.allCases - let indexOfFirst = allCases.firstIndex(of: first)! - let indexOfSecond = allCases.firstIndex(of: second)! - return indexOfFirst < indexOfSecond - } -} - -/// Provides all of the channel-related functionality that a room feature (e.g. an implementation of ``Messages``) needs. -/// -/// This mishmash exists to give a room feature access to: -/// -/// - a `RealtimeChannelProtocol` object -/// - the discontinuities emitted by the room lifecycle -/// - the presence-readiness wait mechanism supplied by the room lifecycle -internal protocol FeatureChannel: Sendable, EmitsDiscontinuities { - nonisolated var channel: any InternalRealtimeChannelProtocol { get } - - /// Waits until we can perform presence operations on the contributors of this room without triggering an implicit attach. - /// - /// Implements the checks described by CHA-PR3d, CHA-PR3e, and CHA-PR3h (and similar ones described by other functionality that performs contributor presence operations). Namely: - /// - /// - CHA-RL9, which is invoked by CHA-PR3d, CHA-PR10d, CHA-PR6c: If the room is in the ATTACHING status, it waits for the next room status change. If the new status is ATTACHED, it returns. Else, it throws an `ARTErrorInfo` derived from ``ChatError/roomTransitionedToInvalidStateForPresenceOperation(cause:)``. - /// - CHA-PR3e, CHA-PR10e, CHA-PR6d: If the room is in the ATTACHED status, it returns immediately. - /// - CHA-PR3h, CHA-PR10h, CHA-PR6h: If the room is in any other status, it throws an `ARTErrorInfo` derived from ``ChatError/presenceOperationRequiresRoomAttach(feature:)``. - /// - /// - Parameters: - /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. - func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) -} - -internal struct DefaultFeatureChannel: FeatureChannel { - internal var channel: any InternalRealtimeChannelProtocol - internal var contributor: DefaultRoomLifecycleContributor - internal var roomLifecycleManager: RoomLifecycleManager - - internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - contributor.onDiscontinuity(bufferingPolicy: bufferingPolicy) - } - - internal func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) { - try await roomLifecycleManager.waitToBeAbleToPerformPresenceOperations(requestedByFeature: requester) - } } diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift index 29343d2d..e9a92dd5 100644 --- a/Sources/AblyChat/RoomLifecycleManager.swift +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -1,18 +1,4 @@ import Ably -import AsyncAlgorithms - -/// A realtime channel that contributes to the room lifecycle. -/// -/// The identity implied by the `Identifiable` conformance must distinguish each of the contributors passed to a given ``RoomLifecycleManager`` instance. -@MainActor -internal protocol RoomLifecycleContributor: Identifiable, Sendable { - /// The room feature that this contributor corresponds to. Used only for choosing which error to throw when a contributor operation fails. - var feature: RoomFeature { get } - var channel: any InternalRealtimeChannelProtocol { get } - - /// Informs the contributor that there has been a break in channel continuity, which it should inform library users about. - func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) -} @MainActor internal protocol RoomLifecycleManager: Sendable { @@ -21,16 +7,28 @@ internal protocol RoomLifecycleManager: Sendable { func performReleaseOperation() async var roomStatus: RoomStatus { get } func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription + + /// Waits until we can perform presence operations on this room's channel without triggering an implicit attach. + /// + /// Implements the checks described by CHA-PR3d, CHA-PR3e, and CHA-PR3h (and similar ones described by other functionality that performs channel presence operations). Namely: + /// + /// - CHA-RL9, which is invoked by CHA-PR3d, CHA-PR10d, CHA-PR6c, CHA-T2c: If the room is in the ATTACHING status, it waits for the next room status change. If the new status is ATTACHED, it returns. Else, it throws an `ARTErrorInfo` derived from ``ChatError/roomTransitionedToInvalidStateForPresenceOperation(cause:)``. + /// - CHA-PR3e, CHA-PR10e, CHA-PR6d, CHA-T2d: If the room is in the ATTACHED status, it returns immediately. + /// - CHA-PR3h, CHA-PR10h, CHA-PR6h, CHA-T2g: If the room is in any other status, it throws an `ARTErrorInfo` derived from ``ChatError/presenceOperationRequiresRoomAttach(feature:)``. + /// + /// - Parameters: + /// - requester: The room feature that wishes to perform a presence operation. This is only used for customising the message of the thrown error. func waitToBeAbleToPerformPresenceOperations(requestedByFeature requester: RoomFeature) async throws(InternalError) + + func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription } @MainActor internal protocol RoomLifecycleManagerFactory: Sendable { - associatedtype Contributor: RoomLifecycleContributor associatedtype Manager: RoomLifecycleManager func createManager( - contributors: [Contributor], + channel: any InternalRealtimeChannelProtocol, logger: InternalLogger ) -> Manager } @@ -39,45 +37,87 @@ internal final class DefaultRoomLifecycleManagerFactory: RoomLifecycleManagerFac private let clock = DefaultSimpleClock() internal func createManager( - contributors: [DefaultRoomLifecycleContributor], + channel: any InternalRealtimeChannelProtocol, logger: InternalLogger - ) -> DefaultRoomLifecycleManager { + ) -> DefaultRoomLifecycleManager { .init( - contributors: contributors, + channel: channel, logger: logger, clock: clock ) } } -internal class DefaultRoomLifecycleManager: RoomLifecycleManager { +private extension RoomStatus { + init(channelState: ARTRealtimeChannelState, error: ARTErrorInfo?) { + switch channelState { + case .initialized: + self = .initialized + case .attaching: + self = .attaching(error: error) + case .attached: + self = .attached(error: error) + case .detaching: + self = .detaching(error: error) + case .detached: + self = .detached(error: error) + case .suspended: + guard let error else { + fatalError("Expected an error with SUSPENDED channel state") + } + self = .suspended(error: error) + case .failed: + guard let error else { + fatalError("Expected an error with FAILED channel state") + } + self = .failed(error: error) + @unknown default: + fatalError("Unknown channel state \(channelState)") + } + } +} + +internal class DefaultRoomLifecycleManager: RoomLifecycleManager { // MARK: - Constant properties private let logger: InternalLogger private let clock: SimpleClock - private let contributors: [Contributor] + private let channel: any InternalRealtimeChannelProtocol // MARK: - Variable properties - private var status: Status - /// Manager state that relates to individual contributors, keyed by contributors’ ``Contributor/id``. Stored separately from ``contributors`` so that the latter can be a `let`, to make it clear that the contributors remain fixed for the lifetime of the manager. - private var contributorAnnotations: ContributorAnnotations - private var listenForStateChangesTask: Task! - private var roomStatusChangeSubscriptions = SubscriptionStorage() + internal private(set) var roomStatus: RoomStatus + private var currentOperationID: UUID? + + // CHA-RL13 + private var hasAttachedOnce: Bool + internal var testsOnly_hasAttachedOnce: Bool { + hasAttachedOnce + } + + // CHA-RL14 + private var isExplicitlyDetached: Bool + internal var testsOnly_isExplicitlyDetached: Bool { + isExplicitlyDetached + } + + private var channelStateEventListener: ARTEventListener! + private let roomStatusChangeSubscriptions = SubscriptionStorage() + private let discontinuitySubscriptions = SubscriptionStorage() private var operationResultContinuations = OperationResultContinuations() // MARK: - Initializers and `deinit` internal convenience init( - contributors: [Contributor], + channel: any InternalRealtimeChannelProtocol, logger: InternalLogger, clock: SimpleClock ) { self.init( - status: nil, - pendingDiscontinuityEvents: nil, - idsOfContributorsWithTransientDisconnectTimeout: nil, - contributors: contributors, + roomStatus: nil, + hasAttachedOnce: nil, + isExplicitlyDetached: nil, + channel: channel, logger: logger, clock: clock ) @@ -85,18 +125,18 @@ internal class DefaultRoomLifecycleManager? = nil, - contributors: [Contributor], + testsOnly_roomStatus roomStatus: RoomStatus? = nil, + testsOnly_hasAttachedOnce hasAttachedOnce: Bool? = nil, + testsOnly_isExplicitlyDetached isExplicitlyDetached: Bool? = nil, + channel: any InternalRealtimeChannelProtocol, logger: InternalLogger, clock: SimpleClock ) { self.init( - status: status, - pendingDiscontinuityEvents: pendingDiscontinuityEvents, - idsOfContributorsWithTransientDisconnectTimeout: idsOfContributorsWithTransientDisconnectTimeout, - contributors: contributors, + roomStatus: roomStatus, + hasAttachedOnce: hasAttachedOnce, + isExplicitlyDetached: isExplicitlyDetached, + channel: channel, logger: logger, clock: clock ) @@ -104,450 +144,93 @@ internal class DefaultRoomLifecycleManager?, - contributors: [Contributor], + roomStatus: RoomStatus?, + hasAttachedOnce: Bool?, + isExplicitlyDetached: Bool?, + channel: any InternalRealtimeChannelProtocol, logger: InternalLogger, clock: SimpleClock ) { - self.status = status ?? .initialized - self.contributors = contributors - contributorAnnotations = .init( - contributors: contributors, - pendingDiscontinuityEvents: pendingDiscontinuityEvents ?? [:], - idsOfContributorsWithTransientDisconnectTimeout: idsOfContributorsWithTransientDisconnectTimeout ?? [] - ) + self.roomStatus = roomStatus ?? .initialized + self.hasAttachedOnce = hasAttachedOnce ?? false + self.isExplicitlyDetached = isExplicitlyDetached ?? false + self.channel = channel self.logger = logger self.clock = clock - let subscriptions = contributors.map { (contributor: $0, subscription: $0.channel.subscribeToState()) } - - // CHA-RL4: listen for state changes from our contributors - // TODO: Understand what happens when this task gets cancelled by `deinit`; I’m not convinced that the for-await loops will exit (https://github.com/ably-labs/ably-chat-swift/issues/29) - listenForStateChangesTask = Task { - await withTaskGroup(of: Void.self) { group in - for (contributor, subscription) in subscriptions { - group.addTask { [weak self] in - // We intentionally wait to finish processing one state change before moving on to the next; this means that when we process an ATTACHED state change, we can be sure that the current `hasBeenAttached` annotation correctly reflects the contributor’s previous state changes. - for await stateChange in subscription { - await self?.didReceiveStateChange(stateChange, forContributor: contributor) - } - } - } - } + // CHA-RL11, CHA-RL12: listen for state events from our channel + channelStateEventListener = channel.on { [weak self] event in + self?.didReceiveChannelStateEvent(event) } } deinit { - listenForStateChangesTask.cancel() - } - - // MARK: - Type for room status - - internal enum Status: Equatable { - case initialized - case attachingDueToAttachOperation(attachOperationID: UUID) - case attachingDueToRetryOperation(retryOperationID: UUID) - case attachingDueToContributorStateChange(error: ARTErrorInfo?) - case attached - case detaching(detachOperationID: UUID) - case detached - case detachedDueToRetryOperation(retryOperationID: UUID) - // `retryOperationTask` is exposed so that tests can wait for the triggered RETRY operation to complete. - case suspendedAwaitingStartOfRetryOperation(retryOperationTask: Task, error: ARTErrorInfo) - case suspended(retryOperationID: UUID, error: ARTErrorInfo) - // `rundownOperationTask` is exposed so that tests can wait for the triggered RUNDOWN operation to complete. - case failedAwaitingStartOfRundownOperation(rundownOperationTask: Task, error: ARTErrorInfo) - case failedAndPerformingRundownOperation(rundownOperationID: UUID, error: ARTErrorInfo) - case failed(error: ARTErrorInfo) - case releasing(releaseOperationID: UUID) - case released - - internal var toRoomStatus: RoomStatus { - switch self { - case .initialized: - .initialized - case .attachingDueToAttachOperation: - .attaching(error: nil) - case .attachingDueToRetryOperation: - .attaching(error: nil) - case let .attachingDueToContributorStateChange(error: error): - .attaching(error: error) - case .attached: - .attached - case .detaching: - .detaching - case .detached, .detachedDueToRetryOperation: - .detached - case let .suspendedAwaitingStartOfRetryOperation(_, error): - .suspended(error: error) - case let .suspended(_, error): - .suspended(error: error) - case let .failedAwaitingStartOfRundownOperation(_, error): - .failed(error: error) - case let .failedAndPerformingRundownOperation(_, error): - .failed(error: error) - case let .failed(error): - .failed(error: error) - case .releasing: - .releasing - case .released: - .released - } - } - - fileprivate var operationID: UUID? { - switch self { - case let .attachingDueToAttachOperation(attachOperationID): - attachOperationID - case let .attachingDueToRetryOperation(retryOperationID): - retryOperationID - case let .detaching(detachOperationID): - detachOperationID - case let .detachedDueToRetryOperation(retryOperationID): - retryOperationID - case let .releasing(releaseOperationID): - releaseOperationID - case let .suspended(retryOperationID, _): - retryOperationID - case let .failedAndPerformingRundownOperation(rundownOperationID, _): - rundownOperationID - case .initialized, - .attached, - .detached, - .failedAwaitingStartOfRundownOperation, - .failed, - .released, - .attachingDueToContributorStateChange, - .suspendedAwaitingStartOfRetryOperation: - nil - } - } - } - - // MARK: - Types for contributor annotations - - /// Stores manager state relating to a given contributor. - private struct ContributorAnnotation { - class TransientDisconnectTimeout: Identifiable { - /// A unique identifier for this timeout. This allows test cases to assert that one timeout has not been replaced by another. - var id = UUID() - /// The task that sleeps until the timeout period passes and then performs the timeout’s side effects. This will be `nil` if you have created a transient disconnect timeout using the `testsOnly_idsOfContributorsWithTransientDisconnectTimeout` manager initializer parameter. - var task: Task? - } - - var pendingDiscontinuityEvent: DiscontinuityEvent? - var transientDisconnectTimeout: TransientDisconnectTimeout? - /// Whether a state change to `ATTACHED` has already been observed for this contributor. - var hasBeenAttached: Bool - - var hasTransientDisconnectTimeout: Bool { - transientDisconnectTimeout != nil - } - } - - /// Provides a `Dictionary`-like interface for storing manager state about individual contributors. - private struct ContributorAnnotations { - private var storage: [Contributor.ID: ContributorAnnotation] - - init( - contributors: [Contributor], - pendingDiscontinuityEvents: [Contributor.ID: DiscontinuityEvent], - idsOfContributorsWithTransientDisconnectTimeout: Set - ) { - storage = contributors.reduce(into: [:]) { result, contributor in - result[contributor.id] = .init( - pendingDiscontinuityEvent: pendingDiscontinuityEvents[contributor.id], - transientDisconnectTimeout: idsOfContributorsWithTransientDisconnectTimeout.contains(contributor.id) ? .init() : nil, - hasBeenAttached: false - ) - } - } - - /// It is a programmer error to call this subscript getter with a contributor that was not one of those passed to ``init(contributors:pendingDiscontinuityEvents)``. - subscript(_ contributor: Contributor) -> ContributorAnnotation { - get { - guard let annotation = storage[contributor.id] else { - preconditionFailure("Expected annotation for \(contributor)") - } - return annotation - } - - set { - storage[contributor.id] = newValue - } - } - - mutating func clearPendingDiscontinuityEvents() { - storage = storage.mapValues { annotation in - var newAnnotation = annotation - newAnnotation.pendingDiscontinuityEvent = nil - return newAnnotation - } + // This was a case of "do something that the compiler accepts"; there might be a better way. + // (https://github.com/swiftlang/swift-evolution/blob/main/proposals/0371-isolated-synchronous-deinit.md sounds relevant too.) + let (channelStateEventListener, channel) = (self.channelStateEventListener as ARTEventListener, self.channel) + Task { @MainActor in + channel.off(channelStateEventListener) } } // MARK: - Room status and its changes - internal var roomStatus: RoomStatus { - status.toRoomStatus - } - internal func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { roomStatusChangeSubscriptions.create(bufferingPolicy: bufferingPolicy) } - #if DEBUG - /// Supports the ``testsOnly_onRoomStatusChange()`` method. - private var statusChangeSubscriptions = SubscriptionStorage() - - internal struct StatusChange { - internal var current: Status - internal var previous: Status - } - - /// Allows tests to subscribe to changes to the manager’s internal status (which exposes more cases and additional metadata, compared to the ``RoomStatus`` exposed by ``onRoomStatusChange(bufferingPolicy:)``). - internal func testsOnly_onStatusChange() -> Subscription { - statusChangeSubscriptions.create(bufferingPolicy: .unbounded) - } - #endif + /// Updates ``roomStatus`` and emits a status change event. + private func changeStatus(to new: RoomStatus) { + logger.log(message: "Transitioning from \(roomStatus) to \(new)", level: .info) + let previous = roomStatus + roomStatus = new - /// Updates ``status`` and emits a status change event. - private func changeStatus(to new: Status) { - logger.log(message: "Transitioning from \(status) to \(new)", level: .info) - let previous = status - status = new - - // Avoid a double-emit of room status when changing between `Status` values that map to the same `RoomStatus`; e.g. when changing from `.suspendedAwaitingStartOfRetryOperation` to `.suspended`. - if new.toRoomStatus != previous.toRoomStatus { - let statusChange = RoomStatusChange(current: status.toRoomStatus, previous: previous.toRoomStatus) - roomStatusChangeSubscriptions.emit(statusChange) - } - - #if DEBUG - let statusChange = StatusChange(current: status, previous: previous) - statusChangeSubscriptions.emit(statusChange) - #endif + let statusChange = RoomStatusChange(current: roomStatus, previous: previous) + roomStatusChangeSubscriptions.emit(statusChange) } - // MARK: - Handling contributor state changes - - #if DEBUG - /// Supports the ``testsOnly_subscribeToHandledContributorStateChanges()`` method. - private var stateChangeHandledSubscriptions = SubscriptionStorage() - - /// Returns a subscription which emits the contributor state changes that have been handled by the manager. - /// - /// A contributor state change is considered handled once the manager has performed all of the side effects that it will perform as a result of receiving this state change. Specifically, once: - /// - /// - (if the state change is ATTACHED) the manager has recorded that an ATTACHED state change has been observed for the contributor - /// - the manager has recorded all pending discontinuity events provoked by the state change (you can retrieve these using ``testsOnly_pendingDiscontinuityEvent(for:)``) - /// - the manager has performed all status changes provoked by the state change (this does _not_ include the case in which the state change provokes the creation of a transient disconnect timeout which subsequently provokes a status change; use ``testsOnly_subscribeToHandledTransientDisconnectTimeouts()`` to find out about those) - /// - the manager has performed all contributor actions provoked by the state change, namely calls to ``InternalRealtimeChannelProtocol/detach()`` or ``InternalRealtimeChannelProtocol/emitDiscontinuity(_:)`` - /// - the manager has recorded all transient disconnect timeouts provoked by the state change (you can retrieve this information using ``testsOnly_hasTransientDisconnectTimeout(for:) or ``testsOnly_idOfTransientDisconnectTimeout(for:)``) - /// - the manager has performed all transient disconnect timeout cancellations provoked by the state change (you can retrieve this information using ``testsOnly_hasTransientDisconnectTimeout(for:) or ``testsOnly_idOfTransientDisconnectTimeout(for:)``) - internal func testsOnly_subscribeToHandledContributorStateChanges() -> Subscription { - stateChangeHandledSubscriptions.create(bufferingPolicy: .unbounded) - } - - internal func testsOnly_pendingDiscontinuityEvent(for contributor: Contributor) -> DiscontinuityEvent? { - contributorAnnotations[contributor].pendingDiscontinuityEvent - } - - internal func testsOnly_hasTransientDisconnectTimeout(for contributor: Contributor) -> Bool { - contributorAnnotations[contributor].hasTransientDisconnectTimeout - } - - internal var testsOnly_hasTransientDisconnectTimeoutForAnyContributor: Bool { - contributors.contains { testsOnly_hasTransientDisconnectTimeout(for: $0) } - } - - internal func testsOnly_idOfTransientDisconnectTimeout(for contributor: Contributor) -> UUID? { - contributorAnnotations[contributor].transientDisconnectTimeout?.id - } + // MARK: - Handling channel state changes - /// Supports the ``testsOnly_subscribeToHandledTransientDisconnectTimeouts()`` method. - private var transientDisconnectTimeoutHandledSubscriptions = SubscriptionStorage() + /// Implements CHA-RL11 and CHA-RL12's channel event handling. + private func didReceiveChannelStateEvent(_ event: ARTChannelStateChange) { + logger.log(message: "Got channel state event \(event)", level: .info) - /// Returns a subscription which emits the IDs of the transient disconnect timeouts that have been handled by the manager. - /// - /// A transient disconnect timeout is considered handled once the manager has performed all of the side effects that it will perform as a result of creating this timeout. Specifically, once: - /// - /// - the manager has performed all status changes provoked by the completion of this timeout (which may be none, if the timeout gets cancelled) - internal func testsOnly_subscribeToHandledTransientDisconnectTimeouts() -> Subscription { - transientDisconnectTimeoutHandledSubscriptions.create(bufferingPolicy: .unbounded) + // CHA-RL11b + if event.event != .update, !hasOperationInProgress { + // CHA-RL11c + changeStatus(to: .init(channelState: event.current, error: event.reason)) } - #endif - - /// Implements CHA-RL4b’s contributor state change handling. - private func didReceiveStateChange(_ stateChange: ARTChannelStateChange, forContributor contributor: Contributor) async { - logger.log(message: "Got state change \(stateChange) for contributor \(contributor)", level: .info) - - // TODO: The spec, which is written for a single-threaded environment, is presumably operating on the assumption that the channel is currently in the state given by `stateChange.current` (https://github.com/ably-labs/ably-chat-swift/issues/49) - switch stateChange.event { - case .update: - // CHA-RL4a1 — if RESUMED then no-op - guard !stateChange.resumed else { - break - } - - // CHA-RL4a2 — if contributor has not yet been attached then no-op - guard contributorAnnotations[contributor].hasBeenAttached else { - break - } - - let reason = stateChange.reason - - if hasOperationInProgress { - // CHA-RL4a3 - recordPendingDiscontinuityEvent(for: contributor, error: reason) - } else { - // CHA-RL4a4 - let discontinuity = DiscontinuityEvent(error: reason) - logger.log(message: "Emitting discontinuity event \(discontinuity) for contributor \(contributor)", level: .info) - contributor.emitDiscontinuity(discontinuity) - } - case .attached: - let hadAlreadyAttached = contributorAnnotations[contributor].hasBeenAttached - contributorAnnotations[contributor].hasBeenAttached = true - - if hasOperationInProgress { - if !stateChange.resumed, hadAlreadyAttached { - // CHA-RL4b1 - recordPendingDiscontinuityEvent(for: contributor, error: stateChange.reason) - } - } else { - // CHA-RL4b10 - clearTransientDisconnectTimeouts(for: contributor) - - if status != .attached { - if await (contributors.async.map { await $0.channel.state }.allSatisfy { @Sendable state in state == .attached }) { - // CHA-RL4b8 - logger.log(message: "Now that all contributors are ATTACHED, transitioning room to ATTACHED", level: .info) - changeStatus(to: .attached) - } - } - } - case .failed: - if !hasOperationInProgress { - // CHA-RL4b5 - guard let reason = stateChange.reason else { - // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) - preconditionFailure("FAILED state change event should have a reason") - } - - clearTransientDisconnectTimeouts() - changeStatus(to: .failed(error: reason)) - - // TODO: CHA-RL4b5 is a bit unclear about how to handle failure, and whether they can be detached concurrently (asked in https://github.com/ably/specification/pull/200/files#r1777471810) - for contributor in contributors { - do { - try await contributor.channel.detach() - } catch { - logger.log(message: "Failed to detach contributor \(contributor), error \(error)", level: .info) - } - } - } - case .suspended: - if !hasOperationInProgress { - // CHA-RL4b9 - guard let reason = stateChange.reason else { - // TODO: Decide the right thing to do here (https://github.com/ably-labs/ably-chat-swift/issues/74) - preconditionFailure("SUSPENDED state change event should have a reason") - } - - clearTransientDisconnectTimeouts() - - // My understanding is that, since this task is being created inside synchronous code which is isolated to an actor (specifically, the MainActor), the two .suspended* statuses will always come in the right order; i.e. first .suspendedAwaitingStartOfRetryOperation and then .suspended. - let retryOperationTask = scheduleAnOperation( - kind: .retry( - triggeringContributor: contributor, - errorForSuspendedStatus: reason - ) - ) - changeStatus(to: .suspendedAwaitingStartOfRetryOperation(retryOperationTask: retryOperationTask, error: reason)) - } - case .attaching: - if !hasOperationInProgress, !contributorAnnotations[contributor].hasTransientDisconnectTimeout { - // CHA-RL4b7 - let transientDisconnectTimeout = ContributorAnnotation.TransientDisconnectTimeout() - contributorAnnotations[contributor].transientDisconnectTimeout = transientDisconnectTimeout - logger.log(message: "Starting transient disconnect timeout \(transientDisconnectTimeout.id) for \(contributor)", level: .debug) - transientDisconnectTimeout.task = Task { - do { - try await clock.sleep(timeInterval: 5) - } catch { - logger.log(message: "Transient disconnect timeout \(transientDisconnectTimeout.id) for \(contributor) was interrupted, error \(error)", level: .debug) - - #if DEBUG - emitTransientDisconnectTimeoutHandledEventForTimeoutWithID(transientDisconnectTimeout.id) - #endif - - return - } - logger.log(message: "Transient disconnect timeout \(transientDisconnectTimeout.id) for \(contributor) completed", level: .debug) - contributorAnnotations[contributor].transientDisconnectTimeout = nil - changeStatus(to: .attachingDueToContributorStateChange(error: stateChange.reason)) - - #if DEBUG - emitTransientDisconnectTimeoutHandledEventForTimeoutWithID(transientDisconnectTimeout.id) - #endif - } + switch event.event { + // CHA-RL12a + case .update, .attached: + // CHA-RL12b + // + // Note that our mechanism for deciding whether a channel state event represents a discontinuity depends on the property that when we call attach() on a channel, the ATTACHED state change that this provokes is received before the call to attach() returns. This property is not in general guaranteed in ably-cocoa, which allows its callbacks to be dispatched to a user-provided queue as specified by the `dispatchQueue` client option. This is why we add the requirement that the ably-cocoa client be configured to use the main queue as its `dispatchQueue` (as enforced by toAblyCocoaCallback in InternalAblyCocoaTypes.swift). + if !event.resumed, hasAttachedOnce, !isExplicitlyDetached { + let error = ARTErrorInfo(chatError: ChatError.roomDiscontinuity(cause: event.reason)) + let discontinuity = DiscontinuityEvent(error: error) + logger.log(message: "Emitting discontinuity event \(discontinuity)", level: .info) + emitDiscontinuity(discontinuity) } default: break } - - #if DEBUG - logger.log(message: "Emitting state change handled event for \(stateChange)", level: .debug) - stateChangeHandledSubscriptions.emit(stateChange) - #endif } - #if DEBUG - private func emitTransientDisconnectTimeoutHandledEventForTimeoutWithID(_ id: UUID) { - logger.log(message: "Emitting transient disconnect timeout handled event for \(id)", level: .debug) - transientDisconnectTimeoutHandledSubscriptions.emit(id) - } - #endif - - private func clearTransientDisconnectTimeouts(for contributor: Contributor) { - guard let transientDisconnectTimeout = contributorAnnotations[contributor].transientDisconnectTimeout else { - return - } - - logger.log(message: "Clearing transient disconnect timeout \(transientDisconnectTimeout.id) for \(contributor)", level: .debug) - transientDisconnectTimeout.task?.cancel() - contributorAnnotations[contributor].transientDisconnectTimeout = nil + internal func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { + discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) } - private func clearTransientDisconnectTimeouts() { - for contributor in contributors { - clearTransientDisconnectTimeouts(for: contributor) - } - } - - private func recordPendingDiscontinuityEvent(for contributor: Contributor, error: ARTErrorInfo?) { - // CHA-RL4a3, and I have assumed that the same behaviour is expected in CHA-RL4b1 too (https://github.com/ably/specification/pull/246 proposes this change). - guard contributorAnnotations[contributor].pendingDiscontinuityEvent == nil else { - logger.log(message: "Error \(String(describing: error)) will not replace existing pending discontinuity event for contributor \(contributor)", level: .info) - return - } - - let discontinuity = DiscontinuityEvent(error: error) - logger.log(message: "Recording pending discontinuity event \(discontinuity) for contributor \(contributor)", level: .info) - contributorAnnotations[contributor].pendingDiscontinuityEvent = discontinuity + private func emitDiscontinuity(_ event: DiscontinuityEvent) { + discontinuitySubscriptions.emit(event) } // MARK: - Operation handling /// Whether the room lifecycle manager currently has a room lifecycle operation in progress. - /// - /// - Warning: I haven’t yet figured out the exact meaning of “has an operation in progress” — at what point is an operation considered to be no longer in progress? Is it the point at which the operation has updated the manager’s status to one that no longer indicates an in-progress operation (this is the meaning currently used by `hasOperationInProgress`)? Or is it the point at which the `bodyOf*Operation` method for that operation exits (i.e. the point at which ``performAnOperation(_:)`` considers the operation to have completed)? Does it matter? I’ve chosen to not think about this very much right now, but might need to revisit. See TODO against `emitPendingDiscontinuityEvents` in `performAttachmentCycle` for an example of something where these two notions of “has an operation in progress” are not equivalent. private var hasOperationInProgress: Bool { - status.operationID != nil + currentOperationID != nil } /// Stores bookkeeping information needed for allowing one operation to await the result of another. @@ -575,7 +258,7 @@ internal class DefaultRoomLifecycleManager() + private let operationWaitEventSubscriptions = SubscriptionStorage() /// Returns a subscription which emits an event each time one room lifecycle operation is going to wait for another to complete. internal func testsOnly_subscribeToOperationWaitEvents() -> Subscription { @@ -623,7 +306,7 @@ internal class DefaultRoomLifecycleManager Task { - logger.log(message: "Scheduling operation \(kind)", level: .debug) - return Task { - logger.log(message: "Performing scheduled operation \(kind)", level: .debug) - switch kind { - case let .retry(triggeringContributor, errorForSuspendedStatus): - await performRetryOperation( - triggeredByContributor: triggeringContributor, - errorForSuspendedStatus: errorForSuspendedStatus - ) - case let .rundown(errorForFailedStatus): - await performRundownOperation( - errorForFailedStatus: errorForFailedStatus - ) - } - } - } - // MARK: - ATTACH operation internal func performAttachOperation() async throws(InternalError) { @@ -728,7 +384,7 @@ internal class DefaultRoomLifecycleManager [Contributor] { - switch trigger { - case .detachOperation: - // CHA-RL2f - contributors - case let .retryOperation(_, triggeringContributor): - // CHA-RL5a - contributors.filter { $0.id != triggeringContributor.id } + // CHA-RL2k + do { + try await channel.detach() + } catch { + // CHA-RL2k2, CHA-RL2k3 + let channelState = await channel.state + logger.log(message: "Failed to detach channel, error \(error), channel now in \(channelState)", level: .info) + changeStatus(to: .init(channelState: channelState, error: error.toARTErrorInfo())) + throw error } + + // CHA-RL2k1 + isExplicitlyDetached = true + changeStatus(to: .detached(error: nil)) } // MARK: - RELEASE operation @@ -976,209 +514,65 @@ internal class DefaultRoomLifecycleManager() + private let statusChangeWaitEventSubscriptions = SubscriptionStorage() /// Returns a subscription which emits an event each time ``waitToBeAbleToPerformPresenceOperations(requestedByFeature:)`` is going to wait for a room status change. internal func testsOnly_subscribeToStatusChangeWaitEvents() -> Subscription { diff --git a/Sources/AblyChat/RoomOptions.swift b/Sources/AblyChat/RoomOptions.swift index bc1cd849..7b306f53 100644 --- a/Sources/AblyChat/RoomOptions.swift +++ b/Sources/AblyChat/RoomOptions.swift @@ -5,38 +5,26 @@ import Foundation */ public struct RoomOptions: Sendable, Equatable { /** - * The presence options for the room. To enable presence in the room, set this property. - * Alternatively, you may use ``RoomOptions/allFeaturesEnabled`` to enable presence with the default options. + * The presence options for the room. */ - public var presence: PresenceOptions? + public var presence = PresenceOptions() /** - * The typing options for the room. To enable typing in the room, set this property. - * Alternatively, you may use ``RoomOptions/allFeaturesEnabled`` to enable typing with the default options. + * The typing options for the room. */ - public var typing: TypingOptions? + public var typing = TypingOptions() /** - * The reactions options for the room. To enable reactions in the room, set this property. - * Alternatively, you may use ``RoomOptions/allFeaturesEnabled`` to enable reactions with the default options. + * The reactions options for the room. */ - public var reactions: RoomReactionsOptions? + public var reactions = RoomReactionsOptions() /** - * The occupancy options for the room. To enable occupancy in the room, set this property. - * Alternatively, you may use ``RoomOptions/allFeaturesEnabled`` to enable occupancy with the default options. + * The occupancy options for the room. */ - public var occupancy: OccupancyOptions? + public var occupancy = OccupancyOptions() - /// A `RoomOptions` which enables all room features, using the default settings for each feature. - public static let allFeaturesEnabled: Self = .init( - presence: .init(), - typing: .init(), - reactions: .init(), - occupancy: .init() - ) - - public init(presence: PresenceOptions? = nil, typing: TypingOptions? = nil, reactions: RoomReactionsOptions? = nil, occupancy: OccupancyOptions? = nil) { + public init(presence: PresenceOptions = PresenceOptions(), typing: TypingOptions = TypingOptions(), reactions: RoomReactionsOptions = RoomReactionsOptions(), occupancy: OccupancyOptions = OccupancyOptions()) { self.presence = presence self.typing = typing self.reactions = reactions @@ -44,33 +32,21 @@ public struct RoomOptions: Sendable, Equatable { } } -// (CHA-PR9) Users may configure their presence options via the RoomOptions provided at room configuration time. -// (CHA-PR9a) Setting enter to false prevents the user from entering presence by means of the ChannelMode on the underlying realtime channel. Entering presence will result in an error. The default is true. -// (CHA-PR9b) Setting subscribe to false prevents the user from subscribing to presence by means of the ChannelMode on the underlying realtime channel. This does not prevent them from receiving their own presence messages, but they will not receive them from others. The default is true. - /** * Represents the presence options for a chat room. */ public struct PresenceOptions: Sendable, Equatable { /** - * Whether the underlying Realtime channel should use the presence enter mode, allowing entry into presence. - * This property does not affect the presence lifecycle, and users must still call ``Presence/enter()`` - * in order to enter presence. - * Defaults to true. - */ - public var enter = true - - /** - * Whether the underlying Realtime channel should use the presence subscribe mode, allowing subscription to presence. - * This property does not affect the presence lifecycle, and users must still call ``Presence/subscribe(events:)`` - * in order to subscribe to presence. + * Whether or not the client should receive presence events from the server. This setting + * can be disabled if you are using presence in your Chat Room, but this particular client does not + * need to receive the messages. + * * Defaults to true. */ - public var subscribe = true + public var enableEvents = true - public init(enter: Bool = true, subscribe: Bool = true) { - self.enter = enter - self.subscribe = subscribe + public init(enableEvents: Bool = true) { + self.enableEvents = enableEvents } } @@ -106,5 +82,16 @@ public struct RoomReactionsOptions: Sendable, Equatable { * Represents the occupancy options for a chat room. */ public struct OccupancyOptions: Sendable, Equatable { - public init() {} + /** + * Whether to enable inbound occupancy events. + * + * Note that enabling this feature will increase the number of messages received by the client. + * + * Defaults to false. + */ + public var enableEvents = false + + public init(enableEvents: Bool = false) { + self.enableEvents = enableEvents + } } diff --git a/Sources/AblyChat/RoomReactionDTO.swift b/Sources/AblyChat/RoomReactionDTO.swift index 0570c52b..8c7e2359 100644 --- a/Sources/AblyChat/RoomReactionDTO.swift +++ b/Sources/AblyChat/RoomReactionDTO.swift @@ -1,4 +1,4 @@ -// CHA-ER3a +// CHA-ER3d internal struct RoomReactionDTO { internal var data: Data internal var extras: Extras @@ -56,6 +56,7 @@ extension RoomReactionDTO.Data: JSONObjectCodable { extension RoomReactionDTO.Extras: JSONObjectCodable { internal enum JSONKey: String { case headers + case ephemeral } internal init(jsonObject: [String: JSONValue]) throws(InternalError) { @@ -67,6 +68,8 @@ extension RoomReactionDTO.Extras: JSONObjectCodable { internal var toJSONObject: [String: JSONValue] { [ JSONKey.headers.rawValue: .object(headers?.mapValues(\.toJSONValue) ?? [:]), + // CHA-ER3d + JSONKey.ephemeral.rawValue: true, ] } } diff --git a/Sources/AblyChat/RoomReactions.swift b/Sources/AblyChat/RoomReactions.swift index e5c04b8d..50738bc4 100644 --- a/Sources/AblyChat/RoomReactions.swift +++ b/Sources/AblyChat/RoomReactions.swift @@ -6,7 +6,7 @@ import Ably * Get an instance via ``Room/reactions``. */ @MainActor -public protocol RoomReactions: AnyObject, Sendable, EmitsDiscontinuities { +public protocol RoomReactions: AnyObject, Sendable { /** * Send a reaction to the room including some metadata. * @@ -17,14 +17,6 @@ public protocol RoomReactions: AnyObject, Sendable, EmitsDiscontinuities { */ func send(params: SendReactionParams) async throws(ARTErrorInfo) - /** - * Returns an instance of the Ably realtime channel used for room-level reactions. - * Avoid using this directly unless special features that cannot otherwise be implemented are needed. - * - * - Returns: The realtime channel. - */ - nonisolated var channel: any RealtimeChannelProtocol { get } - /** * Subscribes a given listener to receive room-level reactions. * diff --git a/Sources/AblyChat/RoomStatus.swift b/Sources/AblyChat/RoomStatus.swift index 7d934018..b9090bf8 100644 --- a/Sources/AblyChat/RoomStatus.swift +++ b/Sources/AblyChat/RoomStatus.swift @@ -17,17 +17,17 @@ public enum RoomStatus: Sendable, Equatable { /** * The room is currently attached and receiving events. */ - case attached + case attached(error: ARTErrorInfo?) /** * The room is currently detaching and will not receive events. */ - case detaching + case detaching(error: ARTErrorInfo?) /** * The room is currently detached and will not receive events. */ - case detached + case detached(error: ARTErrorInfo?) /** * The room is in an extended state of detachment, but will attempt to re-attach when able. @@ -53,14 +53,17 @@ public enum RoomStatus: Sendable, Equatable { switch self { case let .attaching(error): error + case let .attached(error): + error + case let .detaching(error): + error + case let .detached(error): + error case let .suspended(error): error case let .failed(error): error case .initialized, - .attached, - .detaching, - .detached, .releasing, .released: nil @@ -80,6 +83,30 @@ public enum RoomStatus: Sendable, Equatable { } } + public var isAttached: Bool { + if case .attached = self { + true + } else { + false + } + } + + public var isDetaching: Bool { + if case .detaching = self { + true + } else { + false + } + } + + public var isDetached: Bool { + if case .detached = self { + true + } else { + false + } + } + public var isSuspended: Bool { if case .suspended = self { true diff --git a/Sources/AblyChat/Rooms.swift b/Sources/AblyChat/Rooms.swift index 9a67e606..750fdcce 100644 --- a/Sources/AblyChat/Rooms.swift +++ b/Sources/AblyChat/Rooms.swift @@ -28,6 +28,11 @@ public protocol Rooms: AnyObject, Sendable { */ func get(roomID: String, options: RoomOptions) async throws(ARTErrorInfo) -> any Room + /// Same as calling ``get(roomID:options:)`` with `RoomOptions()`. + /// + /// The `Rooms` protocol provides a default implementation of this method. + func get(roomID: String) async throws(ARTErrorInfo) -> any Room + /** * Release the ``Room`` object if it exists. This method only releases the reference * to the Room object from the Rooms instance and detaches the room from Ably. It does not unsubscribe to any @@ -51,6 +56,13 @@ public protocol Rooms: AnyObject, Sendable { nonisolated var clientOptions: ChatClientOptions { get } } +public extension Rooms { + func get(roomID: String) async throws(ARTErrorInfo) -> any Room { + // CHA-RC4a + try await get(roomID: roomID, options: .init()) + } +} + internal class DefaultRooms: Rooms { private nonisolated let realtime: any InternalRealtimeClientProtocol private let chatAPI: ChatAPI @@ -140,7 +152,7 @@ internal class DefaultRooms: Rooms { } /// Supports the ``testsOnly_subscribeToOperationWaitEvents()`` method. - private var operationWaitEventSubscriptions = SubscriptionStorage() + private let operationWaitEventSubscriptions = SubscriptionStorage() /// Returns a subscription which emits an event each time one operation is going to wait for another to complete. internal func testsOnly_subscribeToOperationWaitEvents() -> Subscription { diff --git a/Sources/AblyChat/Subscription.swift b/Sources/AblyChat/Subscription.swift index 4bd07dbe..cbee9b67 100644 --- a/Sources/AblyChat/Subscription.swift +++ b/Sources/AblyChat/Subscription.swift @@ -78,6 +78,22 @@ public final class Subscription: @unchecked Sendable, AsyncSe } } + #if DEBUG + /** + Signal that there are no more elements for the iteration to receive. + + It is a programmer error to call this when the receiver was created using ``init(mockAsyncSequence:)``. + */ + internal func testsOnly_finish() { + switch mode { + case let .default(_, continuation): + continuation.finish() + case .mockAsyncSequence: + fatalError("`finish` cannot be called on a Subscription that was created using init(mockAsyncSequence:)") + } + } + #endif + @MainActor internal func addTerminationHandler(_ terminationHandler: @escaping (@Sendable () -> Void)) { terminationHandlers.append(terminationHandler) diff --git a/Sources/AblyChat/Typing.swift b/Sources/AblyChat/Typing.swift index 351ad5a6..aebc84b7 100644 --- a/Sources/AblyChat/Typing.swift +++ b/Sources/AblyChat/Typing.swift @@ -7,7 +7,7 @@ import Ably * Get an instance via ``Room/typing``. */ @MainActor -public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { +public protocol Typing: AnyObject, Sendable { /** * Subscribes a given listener to all typing events from users in the chat room. * @@ -50,13 +50,6 @@ public protocol Typing: AnyObject, Sendable, EmitsDiscontinuities { * - Throws: An `ARTErrorInfo`. */ func stop() async throws(ARTErrorInfo) - - /** - * Get the Ably realtime channel underpinning typing events. - * - * - Returns: The Ably realtime channel. - */ - nonisolated var channel: any RealtimeChannelProtocol { get } } public extension Typing { diff --git a/Tests/AblyChatTests/ChatAPITests.swift b/Tests/AblyChatTests/ChatAPITests.swift index c0356295..bb8cd8b1 100644 --- a/Tests/AblyChatTests/ChatAPITests.swift +++ b/Tests/AblyChatTests/ChatAPITests.swift @@ -13,7 +13,7 @@ struct ChatAPITests { MockHTTPPaginatedResponse.successSendMessageWithNoItems } let chatAPI = ChatAPI(realtime: realtime) - let roomId = "basketball::$chat::$chatMessages" + let roomId = "basketball" await #expect( performing: { @@ -38,7 +38,7 @@ struct ChatAPITests { MockHTTPPaginatedResponse.successSendMessage } let chatAPI = ChatAPI(realtime: realtime) - let roomId = "basketball::$chat::$chatMessages" + let roomId = "basketball" // When let message = try await chatAPI.sendMessage(roomId: roomId, params: .init(text: "hello", headers: [:])) @@ -116,7 +116,7 @@ struct ChatAPITests { paginatedResponse } let chatAPI = ChatAPI(realtime: realtime) - let roomId = "basketball::$chat::$chatMessages" + let roomId = "basketball" let expectedPaginatedResult = PaginatedResultWrapper( paginatedResponse: paginatedResponse, items: [] @@ -138,7 +138,7 @@ struct ChatAPITests { paginatedResponse } let chatAPI = ChatAPI(realtime: realtime) - let roomId = "basketball::$chat::$chatMessages" + let roomId = "basketball" let expectedPaginatedResult = PaginatedResultWrapper( paginatedResponse: paginatedResponse, items: [ @@ -185,7 +185,7 @@ struct ChatAPITests { throw artError } let chatAPI = ChatAPI(realtime: realtime) - let roomId = "basketball::$chat::$chatMessages" + let roomId = "basketball" await #expect( performing: { diff --git a/Tests/AblyChatTests/DefaultMessagesTests.swift b/Tests/AblyChatTests/DefaultMessagesTests.swift index b2021252..94d88081 100644 --- a/Tests/AblyChatTests/DefaultMessagesTests.swift +++ b/Tests/AblyChatTests/DefaultMessagesTests.swift @@ -12,8 +12,7 @@ struct DefaultMessagesTests { let realtime = MockRealtime() let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel(initialState: .attached) - let featureChannel = MockFeatureChannel(channel: channel) - let defaultMessages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // Then // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released @@ -34,8 +33,7 @@ struct DefaultMessagesTests { let realtime = MockRealtime { MockHTTPPaginatedResponse.successGetMessagesWithNoItems } let chatAPI = ChatAPI(realtime: realtime) let channel = MockRealtimeChannel() - let featureChannel = MockFeatureChannel(channel: channel) - let defaultMessages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // Then // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released @@ -64,8 +62,7 @@ struct DefaultMessagesTests { ), initialState: .attached ) - let featureChannel = MockFeatureChannel(channel: channel) - let defaultMessages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) let subscription = try await defaultMessages.subscribe() let expectedPaginatedResult = PaginatedResultWrapper( paginatedResponse: MockHTTPPaginatedResponse.successGetMessagesWithNoItems, @@ -108,8 +105,7 @@ struct DefaultMessagesTests { return message }() ) - let featureChannel = MockFeatureChannel(channel: channel) - let defaultMessages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // When let messagesSubscription = try await defaultMessages.subscribe() @@ -147,8 +143,7 @@ struct DefaultMessagesTests { return message }() ) - let featureChannel = MockFeatureChannel(channel: channel) - let defaultMessages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) + let defaultMessages = DefaultMessages(channel: channel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) // When let messagesSubscription = try await defaultMessages.subscribe() @@ -157,24 +152,4 @@ struct DefaultMessagesTests { let receivedMessage = try #require(await messagesSubscription.first { @Sendable _ in true }) #expect(receivedMessage.metadata == ["numberKey": .number(10), "stringKey": .string("hello")]) } - - // @spec CHA-M7 - @Test - func onDiscontinuity() async throws { - // Given: A DefaultMessages instance - let realtime = MockRealtime() - let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() - let featureChannel = MockFeatureChannel(channel: channel) - let messages = DefaultMessages(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", clientID: "clientId", logger: TestLogger()) - - // When: The feature channel emits a discontinuity through `onDiscontinuity` - let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError() /* arbitrary */ ) - let messagesDiscontinuitySubscription = messages.onDiscontinuity() - featureChannel.emitDiscontinuity(featureChannelDiscontinuity) - - // Then: The DefaultMessages instance emits this discontinuity through `onDiscontinuity` - let messagesDiscontinuity = try #require(await messagesDiscontinuitySubscription.first { @Sendable _ in true }) - #expect(messagesDiscontinuity == featureChannelDiscontinuity) - } } diff --git a/Tests/AblyChatTests/DefaultPresenceTests.swift b/Tests/AblyChatTests/DefaultPresenceTests.swift new file mode 100644 index 00000000..94bc886f --- /dev/null +++ b/Tests/AblyChatTests/DefaultPresenceTests.swift @@ -0,0 +1,3 @@ +struct DefaultPresenceTests { + // @specUntested CHA-PR7d - We chose to implement this failure with an idiomatic fatalError instead of throwing, but we can’t test this. +} diff --git a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift index 8298941d..4cca02f8 100644 --- a/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift +++ b/Tests/AblyChatTests/DefaultRoomLifecycleManagerTests.swift @@ -28,84 +28,37 @@ struct DefaultRoomLifecycleManagerTests { } } - /// A mock implementation of a `SimpleClock`’s `sleep(timeInterval:)` operation. Its ``complete(result:)`` method allows you to signal to the mock that the sleep should complete. - final class SignallableSleepOperation: Sendable { - private let continuation: AsyncStream.Continuation - - /// When this behavior is set as a ``MockSimpleClock``’s `sleepBehavior`, calling ``complete(result:)`` will cause the corresponding `sleep(timeInterval:)` to complete with the result passed to that method. - /// - /// ``sleep(timeInterval:)`` will respond to task cancellation by throwing `CancellationError`. - let behavior: MockSimpleClock.SleepBehavior - - init() { - let (stream, continuation) = AsyncStream.makeStream(of: Void.self) - self.continuation = continuation - - behavior = .fromFunction { - await (stream.first { _ in true }) // this will return if we yield to the continuation or if the Task is cancelled - try Task.checkCancellation() - } - } - - /// Causes the async function embedded in ``behavior`` to return. - func complete() { - continuation.yield(()) - } - } - private func createManager( - forTestingWhatHappensWhenCurrentlyIn status: DefaultRoomLifecycleManager.Status? = nil, - forTestingWhatHappensWhenHasPendingDiscontinuityEvents pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: DiscontinuityEvent]? = nil, - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs idsOfContributorsWithTransientDisconnectTimeout: Set? = nil, - contributors: [MockRoomLifecycleContributor] = [], + forTestingWhatHappensWhenCurrentlyIn roomStatus: RoomStatus? = nil, + forTestingWhatHappensWhenHasHasAttachedOnce hasAttachedOnce: Bool? = nil, + forTestingWhatHappensWhenHasIsExplicitlyDetached isExplicitlyDetached: Bool? = nil, + channel: MockRealtimeChannel? = nil, clock: SimpleClock = MockSimpleClock() - ) -> DefaultRoomLifecycleManager { + ) -> DefaultRoomLifecycleManager { .init( - testsOnly_status: status, - testsOnly_pendingDiscontinuityEvents: pendingDiscontinuityEvents, - testsOnly_idsOfContributorsWithTransientDisconnectTimeout: idsOfContributorsWithTransientDisconnectTimeout, - contributors: contributors, + testsOnly_roomStatus: roomStatus, + testsOnly_hasAttachedOnce: hasAttachedOnce, + testsOnly_isExplicitlyDetached: isExplicitlyDetached, + channel: channel ?? createChannel(), logger: TestLogger(), clock: clock ) } - private func createContributor( + private func createChannel( initialState: ARTRealtimeChannelState = .initialized, initialErrorReason: ARTErrorInfo? = nil, - feature: RoomFeature = .messages, // Arbitrarily chosen, its value only matters in test cases where we check which error is thrown attachBehavior: MockRealtimeChannel.AttachOrDetachBehavior? = nil, - detachBehavior: MockRealtimeChannel.AttachOrDetachBehavior? = nil, - subscribeToStateBehavior: MockRealtimeChannel.SubscribeToStateBehavior? = nil - ) -> MockRoomLifecycleContributor { + detachBehavior: MockRealtimeChannel.AttachOrDetachBehavior? = nil + ) -> MockRealtimeChannel { .init( - feature: feature, - channel: .init( - initialState: initialState, - initialErrorReason: initialErrorReason, - attachBehavior: attachBehavior, - detachBehavior: detachBehavior, - subscribeToStateBehavior: subscribeToStateBehavior - ) + initialState: initialState, + initialErrorReason: initialErrorReason, + attachBehavior: attachBehavior, + detachBehavior: detachBehavior ) } - /// Given a room lifecycle manager and a channel state change, this method will return once the manager has performed all of the side effects that it will perform as a result of receiving this state change. You can provide a function which will be called after ``waitForManager`` has started listening for the manager’s “state change handled” notifications. - func waitForManager(_ manager: DefaultRoomLifecycleManager, toHandleContributorStateChange stateChange: ARTChannelStateChange, during action: () async -> Void) async { - let subscription = manager.testsOnly_subscribeToHandledContributorStateChanges() - async let handledSignal = subscription.first { $0 === stateChange } - await action() - _ = await handledSignal - } - - /// Given a room lifecycle manager and the ID of a transient disconnect timeout, this method will return once the manager has performed all of the side effects that it will perform as a result of creating that timeout. You can provide a function which will be called after ``waitForManager`` has started listening for the manager’s “transient disconnect timeout handled” notifications. - func waitForManager(_ manager: DefaultRoomLifecycleManager, toHandleTransientDisconnectTimeoutWithID id: UUID, during action: () async -> Void) async { - let subscription = manager.testsOnly_subscribeToHandledTransientDisconnectTimeouts() - async let handledSignal = subscription.first { $0 == id } - await action() - _ = await handledSignal - } - // MARK: - Initial state // @spec CHA-RS2a @@ -123,14 +76,14 @@ struct DefaultRoomLifecycleManagerTests { @Test func attach_whenAlreadyAttached() async throws { // Given: A DefaultRoomLifecycleManager in the ATTACHED status - let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .attached, contributors: [contributor]) + let channel = createChannel() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil)) // When: `performAttachOperation()` is called on the lifecycle manager try await manager.performAttachOperation() - // Then: The room attach operation succeeds, and no attempt is made to attach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) - #expect(contributor.mockChannel.attachCallCount == 0) + // Then: The room attach operation succeeds, and no attempt is made to attach the channel (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + #expect(channel.attachCallCount == 0) } // @spec CHA-RL1b @@ -138,7 +91,7 @@ struct DefaultRoomLifecycleManagerTests { func attach_whenReleasing() async throws { // Given: A DefaultRoomLifecycleManager in the RELEASING status let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .releasing(releaseOperationID: UUID() /* arbitrary */ ) + forTestingWhatHappensWhenCurrentlyIn: .releasing ) // When: `performAttachOperation()` is called on the lifecycle manager @@ -169,16 +122,14 @@ struct DefaultRoomLifecycleManagerTests { @Test func attach_ifOtherOperationInProgress_waitsForItToComplete() async throws { // Given: A DefaultRoomLifecycleManager with a DETACH lifecycle operation in progress (the fact that it is a DETACH is not important; it is just an operation whose execution it is easy to prolong and subsequently complete, which is helpful for this test) - let contributorDetachOperation = SignallableChannelOperation() + let channelDetachOperation = SignallableChannelOperation() let manager = createManager( - contributors: [ - createContributor( - // Arbitrary, allows the ATTACH to eventually complete - attachBehavior: .success, - // This allows us to prolong the execution of the DETACH triggered in (1) - detachBehavior: contributorDetachOperation.behavior - ), - ] + channel: createChannel( + // Arbitrary, allows the ATTACH to eventually complete + attachBehavior: .success, + // This allows us to prolong the execution of the DETACH triggered in (1) + detachBehavior: channelDetachOperation.behavior + ) ) let detachOperationID = UUID() @@ -186,14 +137,14 @@ struct DefaultRoomLifecycleManagerTests { let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) // Wait for the manager to enter DETACHING; this our sign that the DETACH operation triggered in (1) has started - async let detachingStatusChange = statusChangeSubscription.first { $0.current == .detaching } + async let detachingStatusChange = statusChangeSubscription.first { $0.current == .detaching(error: nil) } // (1) This is the "DETACH lifecycle operation in progress" mentioned in Given async let _ = manager.performDetachOperation(testsOnly_forcingOperationID: detachOperationID) _ = await detachingStatusChange let operationWaitEventSubscription = manager.testsOnly_subscribeToOperationWaitEvents() - async let attachedWaitingForDetachedEvent = operationWaitEventSubscription.first { operationWaitEvent in + async let attachWaitingForDetachEvent = operationWaitEventSubscription.first { operationWaitEvent in operationWaitEvent == .init(waitingOperationID: attachOperationID, waitedOperationID: detachOperationID) } @@ -204,10 +155,10 @@ struct DefaultRoomLifecycleManagerTests { // - the manager informs us that the ATTACH operation is waiting for the DETACH operation to complete // - when the DETACH completes, the ATTACH operation proceeds (which we check here by verifying that it eventually completes) — note that (as far as I can tell) there is no way to test that the ATTACH operation would have proceeded _only if_ the DETACH had completed; the best we can do is allow the manager to tell us that that this is indeed what it’s doing (which is what we check for in the previous bullet) - _ = try #require(await attachedWaitingForDetachedEvent) + _ = try #require(await attachWaitingForDetachEvent) // Allow the DETACH to complete - contributorDetachOperation.complete(behavior: .success /* arbitrary */ ) + channelDetachOperation.complete(behavior: .success /* arbitrary */ ) // Check that ATTACH completes try await attachResult @@ -216,10 +167,12 @@ struct DefaultRoomLifecycleManagerTests { // @spec CHA-RL1e @Test func attach_transitionsToAttaching() async throws { - // Given: A DefaultRoomLifecycleManager, with a contributor on whom calling `attach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to ATTACHED, so that we can assert its current status as being ATTACHING) - let contributorAttachOperation = SignallableChannelOperation() + // Given: A DefaultRoomLifecycleManager, with a channel on whom calling `attach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to ATTACHED, so that we can assert its current status as being ATTACHING) + let channelAttachOperation = SignallableChannelOperation() - let manager = createManager(contributors: [createContributor(attachBehavior: contributorAttachOperation.behavior)]) + let manager = createManager( + channel: createChannel(attachBehavior: channelAttachOperation.behavior) + ) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -231,287 +184,73 @@ struct DefaultRoomLifecycleManagerTests { #expect(manager.roomStatus == .attaching(error: nil)) - // Post-test: Now that we’ve seen the ATTACHING status, allow the contributor `attach` call to complete - contributorAttachOperation.complete(behavior: .success) + // Post-test: Now that we’ve seen the ATTACHING status, allow the channel `attach` call to complete + channelAttachOperation.complete(behavior: .success) } - // @spec CHA-RL1f - // @spec CHA-RL1g1 + // @spec CHA-RL1k + // @spec CHA-RL1k1 @Test - func attach_attachesAllContributors_andWhenTheyAllAttachSuccessfully_transitionsToAttached() async throws { - // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } - let manager = createManager(contributors: contributors) + func attach_attachesChannel_andWhenItAttachesSuccessfully_transitionsToAttached() async throws { + // Given: A DefaultRoomLifecycleManager, whose channel's call to `attach` succeeds + let channel = createChannel(attachBehavior: .success) + let manager = createManager( + // These two flags are being set just so that we can verify they get unset per CHA-RL1k1 + forTestingWhatHappensWhenHasHasAttachedOnce: true, + forTestingWhatHappensWhenHasIsExplicitlyDetached: true, + channel: channel + ) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let attachedStatusChange = statusChangeSubscription.first { $0.current == .attached } + async let attachedStatusChange = statusChangeSubscription.first { $0.current.isAttached } // When: `performAttachOperation()` is called on the lifecycle manager try await manager.performAttachOperation() - // Then: It calls `attach` on all the contributors, the room attach operation succeeds, it emits a status change to ATTACHED, and its current status is ATTACHED - for contributor in contributors { - #expect(contributor.mockChannel.attachCallCount > 0) - } + // Then: It calls `attach` on the channel, the room attach operation succeeds, it emits a status change to ATTACHED, its current status is ATTACHED, and it sets the isExplicitlyDetached flag to false and the hasAttachedOnce flag to true + #expect(channel.attachCallCount > 0) _ = try #require(await attachedStatusChange, "Expected status change to ATTACHED") - try #require(manager.roomStatus == .attached) - } - - // @spec CHA-RL1g2 - @Test - func attach_uponSuccess_emitsPendingDiscontinuityEvents() async throws { - // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } - let pendingDiscontinuityEvents: [MockRoomLifecycleContributor.ID: DiscontinuityEvent] = [ - contributors[1].id: .init(error: .init(domain: "SomeDomain", code: 123) /* arbitrary */ ), - contributors[2].id: .init(error: .init(domain: "SomeDomain", code: 456) /* arbitrary */ ), - ] - let manager = createManager( - forTestingWhatHappensWhenHasPendingDiscontinuityEvents: pendingDiscontinuityEvents, - contributors: contributors - ) - - // When: `performAttachOperation()` is called on the lifecycle manager - try await manager.performAttachOperation() - - // Then: It: - // - emits all pending discontinuities to its contributors - // - clears all pending discontinuity events - for contributor in contributors { - let expectedPendingDiscontinuityEvent = pendingDiscontinuityEvents[contributor.id] - let emitDiscontinuityArguments = contributor.emitDiscontinuityArguments - try #require(emitDiscontinuityArguments.count == (expectedPendingDiscontinuityEvent == nil ? 0 : 1)) - #expect(emitDiscontinuityArguments.first == expectedPendingDiscontinuityEvent) - } + try #require(manager.roomStatus == .attached(error: nil)) - for contributor in contributors { - #expect(manager.testsOnly_pendingDiscontinuityEvent(for: contributor) == nil) - } + #expect(!manager.testsOnly_isExplicitlyDetached) + #expect(manager.testsOnly_hasAttachedOnce) } - // @spec CHA-RL1g3 + // @spec CHA-RL1k2 + // @spec CHA-RL1k3 @Test - func attach_uponSuccess_clearsTransientDisconnectTimeouts() async throws { - // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `attach` succeed - let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .success) } - let manager = createManager( - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [contributors[1].id], - contributors: contributors + func attach_whenChannelFailsToAttach() async throws { + // Given: A DefaultRoomLifecycleManager, whose channel's call to `attach` fails causing it to enter the FAILED state (arbitrarily chosen) + let channelAttachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let channel = createChannel( + attachBehavior: .completeAndChangeState(.failure(channelAttachError), newState: .failed) ) - // When: `performAttachOperation()` is called on the lifecycle manager - try await manager.performAttachOperation() - - // Then: It clears all transient disconnect timeouts - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) - } - - // @spec CHA-RL1h2 - // @spec CHA-RL1h3 - @Test - func attach_whenContributorFailsToAttachAndEntersSuspended_transitionsToSuspendedAndPerformsRetryOperation() async throws { - // Given: A DefaultRoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the SUSPENDED status - let contributorAttachError = ARTErrorInfo(domain: "SomeDomain", code: 123) - var nonSuspendedContributorsDetachOperations: [SignallableChannelOperation] = [] - let contributors = (1 ... 3).map { i in - if i == 1 { - return createContributor( - attachBehavior: .fromFunction { callCount in - if callCount == 1 { - .completeAndChangeState(.failure(contributorAttachError), newState: .suspended) // The behaviour described above - } else { - .success // So the CHA-RL5f RETRY attachment cycle succeeds - } - }, - - // This is so that the RETRY operation’s wait-to-become-ATTACHED succeeds - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .detached, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - ) - ) - } else { - // These contributors will be detached by the RETRY operation; we want to be able to delay their completion (and hence delay the RETRY operation’s transition from SUSPENDED to DETACHED) until we have been able to verify that the room’s status is SUSPENDED - let detachOperation = SignallableChannelOperation() - nonSuspendedContributorsDetachOperations.append(detachOperation) - - return createContributor( - attachBehavior: .success, // So the CHA-RL5f RETRY attachment cycle succeeds - detachBehavior: detachOperation.behavior - ) - } - } - - let manager = createManager(contributors: contributors) + let manager = createManager(channel: channel) - let roomStatusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeSuspendedRoomStatusChange = roomStatusChangeSubscription.suspendedElements().first { _ in true } - - let managerStatusChangeSubscription = manager.testsOnly_onStatusChange() + let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) + async let maybeFailedStatusChange = statusChangeSubscription.failedElements().first { _ in true } // When: `performAttachOperation()` is called on the lifecycle manager - async let roomAttachResult: Void = manager.performAttachOperation() - - // Then: - // - // 1. the room status transitions to SUSPENDED, with the status change’s `error` having the AttachmentFailed code corresponding to the feature of the failed contributor, `cause` equal to the error thrown by the contributor `attach` call - // 2. the manager’s `error` is set to this same error - // 3. the room attach operation fails with this same error - let suspendedRoomStatusChange = try #require(await maybeSuspendedRoomStatusChange) - - #expect(manager.roomStatus.isSuspended) - - var roomAttachError: Error? + var roomAttachError: ARTErrorInfo? do { - _ = try await roomAttachResult + try await manager.performAttachOperation() } catch { - roomAttachError = error - } - - for error in [suspendedRoomStatusChange.error, manager.roomStatus.error, roomAttachError] { - #expect(isChatError(error, withCodeAndStatusCode: .fixedStatusCode(.messagesAttachmentFailed), cause: contributorAttachError)) - } - - // and: - // 4. The manager asynchronously performs a RETRY operation, triggered by the contributor that entered SUSPENDED - - // Allow the RETRY operation’s contributor detaches to complete - for detachOperation in nonSuspendedContributorsDetachOperations { - detachOperation.complete(behavior: .success) // So the CHA-RL5a RETRY detachment cycle succeeds - } - - // Wait for the RETRY to finish - let retryOperationTask = try #require(await managerStatusChangeSubscription.compactMap { statusChange in - if case let .suspendedAwaitingStartOfRetryOperation(retryOperationTask, _) = statusChange.current { - return retryOperationTask - } - return nil + roomAttachError = error.toARTErrorInfo() } - .first { @Sendable _ in - true - }) - - await retryOperationTask.value - - // We confirm that a RETRY happened by checking for its expected side effects: - #expect(contributors[0].mockChannel.detachCallCount == 0) // RETRY doesn’t touch this since it’s the one that triggered the RETRY - #expect(contributors[1].mockChannel.detachCallCount == 1) // From the CHA-RL5a RETRY detachment cycle - #expect(contributors[2].mockChannel.detachCallCount == 1) // From the CHA-RL5a RETRY detachment cycle - - #expect(contributors[0].mockChannel.attachCallCount == 2) // From the ATTACH operation and the CHA-RL5f RETRY attachment cycle - #expect(contributors[1].mockChannel.attachCallCount == 1) // From the CHA-RL5f RETRY attachment cycle - #expect(contributors[2].mockChannel.attachCallCount == 1) // From the CHA-RL5f RETRY attachment cycle - - _ = try #require(await roomStatusChangeSubscription.first { @Sendable statusChange in - statusChange.current == .attached - }) // Room status changes to ATTACHED - } - - // @spec CHA-RL1h4 - @Test - func attach_whenContributorFailsToAttachAndEntersFailed_transitionsToFailed() async throws { - // Given: A DefaultRoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the FAILED state - let contributorAttachError = ARTErrorInfo(domain: "SomeDomain", code: 123) - let contributors = (1 ... 3).map { i in - if i == 1 { - createContributor( - feature: .messages, // arbitrary - attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .failed) - ) - } else { - createContributor( - feature: .occupancy, // arbitrary, just needs to be different to that used for the other contributor - attachBehavior: .success, - // The room is going to try to detach per CHA-RL1h5 (the RUNDOWN operation), so even though that's not what this test is testing, we need a detachBehavior so the mock doesn’t blow up - detachBehavior: .success - ) - } - } - - let manager = createManager(contributors: contributors) - - let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeFailedStatusChange = statusChangeSubscription.failedElements().first { _ in true } - - // When: `performAttachOperation()` is called on the lifecycle manager - async let roomAttachResult: Void = manager.performAttachOperation() // Then: - // 1. the room status transitions to FAILED, with the status change’s `error` having the AttachmentFailed code corresponding to the feature of the failed contributor, `cause` equal to the error thrown by the contributor `attach` call + // 1. the room status transitions to the same state as the channel entered (i.e. FAILED in this example), with the status change’s `error` equal to the error thrown by the channel `attach` call // 2. the manager’s `error` is set to this same error // 3. the room attach operation fails with this same error let failedStatusChange = try #require(await maybeFailedStatusChange) #expect(manager.roomStatus.isFailed) - var roomAttachError: Error? - do { - _ = try await roomAttachResult - } catch { - roomAttachError = error - } - for error in [failedStatusChange.error, manager.roomStatus.error, roomAttachError] { - #expect(isChatError(error, withCodeAndStatusCode: .fixedStatusCode(.messagesAttachmentFailed), cause: contributorAttachError)) - } - } - - // @specOneOf(1/2) CHA-RL1h5 - Tests that the RUNDOWN operation is triggered - see TODO on `performRundownOperation` for my interpretation of CHA-RL1h5, in which I introduce RUNDOWN - @Test - func attach_whenAttachPutsChannelIntoFailedState_schedulesRundownOperation() async throws { - // Given: A room with the following contributors, in the following order: - // - // 0. a channel for whom calling `attach` will complete successfully, putting it in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) - // 1. a channel for whom calling `attach` will fail, putting it in the FAILED state - // 2. a channel in the INITIALIZED state (another arbitrarily-chosen state that is not FAILED) - // - // for which, when `detach` is called on contributors 0 and 2 (i.e. the non-FAILED contributors), it completes successfully - let contributors = [ - createContributor( - attachBehavior: .completeAndChangeState(.success, newState: .attached), - detachBehavior: .success - ), - createContributor( - attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) - ), - createContributor( - detachBehavior: .success - ), - ] - - let manager = createManager(contributors: contributors) - - let managerStatusSubscription = manager.testsOnly_onStatusChange() - - // When: `performAttachOperation()` is called on the lifecycle manager - try? await manager.performAttachOperation() - - // Then: - // - // - the lifecycle manager schedules a RUNDOWN operation - // - when we wait for this RUNDOWN operation to complete, we confirm that it has occured by checking for its side effects, namely that: - // - the lifecycle manager has called `detach` on contributors 0 and 2 - // - the lifecycle manager has not called `detach` on contributor 1 - - let rundownOperationTask = try #require(await managerStatusSubscription.compactMap { managerStatusChange in - if case let .failedAwaitingStartOfRundownOperation(rundownOperationTask, _) = managerStatusChange.current { - rundownOperationTask - } else { - nil - } + #expect(error === channelAttachError) } - .first { @Sendable _ in true }) - - _ = await rundownOperationTask.value - - #expect(contributors[0].mockChannel.detachCallCount > 0) - #expect(contributors[2].mockChannel.detachCallCount > 0) - #expect(contributors[1].mockChannel.detachCallCount == 0) } // MARK: - DETACH operation @@ -520,14 +259,14 @@ struct DefaultRoomLifecycleManagerTests { @Test func detach_whenAlreadyDetached() async throws { // Given: A DefaultRoomLifecycleManager in the DETACHED status - let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + let channel = createChannel() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached(error: nil), channel: channel) // When: `performDetachOperation()` is called on the lifecycle manager try await manager.performDetachOperation() - // Then: The room detach operation succeeds, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) - #expect(contributor.mockChannel.detachCallCount == 0) + // Then: The room detach operation succeeds, and no attempt is made to detach the channel (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + #expect(channel.detachCallCount == 0) } // @spec CHA-RL2b @@ -535,7 +274,7 @@ struct DefaultRoomLifecycleManagerTests { func detach_whenReleasing() async throws { // Given: A DefaultRoomLifecycleManager in the RELEASING status let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .releasing(releaseOperationID: UUID() /* arbitrary */ ) + forTestingWhatHappensWhenCurrentlyIn: .releasing ) // When: `performDetachOperation()` is called on the lifecycle manager @@ -581,138 +320,137 @@ struct DefaultRoomLifecycleManagerTests { } } - // @spec CHA-RL2e + // @spec CHA-RL2i + @Test + func detach_ifOtherOperationInProgress_waitsForItToComplete() async throws { + // Given: A DefaultRoomLifecycleManager with an ATTACH lifecycle operation in progress (the fact that it is an ATTACH is not important; it is just an operation whose execution it is easy to prolong and subsequently complete, which is helpful for this test) + let channelAttachOperation = SignallableChannelOperation() + let manager = createManager( + channel: createChannel( + // This allows us to prolong the execution of the ATTACH triggered in (1) + attachBehavior: channelAttachOperation.behavior, + // Arbitrary, allows the DETACH to eventually complete + detachBehavior: .success + ) + ) + + let attachOperationID = UUID() + let detachOperationID = UUID() + + let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) + // Wait for the manager to enter ATTACHING; this our sign that the ATTACH operation triggered in (1) has started + async let attachingStatusChange = statusChangeSubscription.attachingElements().first { _ in true } + + // (1) This is the "ATTACH lifecycle operation in progress" mentioned in Given + async let _ = manager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + _ = await attachingStatusChange + + let operationWaitEventSubscription = manager.testsOnly_subscribeToOperationWaitEvents() + async let detachWaitingForAttachEvent = operationWaitEventSubscription.first { operationWaitEvent in + operationWaitEvent == .init(waitingOperationID: detachOperationID, waitedOperationID: attachOperationID) + } + + // When: `performDetachOperation()` is called on the lifecycle manager + async let detachResult: Void = manager.performDetachOperation(testsOnly_forcingOperationID: detachOperationID) + + // Then: + // - the manager informs us that the DETACH operation is waiting for the ATTACH operation to complete + // - when the ATTACH completes, the DETACH operation proceeds (which we check here by verifying that it eventually completes) — note that (as far as I can tell) there is no way to test that the DETACH operation would have proceeded _only if_ the ATTACH had completed; the best we can do is allow the manager to tell us that that this is indeed what it’s doing (which is what we check for in the previous bullet) + + _ = try #require(await detachWaitingForAttachEvent) + + // Allow the ATTACH to complete + channelAttachOperation.complete(behavior: .success /* arbitrary */ ) + + // Check that DETACH completes + try await detachResult + } + + // @spec CHA-RL2j @Test func detach_transitionsToDetaching() async throws { - // Given: A DefaultRoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to DETACHED, so that we can assert its current status as being DETACHING) - let contributorDetachOperation = SignallableChannelOperation() + // Given: A DefaultRoomLifecycleManager, with a channel on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to DETACHED, so that we can assert its current status as being DETACHING) + let channelDetachOperation = SignallableChannelOperation() - let contributor = createContributor(detachBehavior: contributorDetachOperation.behavior) + let channel = createChannel(detachBehavior: channelDetachOperation.behavior) - let manager = createManager( - // We set a transient disconnect timeout, just so we can check that it gets cleared, as the spec point specifies - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [contributor.id], - contributors: [contributor] - ) + let manager = createManager(channel: channel) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } // When: `performDetachOperation()` is called on the lifecycle manager async let _ = try await manager.performDetachOperation() - // Then: It emits a status change to DETACHING, its current status is DETACHING, and it clears transient disconnect timeouts - #expect(try #require(await statusChange).current == .detaching) - #expect(manager.roomStatus == .detaching) - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) + // Then: It emits a status change to DETACHING, and its current status is DETACHING + #expect(try #require(await statusChange).current == .detaching(error: nil)) + #expect(manager.roomStatus == .detaching(error: nil)) - // Post-test: Now that we’ve seen the DETACHING status, allow the contributor `detach` call to complete - contributorDetachOperation.complete(behavior: .success) + // Post-test: Now that we’ve seen the DETACHING status, allow the channel `detach` call to complete + channelDetachOperation.complete(behavior: .success) } - // @spec CHA-RL2f - // @spec CHA-RL2g + // @spec CHA-RL2k + // @spec CHA-RL2k1 @Test - func detach_detachesAllContributors_andWhenTheyAllDetachSuccessfully_transitionsToDetached() async throws { - // Given: A DefaultRoomLifecycleManager, all of whose contributors’ calls to `detach` succeed - let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .success) } - let manager = createManager(contributors: contributors) + func detach_detachesChannel_andWhenItDetachesSuccessfully_transitionsToDetached() async throws { + // Given: A DefaultRoomLifecycleManager, whose channel's call to `detach` succeeds + let channel = createChannel(detachBehavior: .success) + let manager = createManager( + channel: channel + ) + + // Double-check the initial value of this flag, so that we can verify it gets set per CHA-RL2k1 + try #require(!manager.testsOnly_isExplicitlyDetached) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let detachedStatusChange = statusChangeSubscription.first { $0.current == .detached } + async let detachedStatusChange = statusChangeSubscription.first { $0.current.isDetached } // When: `performDetachOperation()` is called on the lifecycle manager try await manager.performDetachOperation() - // Then: It calls `detach` on all the contributors, the room detach operation succeeds, it emits a status change to DETACHED, and its current status is DETACHED - for contributor in contributors { - #expect(contributor.mockChannel.detachCallCount > 0) - } + // Then: It calls `detach` on the channel, the room detach operation succeeds, it emits a status change to DETACHED, its current status is DETACHED, and it sets the isExplicitlyDetached flag to true + #expect(channel.detachCallCount > 0) _ = try #require(await detachedStatusChange, "Expected status change to DETACHED") - #expect(manager.roomStatus == .detached) + try #require(manager.roomStatus.isDetached) + + #expect(manager.testsOnly_isExplicitlyDetached) } - // @spec CHA-RL2h1 + // @spec CHA-RL2k2 + // @spec CHA-RL2k3 @Test - func detach_whenAContributorFailsToDetachAndEntersFailed_detachesRemainingContributorsAndTransitionsToFailed() async throws { - // Given: A DefaultRoomLifecycleManager, which has 4 contributors: - // - // 0: calling `detach` succeeds - // 1: calling `detach` fails, causing that contributor to subsequently be in the FAILED state - // 2: calling `detach` fails, causing that contributor to subsequently be in the FAILED state - // 3: calling `detach` succeeds - let contributor1DetachError = ARTErrorInfo(domain: "SomeDomain", code: 123) - let contributor2DetachError = ARTErrorInfo(domain: "SomeDomain", code: 456) - - let contributors = [ - // Features arbitrarily chosen, just need to be distinct in order to make assertions about errors later - createContributor(feature: .messages, detachBehavior: .success), - createContributor(feature: .presence, detachBehavior: .completeAndChangeState(.failure(contributor1DetachError), newState: .failed)), - createContributor(feature: .reactions, detachBehavior: .completeAndChangeState(.failure(contributor2DetachError), newState: .failed)), - createContributor(feature: .typing, detachBehavior: .success), - ] - - let manager = createManager(contributors: contributors) + func detach_whenChannelFailsToDetach() async throws { + // Given: A DefaultRoomLifecycleManager, whose channel's call to `detach` fails causing it to enter the FAILED state (arbitrarily chosen) + let channelDetachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let channel = createChannel( + detachBehavior: .completeAndChangeState(.failure(channelDetachError), newState: .failed) + ) + + let manager = createManager(channel: channel) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let maybeFailedStatusChange = statusChangeSubscription.failedElements().first { _ in true } // When: `performDetachOperation()` is called on the lifecycle manager - let maybeRoomDetachError: Error? + var roomDetachError: ARTErrorInfo? do { try await manager.performDetachOperation() - maybeRoomDetachError = nil } catch { - maybeRoomDetachError = error - } - - // Then: It: - // - calls `detach` on all of the contributors - // - emits a status change to FAILED and the call to `performDetachOperation()` fails; the error associated with the status change and the `performDetachOperation()` has the *DetachmentFailed code corresponding to contributor 1’s feature, and its `cause` is the error thrown by contributor 1’s `detach()` call (contributor 1 because it’s the "first feature to fail" as the spec says) - for contributor in contributors { - #expect(contributor.mockChannel.detachCallCount > 0) + roomDetachError = error.toARTErrorInfo() } + // Then: + // 1. the room status transitions to the same state as the channel entered (i.e. FAILED in this example), with the status change’s `error` equal to the error thrown by the channel `detach` call + // 2. the manager’s `error` is set to this same error + // 3. the room detach operation fails with this same error let failedStatusChange = try #require(await maybeFailedStatusChange) - for maybeError in [maybeRoomDetachError, failedStatusChange.error] { - #expect(isChatError(maybeError, withCodeAndStatusCode: .fixedStatusCode(.presenceDetachmentFailed), cause: contributor1DetachError)) - } - } - - // @specUntested CHA-RL2h2 - I was unable to find a way to test this spec point in an environment in which concurrency is being used; there is no obvious moment at which to stop observing the emitted status changes in order to be sure that FAILED has not been emitted twice. + #expect(manager.roomStatus.isFailed) - // @spec CHA-RL2h3 - @Test - func detach_whenAContributorFailsToDetachAndEntersANonFailedState_pausesAWhileThenRetriesDetach() async throws { - // Given: A DefaultRoomLifecycleManager, with a contributor for whom: - // - // - the first two times `detach` is called, it throws an error, leaving it in the ATTACHED state - // - the third time `detach` is called, it succeeds - let detachImpl = { @Sendable (callCount: Int) async -> MockRealtimeChannel.AttachOrDetachBehavior in - if callCount < 3 { - return .failure(ARTErrorInfo(domain: "SomeDomain", code: 123)) // exact error is unimportant - } - return .success + for error in [failedStatusChange.error, manager.roomStatus.error, roomDetachError] { + #expect(error === channelDetachError) } - let contributor = createContributor(initialState: .attached, detachBehavior: .fromFunction(detachImpl)) - let clock = MockSimpleClock() - - let manager = createManager(contributors: [contributor], clock: clock) - - let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let asyncLetStatusChanges = Array(statusChangeSubscription.prefix(2)) - - // When: `performDetachOperation()` is called on the manager - try await manager.performDetachOperation() - - // Then: It attempts to detach the channel 3 times, waiting 250ms between each attempt, the room transitions from DETACHING to DETACHED with no status updates in between, and the call to `performDetachOperation()` succeeds - #expect(contributor.mockChannel.detachCallCount == 3) - - // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" - #expect(clock.sleepCallArguments == Array(repeating: 0.25, count: 2)) - - #expect(await asyncLetStatusChanges.map(\.current) == [.detaching, .detached]) } // MARK: - RELEASE operation @@ -721,28 +459,28 @@ struct DefaultRoomLifecycleManagerTests { @Test func release_whenAlreadyReleased() async { // Given: A DefaultRoomLifecycleManager in the RELEASED status - let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released, contributors: [contributor]) + let channel = createChannel() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released, channel: channel) // When: `performReleaseOperation()` is called on the lifecycle manager await manager.performReleaseOperation() - // Then: The room release operation succeeds, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) - #expect(contributor.mockChannel.detachCallCount == 0) + // Then: The room release operation succeeds, and no attempt is made to detach the channel (which we’ll consider as satisfying the spec’s requirement that a "no-op" happen) + #expect(channel.detachCallCount == 0) } @Test( arguments: [ // @spec CHA-RL3b - .detached, + .detached(error: nil), // @spec CHA-RL3j .initialized, - ] as[DefaultRoomLifecycleManager.Status] + ] as[RoomStatus] ) - func release_whenDetachedOrInitialized(status: DefaultRoomLifecycleManager.Status) async throws { + func release_whenDetachedOrInitialized(status: RoomStatus) async throws { // Given: A DefaultRoomLifecycleManager in the DETACHED or INITIALIZED status - let contributor = createContributor() - let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: status, contributors: [contributor]) + let channel = createChannel() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: status, channel: channel) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -750,74 +488,70 @@ struct DefaultRoomLifecycleManagerTests { // When: `performReleaseOperation()` is called on the lifecycle manager await manager.performReleaseOperation() - // Then: The room release operation succeeds, the room transitions to RELEASED, and no attempt is made to detach a contributor (which we’ll consider as satisfying the spec’s requirement that the transition be "immediate") + // Then: The room release operation succeeds, the room transitions to RELEASED, and no attempt is made to detach the channel (which we’ll consider as satisfying the spec’s requirement that the transition be "immediate") #expect(try #require(await statusChange).current == .released) #expect(manager.roomStatus == .released) - #expect(contributor.mockChannel.detachCallCount == 0) + #expect(channel.detachCallCount == 0) } - // @spec CHA-RL3c + // @spec CHA-RL3k @Test - func release_whenReleasing() async throws { - // Given: A DefaultRoomLifecycleManager with a RELEASE lifecycle operation in progress, and hence in the RELEASING status - let contributorDetachOperation = SignallableChannelOperation() - let contributor = createContributor( - // This allows us to prolong the execution of the RELEASE triggered in (1) - detachBehavior: contributorDetachOperation.behavior - ) + func release_ifOtherOperationInProgress_waitsForItToComplete() async throws { + // Given: A DefaultRoomLifecycleManager with an ATTACH lifecycle operation in progress (the fact that it is an ATTACH is not important; it is just an operation whose execution it is easy to prolong and subsequently complete, which is helpful for this test) + let channelAttachOperation = SignallableChannelOperation() let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached, // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that the first RELEASE gets as far as CHA-RL3l - contributors: [contributor] + forTestingWhatHappensWhenCurrentlyIn: .detached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n, and non-ATTACHED so that we get as far as CHA-RL1e + channel: createChannel( + // This allows us to prolong the execution of the ATTACH triggered in (1) + attachBehavior: channelAttachOperation.behavior, + // Arbitrary, allows the RELEASE to eventually complete + detachBehavior: .success + ) ) - let firstReleaseOperationID = UUID() - let secondReleaseOperationID = UUID() + let attachOperationID = UUID() + let releaseOperationID = UUID() let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - // Wait for the manager to enter RELEASING; this our sign that the DETACH operation triggered in (1) has started - async let releasingStatusChange = statusChangeSubscription.first { $0.current == .releasing } + // Wait for the manager to enter ATTACHING; this our sign that the ATTACH operation triggered in (1) has started + async let attachingStatusChange = statusChangeSubscription.attachingElements().first { _ in true } - // (1) This is the "RELEASE lifecycle operation in progress" mentioned in Given - async let _ = manager.performReleaseOperation(testsOnly_forcingOperationID: firstReleaseOperationID) - _ = await releasingStatusChange + // (1) This is the "ATTACH lifecycle operation in progress" mentioned in Given + async let _ = manager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + _ = await attachingStatusChange let operationWaitEventSubscription = manager.testsOnly_subscribeToOperationWaitEvents() - async let secondReleaseWaitingForFirstReleaseEvent = operationWaitEventSubscription.first { operationWaitEvent in - operationWaitEvent == .init(waitingOperationID: secondReleaseOperationID, waitedOperationID: firstReleaseOperationID) + async let releaseWaitingForAttachEvent = operationWaitEventSubscription.first { operationWaitEvent in + operationWaitEvent == .init(waitingOperationID: releaseOperationID, waitedOperationID: attachOperationID) } // When: `performReleaseOperation()` is called on the lifecycle manager - async let secondReleaseResult: Void = manager.performReleaseOperation(testsOnly_forcingOperationID: secondReleaseOperationID) + async let releaseResult: Void = manager.performReleaseOperation(testsOnly_forcingOperationID: releaseOperationID) // Then: - // - the manager informs us that the second RELEASE operation is waiting for first RELEASE operation to complete - // - when the first RELEASE completes, the second RELEASE operation also completes - // - the second RELEASE operation does not perform any side-effects (which we check here by confirming that the CHA-RL3d contributor detach only happened once, i.e. was only performed by the first RELEASE operation) + // - the manager informs us that the RELEASE operation is waiting for the ATTACH operation to complete + // - when the ATTACH completes, the RELEASE operation proceeds (which we check here by verifying that it eventually completes) — note that (as far as I can tell) there is no way to test that the RELEASE operation would have proceeded _only if_ the ATTACH had completed; the best we can do is allow the manager to tell us that that this is indeed what it’s doing (which is what we check for in the previous bullet) - _ = try #require(await secondReleaseWaitingForFirstReleaseEvent) + _ = try #require(await releaseWaitingForAttachEvent) - // Allow the first RELEASE to complete - contributorDetachOperation.complete(behavior: .success) + // Allow the ATTACH to complete + channelAttachOperation.complete(behavior: .success /* arbitrary */ ) - // Check that the second RELEASE completes - await secondReleaseResult - - #expect(contributor.mockChannel.detachCallCount == 1) + // Check that RELEASE completes + await releaseResult } - // @spec CHA-RL3l + // @spec CHA-RL3m @Test func release_transitionsToReleasing() async throws { - // Given: A DefaultRoomLifecycleManager, with a contributor on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to RELEASED, so that we can assert its current status as being RELEASING) - let contributorDetachOperation = SignallableChannelOperation() + // Given: A DefaultRoomLifecycleManager, with a channel on whom calling `detach()` will not complete until after the "Then" part of this test (the motivation for this is to suppress the room from transitioning to RELEASED, so that we can assert its current status as being RELEASING) + let channelDetachOperation = SignallableChannelOperation() - let contributor = createContributor(detachBehavior: contributorDetachOperation.behavior) + let channel = createChannel(detachBehavior: channelDetachOperation.behavior) let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached, // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3l - // We set a transient disconnect timeout, just so we can check that it gets cleared, as the spec point specifies - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [contributor.id], - contributors: [contributor] + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n + channel: channel ) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) async let statusChange = statusChangeSubscription.first { _ in true } @@ -825,33 +559,24 @@ struct DefaultRoomLifecycleManagerTests { // When: `performReleaseOperation()` is called on the lifecycle manager async let _ = await manager.performReleaseOperation() - // Then: It emits a status change to RELEASING, its current status is RELEASING, and it clears transient disconnect timeouts + // Then: It emits a status change to RELEASING, and its current status is RELEASING #expect(try #require(await statusChange).current == .releasing) #expect(manager.roomStatus == .releasing) - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) - // Post-test: Now that we’ve seen the RELEASING status, allow the contributor `detach` call to complete - contributorDetachOperation.complete(behavior: .success) + // Post-test: Now that we’ve seen the RELEASING status, allow the channel `detach` call to complete + channelDetachOperation.complete(behavior: .success) } - // @spec CHA-RL3d - // @specOneOf(1/2) CHA-RL3e - // @spec CHA-RL3g + // @spec CHA-RL3n1 + // @specOneOf(1/3) CHA-RL3o - Tests the case where the operation completes without any detach attempt @Test - func release_detachesAllNonFailedContributors() async throws { - // Given: A DefaultRoomLifecycleManager, with the following contributors: - // - two in a non-FAILED state, and on whom calling `detach()` succeeds - // - one in the FAILED state - let contributors = [ - createContributor(initialState: .attached /* arbitrary non-FAILED */, detachBehavior: .success), - // We put the one that will be skipped in the middle, to verify that the subsequent contributors don’t get skipped - createContributor(initialState: .failed, detachBehavior: .failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ )), - createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .success), - ] + func release_whenChannelIsFailed() async throws { + // Given: A DefaultRoomLifecycleManager, with a channel in the FAILED state + let channel = createChannel(initialState: .failed) let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached, // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3l - contributors: contributors + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n + channel: channel ) let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) @@ -861,26 +586,52 @@ struct DefaultRoomLifecycleManagerTests { await manager.performReleaseOperation() // Then: - // - it calls `detach()` on the non-FAILED contributors - // - it does not call `detach()` on the FAILED contributor + // - it does not call `detach()` on the channel // - the room transitions to RELEASED // - the call to `performReleaseOperation()` completes - for nonFailedContributor in [contributors[0], contributors[2]] { - #expect(nonFailedContributor.mockChannel.detachCallCount == 1) - } + #expect(channel.detachCallCount == 0) + + _ = await releasedStatusChange + + #expect(manager.roomStatus == .released) + } + + // @specOneOf(1/2) CHA-RL3n2 - Tests the case where there's a single detach attempt + // @specOneOf(2/3) CHA-RL3o - Tests the case where the operation completes after single detach attempt + @Test + func release_whenChannelIsNotFailed() async throws { + // Given: A DefaultRoomLifecycleManager, with a channel in a non-FAILED state and on whom calling `detach()` succeeds + let channel = createChannel(initialState: .attached /* arbitrary non-FAILED */, detachBehavior: .success) + + let manager = createManager( + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n + channel: channel + ) + + let statusChangeSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) + async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() - #expect(contributors[1].mockChannel.detachCallCount == 0) + // Then: + // - it calls `detach()` on the channel + // - the room transitions to RELEASED + // - the call to `performReleaseOperation()` completes + #expect(channel.detachCallCount == 1) _ = await releasedStatusChange #expect(manager.roomStatus == .released) } - // @spec CHA-RL3f + // @spec CHA-RL3n4 + // @specOneOf(2/2) CHA-RL3n2 - Tests the case where there are multiple detach attempts + // @specOneOf(3/3) CHA-RL3o - Tests the case where the operation completes after multiple detach attempts @Test - func release_whenDetachFails_ifContributorIsNotFailed_retriesAfterPause() async { - // Given: A DefaultRoomLifecycleManager, with a contributor for which: - // - the first two times that `detach()` is called, it fails, leaving the contributor in a non-FAILED state + func release_whenDetachFails_ifChannelIsNotFailed_retriesAfterPause() async { + // Given: A DefaultRoomLifecycleManager, with a channel for which: + // - the first two times that `detach()` is called, it fails, leaving the channel in a non-FAILED state // - the third time that `detach()` is called, it succeeds let detachImpl = { @Sendable (callCount: Int) async -> MockRealtimeChannel.AttachOrDetachBehavior in if callCount < 3 { @@ -888,13 +639,13 @@ struct DefaultRoomLifecycleManagerTests { } return .success } - let contributor = createContributor(detachBehavior: .fromFunction(detachImpl)) + let channel = createChannel(detachBehavior: .fromFunction(detachImpl)) let clock = MockSimpleClock() let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached, // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3l - contributors: [contributor], + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n + channel: channel, clock: clock ) @@ -902,23 +653,23 @@ struct DefaultRoomLifecycleManagerTests { await manager.performReleaseOperation() // It: calls `detach()` on the channel 3 times, with a 0.25s pause between each attempt, and the call to `performReleaseOperation` completes - #expect(contributor.mockChannel.detachCallCount == 3) + #expect(channel.detachCallCount == 3) // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" #expect(clock.sleepCallArguments == Array(repeating: 0.25, count: 2)) } - // @specOneOf(2/2) CHA-RL3e - Tests that this spec point suppresses CHA-RL3f retries + // @spec CHA-RL3n3 @Test - func release_whenDetachFails_ifContributorIsFailed_doesNotRetry() async { - // Given: A DefaultRoomLifecycleManager, with a contributor for which, when `detach()` is called, it fails, causing the contributor to enter the FAILED state - let contributor = createContributor(detachBehavior: .completeAndChangeState(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ), newState: .failed)) + func release_whenDetachFails_ifChannelIsFailed_doesNotRetry() async { + // Given: A DefaultRoomLifecycleManager, with a channel for which, when `detach()` is called, it fails, causing the channel to enter the FAILED state + let channel = createChannel(detachBehavior: .completeAndChangeState(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ), newState: .failed)) let clock = MockSimpleClock() let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached, // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3l - contributors: [contributor], + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil), // arbitrary non-{RELEASED, DETACHED, INITIALIZED} status, so that we get as far as CHA-RL3n + channel: channel, clock: clock ) @@ -929,1152 +680,261 @@ struct DefaultRoomLifecycleManagerTests { await manager.performReleaseOperation() // Then: - // - it calls `detach()` precisely once on the contributor (that is, it does not retry) - // - it waits 0.25s (TODO: confirm my interpretation of CHA-RL3f, which is that the wait still happens, but is not followed by a retry; have asked in https://github.com/ably/specification/pull/200/files#r1765372854) + // - it calls `detach()` precisely once on the channel (that is, it does not retry) + // - it does not perform a pause // - the room transitions to RELEASED // - the call to `performReleaseOperation()` completes - #expect(contributor.mockChannel.detachCallCount == 1) + #expect(channel.detachCallCount == 1) - // We use "did it call clock.sleep(…)?" as a good-enough proxy for the question "did it wait for the right amount of time at the right moment?" - #expect(clock.sleepCallArguments == [0.25]) + #expect(clock.sleepCallArguments.isEmpty) _ = await releasedStatusChange #expect(manager.roomStatus == .released) } - // MARK: - RETRY operation - - // @specOneOf(1/2) CHA-RL5a - @Test - func retry_detachesAllContributorsExceptForTriggering() async throws { - // Given: A RoomLifecycleManager - let contributors = [ - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - - // Not related to this test, just so that the subsequent CHA-RL5d wait-for-ATTACHED completes - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - ) - ), - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - detachBehavior: .success - ), - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - detachBehavior: .success - ), - ] - - let manager = createManager(contributors: contributors) - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: The manager calls `detach` on all contributors except that which triggered the RETRY (I’m using this, combined with the CHA-RL5b and CHA-RL5c tests, as a good-enough way of checking that a CHA-RL2f detachment cycle happened) - #expect(contributors[0].mockChannel.detachCallCount == 0) - #expect(contributors[1].mockChannel.detachCallCount == 1) - #expect(contributors[2].mockChannel.detachCallCount == 1) - } + // MARK: - Handling channel state events - // @specOneOf(2/2) CHA-RL5a - Verifies that, when the RETRY operation triggers a CHA-RL2f detachment cycle, the retry behaviour of CHA-RL2h3 is performed. + // @specOneOf(1/3) CHA-RL11a + // @spec CHA-RL11b @Test - func retry_ifDetachFailsDueToNonFailedChannelState_retries() async throws { - // Given: A RoomLifecycleManager, whose contributor at index 1 has an implementation of `detach()` which: - // - throws an error and transitions the contributor to a non-FAILED state the first time it’s called - // - succeeds the second time it’s called - let detachImpl = { @Sendable (callCount: Int) -> MockRealtimeChannel.AttachOrDetachBehavior in - if callCount == 1 { - return .completeAndChangeState(.failure(.createUnknownError() /* arbitrary */ ), newState: .attached /* this is what CHA-RL2h3 mentions as being the only non-FAILED state that would happen in this situation in reality */ ) - } else { - return .success - } - } + func channelStateChange_withOperationInProgress() async throws { + // Given: A DefaultRoomLifecycleManager, with a room lifecycle operation in progress + let channelAttachBehavior = SignallableChannelOperation() + let channel = createChannel( + attachBehavior: channelAttachBehavior.behavior + ) + let manager = createManager( + channel: channel + ) - let contributors = [ - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - - // Not related to this test, just so that the subsequent CHA-RL5d wait-for-ATTACHED completes - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - ) - ), - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - detachBehavior: .fromFunction(detachImpl) - ), - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - detachBehavior: .success - ), - ] + let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) + async let _ = manager.performAttachOperation() + // Wait for the transition to ATTACHED, so that we know the manager considers the ATTACH operation to be in progress + _ = await roomStatusSubscription.attachingElements().first { @Sendable _ in true } - let clock = MockSimpleClock() + let originalRoomStatus = manager.roomStatus - let manager = createManager(contributors: contributors, clock: clock) + // When: The channel emits a state change + let channelStateChange = ARTChannelStateChange( + current: .detaching, // arbitrary, just different to the ATTACHING we started off in + previous: .attached, // arbitrary + event: .detaching, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by a contributor that isn’t that at index 1 - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + channel.emitEvent(channelStateChange) - // Then: The manager calls `detach` in sequence on all contributors except that which triggered the RETRY, stopping upon one of these `detach` calls throwing an error, then sleeps for 250ms, then performs these `detach` calls again + // Then: The manager does not change room status + #expect(manager.roomStatus == originalRoomStatus) - // (Note that for simplicity of the test I’m not actually making assertions about the sequence in which events happen here) - #expect(contributors[0].mockChannel.detachCallCount == 0) - #expect(contributors[1].mockChannel.detachCallCount == 2) - #expect(contributors[2].mockChannel.detachCallCount == 1) - #expect(clock.sleepCallArguments == [0.25]) + // Post-test: Allow the room lifecycle operation to complete + channelAttachBehavior.complete(behavior: .success /* arbitrary */ ) } - // @spec CHA-RL5c + // @specOneOf(2/3) CHA-RL11a + // @spec CHA-RL11c @Test - func retry_ifDetachFailsDueToFailedChannelState_transitionsToFailed() async throws { - // Given: A RoomLifecycleManager, whose contributor at index 1 has an implementation of `detach()` which throws an error and transitions the contributor to the FAILED state - let contributor1DetachError = ARTErrorInfo.createUnknownError() // arbitrary - - let contributors = [ - // Features arbitrarily chosen, just need to be distinct in order to make assertions about errors later - createContributor(feature: .messages), - createContributor( - feature: .presence, - detachBehavior: .completeAndChangeState(.failure(contributor1DetachError), newState: .failed) - ), - createContributor( - feature: .typing, - detachBehavior: .success - ), - ] - - let manager = createManager(contributors: contributors) + func channelStateChange_withNoOperationInProgress() async throws { + // Given: A DefaultRoomLifecycleManager, with no room lifecycle operation in progress + let channel = createChannel() + let manager = createManager(channel: channel) let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeFailedStatusChange = roomStatusSubscription.failedElements().first { _ in true } - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the contributor at index 0 - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: - // 1. (This is basically just testing the behaviour of CHA-RL2h1 again, plus the fact that we don’t try to detach the channel that triggered the RETRY) The manager calls `detach` in sequence on all contributors except that which triggered the RETRY, and enters the FAILED status, the associated error for this status being the *DetachmentFailed code corresponding to contributor 1’s feature, and its `cause` is contributor 1’s `errorReason` - // 2. the RETRY operation is terminated (which we confirm here by checking that it has not attempted to attach any of the contributors, which shows us that CHA-RL5f didn’t happen) - #expect(contributors[0].mockChannel.detachCallCount == 0) - #expect(contributors[1].mockChannel.detachCallCount == 1) - #expect(contributors[2].mockChannel.detachCallCount == 1) - let roomStatus = manager.roomStatus - let failedStatusChange = try #require(await maybeFailedStatusChange) + // When: The channel emits a state change + let channelStateChange = ARTChannelStateChange( + current: .attaching, // arbitrary + previous: .attached, // arbitrary + event: .attaching, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) - #expect(roomStatus.isFailed) - for error in [roomStatus.error, failedStatusChange.error] { - #expect(isChatError(error, withCodeAndStatusCode: .fixedStatusCode(.presenceDetachmentFailed), cause: contributor1DetachError)) - } + channel.emitEvent(channelStateChange) - for contributor in contributors { - #expect(contributor.mockChannel.attachCallCount == 0) + // Then: The manager changes room status to match that informed by the channel event + let roomStatusChange = try #require(await roomStatusSubscription.first { @Sendable _ in true }) + for roomStatus in [roomStatusChange.current, manager.roomStatus] { + #expect(roomStatus == RoomStatus.attaching(error: channelStateChange.reason)) } } - // swiftlint:disable type_name - /// Describes how a contributor will end up in one of the states that CHA-RL5d expects it to end up in. - enum CHA_RL5d_JourneyToState { - // swiftlint:enable type_name - case transitionsToState - case alreadyInState - } - - // @spec CHA-RL5e - @Test(arguments: [CHA_RL5d_JourneyToState.transitionsToState, .alreadyInState]) - func retry_whenTriggeringContributorEndsUpFailed_terminatesOperation(journeyToState: CHA_RL5d_JourneyToState) async throws { - // Given: A RoomLifecycleManager, with a contributor that: - // - (if test argument journeyToState is .transitionsToState) emits a state change to FAILED after subscribeToState() is called on it - // - (if test argument journeyToState is .alreadyInState) is already FAILED (it would be more realistic for it to become FAILED _during_ the CHA-RL5a detachment cycle, but that would be more awkward to mock, so this will do) - let contributorFailedReason = ARTErrorInfo.createUnknownError() // arbitrary - - let contributors = [ - createContributor( - initialState: ({ - switch journeyToState { - case .alreadyInState: - .failed - case .transitionsToState: - .suspended // arbitrary - } - })(), - initialErrorReason: ({ - switch journeyToState { - case .alreadyInState: - contributorFailedReason - case .transitionsToState: - nil - } - })(), - - detachBehavior: .success, // Not related to this test, just so that the CHA-RL5a detachment cycle completes - - subscribeToStateBehavior: ({ - switch journeyToState { - case .alreadyInState: - return nil - case .transitionsToState: - let contributorFailedStateChange = ARTChannelStateChange( - current: .failed, - previous: .attaching, // arbitrary - event: .failed, - reason: contributorFailedReason - ) - - return .addSubscriptionAndEmitStateChange(contributorFailedStateChange) - } - })() - ), - createContributor( - detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes - ), - ] - + // @specOneOf(3/3) CHA-RL11a - Tests that only state change events can cause a room status change + @Test + func channel_nonStateChangeEvent_doesNotCauseRoomStatusChange() async throws { + // Given: A DefaultRoomLifecycleManager, with no room lifecycle operation in progress + let channel = createChannel() let manager = createManager( - contributors: contributors + forTestingWhatHappensWhenCurrentlyIn: .detached(error: nil), // arbitrary value different to the ATTACHED in the UPDATE event emitted below + channel: channel ) - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeFailedStatusChange = roomStatusSubscription.failedElements().first { _ in true } - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the aforementioned contributor - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) + let initialRoomStatus = manager.roomStatus - // Then: - // 1. The room transitions to (and remains in) FAILED, and its error is: - // - (if test argument journeyToState is .transitionsToState) that associated with the contributor state change - // - (if test argument journeyToState is .alreadyInState) the channel’s `errorReason` - // 2. The RETRY operation terminates (to confirm this, we check that the room has not attempted to attach any of the contributors, indicating that we have not proceeded to the CHA-RL5f attachment cycle) + let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - let failedStatusChange = try #require(await maybeFailedStatusChange) + // When: The channel emits an UPDATE event (i.e. not a state change) + let channelUpdateEvent = ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, // arbitrary + event: .update, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false + ) - let roomStatus = manager.roomStatus - #expect(roomStatus.isFailed) + channel.emitEvent(channelUpdateEvent) - for error in [failedStatusChange.error, roomStatus.error] { - #expect(error === contributorFailedReason) - } + // Then: The manager does not update the room status + #expect(manager.roomStatus == initialRoomStatus) - for contributor in contributors { - #expect(contributor.mockChannel.attachCallCount == 0) - } + roomStatusSubscription.testsOnly_finish() + let emittedRoomStatusEvents = await Array(roomStatusSubscription) + #expect(emittedRoomStatusEvents.isEmpty) } - // @specOneOf(1/3) CHA-RL5f - Tests the first part of CHA-RL5f, namely the transition to ATTACHING once the triggering channel becomes ATTACHED. - @Test(arguments: [CHA_RL5d_JourneyToState.transitionsToState, .alreadyInState]) - func retry_whenTriggeringContributorEndsUpAttached_proceedsToAttachmentCycle(journeyToState: CHA_RL5d_JourneyToState) async throws { - // Given: A RoomLifecycleManager, with a contributor that: - // - (if test argument journeyToState is .transitionsToState) emits a state change to ATTACHED after subscribeToState() is called on it - // - (if test argument journeyToState is .alreadyInState) is already ATTACHED (it would be more realistic for it to become ATTACHED _during_ the CHA-RL5a detachment cycle, but that would be more awkward to mock, so this will do) - let contributors = [ - createContributor( - initialState: ({ - switch journeyToState { - case .alreadyInState: - .attached - case .transitionsToState: - .suspended // arbitrary - } - })(), - - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - - subscribeToStateBehavior: ({ - switch journeyToState { - case .alreadyInState: - return nil - case .transitionsToState: - let contributorAttachedStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - - return .addSubscriptionAndEmitStateChange(contributorAttachedStateChange) - } - })() + // @spec CHA-RL12a + // @spec CHA-RL12b + @Test(arguments: [ + ( + hasAttachedOnce: true, + isExplicitlyDetached: false, + + // State change to ATTACHED, resumed false + channelEvent: ARTChannelStateChange( + current: .attached, + previous: .attaching, // arbitrary + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false ), - createContributor( - attachBehavior: .success, // Not related to this test, just so that the subsequent CHA-RL5f attachment cycle completes - detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes + + expectDiscontinuity: true + ), + ( + hasAttachedOnce: true, + isExplicitlyDetached: false, + + // UPDATE event, resumed false + channelEvent: ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, + event: .update, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false ), - ] - let manager = createManager( - contributors: contributors - ) + expectDiscontinuity: true + ), + ( + hasAttachedOnce: true, + isExplicitlyDetached: false, + + // UPDATE event, resumed true (so ineligible for a discontinuity) - not sure if this happens in reality, but RTL12 suggests it's possible + channelEvent: ARTChannelStateChange( + current: .attached, // arbitrary + previous: .attached, + event: .update, + reason: nil, + resumed: true + ), - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeAttachingStatusChange = roomStatusSubscription.attachingElements().first { _ in true } - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager, triggered by the aforementioned contributor - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: The room transitions to ATTACHING, and the RETRY operation continues (to confirm this, we check that the room has attempted to attach all of the contributors, indicating that we have proceeded to the CHA-RL5f attachment cycle) - let attachingStatusChange = try #require(await maybeAttachingStatusChange) - // The spec doesn’t mention an error for the ATTACHING status change, so I’ll assume there shouldn’t be one - #expect(attachingStatusChange.error == nil) - - for contributor in contributors { - #expect(contributor.mockChannel.attachCallCount == 1) - } - } - - // @specOneOf(2/3) CHA-RL5f - Tests the case where the CHA-RL1e attachment cycle succeeds - @Test - func retry_whenAttachmentCycleSucceeds() async throws { - // Given: A RoomLifecycleManager, with contributors on all of whom calling `attach()` succeeds (i.e. conditions for a CHA-RL1e attachment cycle to succeed) - let contributors = [ - createContributor( - attachBehavior: .success, - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - ) // Not related to this test, just so that the CHA-RL5d wait completes - ), - createContributor( - attachBehavior: .success, - detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes - ), - createContributor( - attachBehavior: .success, - detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes - ), - ] - - let manager = createManager( - contributors: contributors - ) - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let attachedStatusChange = roomStatusSubscription.first { $0.current == .attached } - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: The room performs a CHA-RL1e attachment cycle (we sufficiently satisfy ourselves of this fact by checking that it’s attempted to attach all of the channels), transitions to ATTACHED, and the RETRY operation completes - for contributor in contributors { - #expect(contributor.mockChannel.attachCallCount == 1) - } - - _ = try #require(await attachedStatusChange) - #expect(manager.roomStatus == .attached) - } - - // @specOneOf(3/3) CHA-RL5f - Tests the case where the CHA-RL1e attachment cycle fails - @Test - func retry_whenAttachmentCycleFails() async throws { - // Given: A RoomLifecycleManager, with a contributor on whom calling `attach()` fails, putting the contributor into the FAILED state (i.e. conditions for a CHA-RL1e attachment cycle to fail) - let contributorAttachError = ARTErrorInfo.createUnknownError() // arbitrary - - let contributors = [ - createContributor( - attachBehavior: .success, - detachBehavior: .success, // So that the detach performed by CHA-RL1h5’s triggered RUNDOWN succeeds - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - ) // Not related to this test, just so that the CHA-RL5d wait completes - ), - createContributor( - attachBehavior: .completeAndChangeState(.failure(contributorAttachError), newState: .failed), - detachBehavior: .success // Not related to this test, just so that the CHA-RL5a detachment cycle completes - ), - createContributor( - attachBehavior: .success, - detachBehavior: .success // So that the CHA-RL5a detachment cycle completes (not related to this test) and so that the detach performed by CHA-RL1h5’s triggered RUNDOWN succeeds + expectDiscontinuity: false + ), + ( + hasAttachedOnce: true, + isExplicitlyDetached: false, + + // non-(UPDATE or ATTACHED) event (so ineligible for a discontinuity) + channelEvent: ARTChannelStateChange( + current: .attaching, // arbitrary non-(UPDATE or ATTACHED) + previous: .attached, + event: .attaching, + reason: nil, + resumed: false ), - ] - - let manager = createManager( - contributors: contributors - ) - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let failedStatusChange = roomStatusSubscription.failedElements().first { _ in true } - - let managerStatusSubscription = manager.testsOnly_onStatusChange() - - // When: `performRetryOperation(triggeredByContributor:errorForSuspendedStatus:)` is called on the manager - await manager.performRetryOperation(triggeredByContributor: contributors[0], errorForSuspendedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: The room performs a CHA-RL1e attachment cycle (we sufficiently satisfy ourselves of this fact by checking that it’s attempted to attach all of the channels up to and including the one for which attachment fails, and subsequently detached all channels except for the FAILED one), transitions to FAILED, and the RETRY operation completes - - // We first wait for CHA-RLh5’s triggered RUNDOWN operation to complete, so that we can make assertions about its side effects - let rundownOperationTask = try #require(await managerStatusSubscription.compactMap { managerStatusChange in - if case let .failedAwaitingStartOfRundownOperation(rundownOperationTask, _) = managerStatusChange.current { - rundownOperationTask - } else { - nil - } - } - .first { @Sendable _ in true }) - - _ = await rundownOperationTask.value - - #expect(contributors[0].mockChannel.attachCallCount == 1) - #expect(contributors[1].mockChannel.attachCallCount == 1) - #expect(contributors[2].mockChannel.attachCallCount == 0) - - #expect(contributors[0].mockChannel.detachCallCount == 1) // from CHA-RL1h5’s triggered RUNDOWN - #expect(contributors[1].mockChannel.detachCallCount == 1) // from CHA-RL5a detachment cycle - #expect(contributors[2].mockChannel.detachCallCount == 2) // from CHA-RL5a detachment cycle and CHA-RL1h5’s triggered RUNDOWN - _ = try #require(await failedStatusChange) - #expect(manager.roomStatus.isFailed) - } - - // MARK: - RUNDOWN operation - - // @specOneOf(2/2) CHA-RL1h5 - Tests the behaviour of the RUNDOWN operation - see TODO on `performRundownOperation` for my interpretation of CHA-RL1h5, in which I introduce RUNDOWN - @Test - func rundown_detachesAllNonFailedChannels() async throws { - // Given: A room with the following contributors, in the following order: - // - // 0. a channel in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) - // 1. a channel in the FAILED state - // 2. a channel in the INITIALIZED state (another arbitrarily-chosen state that is not FAILED) - // - // for which, when `detach` is called on contributors 0 and 2 (i.e. the non-FAILED contributors), it completes successfully - let contributors = [ - createContributor( - initialState: .attached, - detachBehavior: .success - ), - createContributor( - initialState: .failed + expectDiscontinuity: false + ), + ( + hasAttachedOnce: false, + isExplicitlyDetached: false, + + // State change to ATTACHED, resumed false (i.e. an event eligible for a discontinuity, but which will be excluded because of hasAttachedOnce) + channelEvent: ARTChannelStateChange( + current: .attaching, // arbitrary + previous: .attached, + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false ), - createContributor( - detachBehavior: .success - ), - ] - - let manager = createManager(contributors: contributors) - - // When: `performRundownOperation()` is called on the lifecycle manager - await manager.performRundownOperation(errorForFailedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: - // - // - the lifecycle manager calls `detach` on contributors 0 and 2 - // - the lifecycle manager does not call `detach` on contributor 1 - // - the call to `performRundownOperation()` completes - #expect(contributors[0].mockChannel.detachCallCount == 1) - #expect(contributors[2].mockChannel.detachCallCount == 1) - #expect(contributors[1].mockChannel.detachCallCount == 0) - } - - // @spec CHA-RL1h6 - see TODO on `performRundownOperation` for my interpretation of CHA-RL1h5, in which I introduce RUNDOWN - @Test - func rundown_ifADetachFailsItIsRetriedUntilSuccess() async throws { - // Given: A room with a contributor in the ATTACHED state (i.e. an arbitrarily-chosen state that is not FAILED) and for whom calling `detach` will fail on the first attempt and succeed on the second - - let detachResult = { @Sendable (callCount: Int) async -> MockRealtimeChannel.AttachOrDetachBehavior in - if callCount == 1 { - return .failure(.create(withCode: 123, message: "")) - } else { - return .success - } - } - let contributors = [ - createContributor( - detachBehavior: .fromFunction(detachResult) + expectDiscontinuity: false + ), + ( + hasAttachedOnce: true, + isExplicitlyDetached: true, + + // State change to ATTACHED, resumed false (i.e. an event eligible for a discontinuity, but which will be excluded because of isExplicitlyDetached) + channelEvent: ARTChannelStateChange( + current: .attaching, // arbitrary + previous: .attached, + event: .attached, + reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary + resumed: false ), - ] - - let manager = createManager(contributors: contributors) - - // When: `performRundownOperation()` is called on the lifecycle manager - await manager.performRundownOperation(errorForFailedStatus: .createUnknownError() /* arbitrary */ ) - - // Then: the lifecycle manager calls `detach` twice on the contributor (i.e. it retries the failed detach) - #expect(contributors[0].mockChannel.detachCallCount == 2) - } - - // MARK: - Handling contributor UPDATE events - - // @spec CHA-RL4a1 - @Test - func contributorUpdate_withResumedTrue_doesNothing() async throws { - // Given: A DefaultRoomLifecycleManager, which has a contributor for which it has previously received an ATTACHED state change (so that we get through the CHA-RL4a2 check) - let contributor = createContributor() - let manager = createManager(contributors: [contributor]) - - // This is to satisfy "for which it has previously received an ATTACHED state change" - let previousContributorStateChange = ARTChannelStateChange( - // `previous`, `reason`, and `resumed` are arbitrary, but for realism let’s simulate an initial ATTACHED - current: .attached, - previous: .attaching, - event: .attached, - reason: nil, - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: previousContributorStateChange) { - contributor.mockChannel.emitStateChange(previousContributorStateChange) - } - - // When: This contributor emits an UPDATE event with `resumed` flag set to true - let contributorStateChange = ARTChannelStateChange( - current: .attached, // arbitrary - previous: .attached, // arbitrary - event: .update, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: true - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager does not record a pending discontinuity event for this contributor, nor does it call `emitDiscontinuity` on the contributor; this shows us that the actions described in CHA-RL4a3 and CHA-RL4a4 haven’t been performed - #expect(manager.testsOnly_pendingDiscontinuityEvent(for: contributor) == nil) - #expect(contributor.emitDiscontinuityArguments.isEmpty) - } - - // @specPartial CHA-RL4a2 - TODO: I have changed the criteria for deciding whether an ATTACHED status change represents a discontinuity, to be based on whether there was a previous ATTACHED state change instead of whether the `attach()` call has completed; see https://github.com/ably/specification/issues/239 and change this to @spec once we’re aligned with spec again - @Test - func contributorUpdate_withContributorNotPreviouslyAttached_doesNothing() async throws { - // Given: A DefaultRoomLifecycleManager, which has a contributor for which it has not previously received an ATTACHED state change - let contributor = createContributor() - let manager = createManager(contributors: [contributor]) - - // When: This contributor emits an UPDATE event with `resumed` flag set to false (so that we get through the CHA-RL4a1 check) - let contributorStateChange = ARTChannelStateChange( - current: .attached, // arbitrary - previous: .attached, // arbitrary - event: .update, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager does not record a pending discontinuity event for this contributor, nor does it call `emitDiscontinuity` on the contributor; this shows us that the actions described in CHA-RL4a3 and CHA-RL4a4 haven’t been performed - #expect(manager.testsOnly_pendingDiscontinuityEvent(for: contributor) == nil) - #expect(contributor.emitDiscontinuityArguments.isEmpty) - } - - // @specOneOf(1/2) CHA-RL4a3 - @Test - func contributorUpdate_withResumedFalse_withOperationInProgress_recordsPendingDiscontinuityEvent() async throws { - // Given: A DefaultRoomLifecycleManager, with a room lifecycle operation in progress, and with a contributor for which it has previously received an ATTACHED state change - let contributor = createContributor() - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attachingDueToAttachOperation(attachOperationID: UUID()), // case and ID arbitrary, just care that an operation is in progress - contributors: [contributor] - ) - - // This is to satisfy "for which it has previously received an ATTACHED state change" - let previousContributorStateChange = ARTChannelStateChange( - // `previous`, `reason`, and `resumed` are arbitrary, but for realism let’s simulate an initial ATTACHED - current: .attached, - previous: .attaching, - event: .attached, - reason: nil, - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: previousContributorStateChange) { - contributor.mockChannel.emitStateChange(previousContributorStateChange) - } - - // When: This contributor emits an UPDATE event with `resumed` flag set to false - let contributorStateChange = ARTChannelStateChange( - current: .attached, // arbitrary - previous: .attached, // arbitrary - event: .update, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager records a pending discontinuity event for this contributor, and this discontinuity event has error equal to the contributor UPDATE event’s `reason` - let pendingDiscontinuityEvent = try #require(manager.testsOnly_pendingDiscontinuityEvent(for: contributor)) - #expect(pendingDiscontinuityEvent.error === contributorStateChange.reason) - } - - // @specOneOf(2/2) CHA-RL4a3 - tests the “though it must not overwrite any existing discontinuity event” part of the spec point - @Test - func contributorUpdate_withResumedFalse_withOperationInProgress_doesNotOverwriteExistingPendingDiscontinuityEvent() async throws { - // Given: A DefaultRoomLifecycleManager, with a room lifecycle operation in progress, with a contributor for which it has previously received an ATTACHED state change, and with an existing pending discontinuity event for this contributor - let contributor = createContributor() - let existingPendingDiscontinuityEvent = DiscontinuityEvent(error: .createUnknownError()) - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attachingDueToAttachOperation(attachOperationID: UUID()), // case and ID arbitrary, just care that an operation is in progress - forTestingWhatHappensWhenHasPendingDiscontinuityEvents: [ - contributor.id: existingPendingDiscontinuityEvent, - ], - contributors: [contributor] - ) - - // This is to satisfy "for which it has previously received an ATTACHED state change" - let previousContributorStateChange = ARTChannelStateChange( - // `previous`, `reason`, and `resumed` are arbitrary, but for realism let’s simulate an initial ATTACHED - current: .attached, - previous: .attaching, - event: .attached, - reason: nil, - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: previousContributorStateChange) { - contributor.mockChannel.emitStateChange(previousContributorStateChange) - } - - // When: The aforementioned contributor emits an UPDATE event with `resumed` flag set to false - let contributorStateChange = ARTChannelStateChange( - current: .attached, // arbitrary - previous: .attached, // arbitrary - event: .update, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager does not replace the existing pending discontinuity event for this contributor - let pendingDiscontinuityEvent = try #require(manager.testsOnly_pendingDiscontinuityEvent(for: contributor)) - #expect(pendingDiscontinuityEvent == existingPendingDiscontinuityEvent) - } - - // @spec CHA-RL4a4 - @Test - func contributorUpdate_withResumedTrue_withNoOperationInProgress_emitsDiscontinuityEvent() async throws { - // Given: A DefaultRoomLifecycleManager, with no room lifecycle operation in progress, and with a contributor for which it has previously received an ATTACHED state change - let contributor = createContributor() - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // case arbitrary, just care that no operation is in progress - contributors: [contributor] - ) - - // This is to satisfy "for which it has previously received an ATTACHED state change" - let previousContributorStateChange = ARTChannelStateChange( - // `previous`, `reason`, and `resumed` are arbitrary, but for realism let’s simulate an initial ATTACHED - current: .attached, - previous: .attaching, - event: .attached, - reason: nil, - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: previousContributorStateChange) { - contributor.mockChannel.emitStateChange(previousContributorStateChange) - } - - // When: This contributor emits an UPDATE event with `resumed` flag set to false - let contributorStateChange = ARTChannelStateChange( - current: .attached, // arbitrary - previous: .attached, // arbitrary - event: .update, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager calls `emitDiscontinuity` on the contributor, with error equal to the contributor UPDATE event’s `reason` - let emitDiscontinuityArguments = contributor.emitDiscontinuityArguments - try #require(emitDiscontinuityArguments.count == 1) - - let discontinuity = emitDiscontinuityArguments[0] - #expect(discontinuity.error === contributorStateChange.reason) - } - - // @specPartial CHA-RL4b1 - Tests the case where the contributor has been attached previously (TODO: I have changed the criteria for deciding whether an ATTACHED status change represents a discontinuity, to be based on whether there was a previous ATTACHED state change instead of whether the `attach()` call has completed; see https://github.com/ably/specification/issues/239 and change this back to specOneOf(1/2) once we’re aligned with spec again) - @Test - func contributorAttachEvent_withResumeFalse_withOperationInProgress_withContributorAttachedPreviously_recordsPendingDiscontinuityEvent() async throws { - // Given: A DefaultRoomLifecycleManager, with a room lifecycle operation in progress, and which has a contributor for which it has previously received an ATTACHED state change - let contributorDetachOperation = SignallableChannelOperation() - let contributor = createContributor(attachBehavior: .success, detachBehavior: contributorDetachOperation.behavior) - let manager = createManager( - contributors: [contributor] - ) - - // This is to satisfy "for which it has previously received an ATTACHED state change" - let previousContributorStateChange = ARTChannelStateChange( - // `previous`, `reason`, and `resumed` are arbitrary, but for realism let’s simulate an initial ATTACHED - current: .attached, - previous: .attaching, - event: .attached, - reason: nil, - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: previousContributorStateChange) { - contributor.mockChannel.emitStateChange(previousContributorStateChange) - } - - // This is to put the manager into the DETACHING state, to satisfy "with a room lifecycle operation in progress" - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let _ = manager.performDetachOperation() - _ = await roomStatusSubscription.first { @Sendable statusChange in - statusChange.current == .detaching - } - - // When: The aforementioned contributor emits an ATTACHED event with `resumed` flag set to false - let contributorStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager records a pending discontinuity event for this contributor, and this discontinuity event has error equal to the contributor ATTACHED event’s `reason` - let pendingDiscontinuityEvent = try #require(manager.testsOnly_pendingDiscontinuityEvent(for: contributor)) - #expect(pendingDiscontinuityEvent.error === contributorStateChange.reason) - - // Teardown: Allow performDetachOperation() call to complete - contributorDetachOperation.complete(behavior: .success) - } - - // @specPartial CHA-RL4b1 - Tests the case where the contributor has not been attached previously (TODO: I have changed the criteria for deciding whether an ATTACHED status change represents a discontinuity, to be based on whether there was a previous ATTACHED state change instead of whether the `attach()` call has completed; see https://github.com/ably/specification/issues/239 and change this back to specOneOf(2/2) once we’re aligned with spec again) - @Test - func contributorAttachEvent_withResumeFalse_withOperationInProgress_withContributorNotAttachedPreviously_doesNotRecordPendingDiscontinuityEvent() async throws { - // Given: A DefaultRoomLifecycleManager, with a room lifecycle operation in progress, and which has a contributor for which it has not previously received an ATTACHED state change - let contributor = createContributor() - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attachingDueToAttachOperation(attachOperationID: UUID()), // case and ID arbitrary, just care that an operation is in progress - contributors: [contributor] - ) - - // When: The aforementioned contributor emits an ATTACHED event with `resumed` flag set to false - let contributorStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager does not record a pending discontinuity event for this contributor - #expect(manager.testsOnly_pendingDiscontinuityEvent(for: contributor) == nil) - } - - // @spec CHA-RL4b5 - @Test - func contributorFailedEvent_withNoOperationInProgress() async throws { - // Given: A DefaultRoomLifecycleManager, with no room lifecycle operation in progress - let contributors = [ - // TODO: The .success is currently arbitrary since the spec doesn’t say what to do if detach fails (have asked in https://github.com/ably/specification/pull/200#discussion_r1777471810) - createContributor(detachBehavior: .success), - createContributor(detachBehavior: .success), - createContributor(detachBehavior: .success), - ] - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // case arbitrary, just care that no operation is in progress - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [ - // Give 2 of the 3 contributors a transient disconnect timeout, so we can test that _all_ such timeouts get cleared (as the spec point specifies), not just those for the FAILED contributor - contributors[0].id, - contributors[1].id, - ], - contributors: contributors - ) - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let failedStatusChange = roomStatusSubscription.failedElements().first { _ in true } - - // When: A contributor emits an FAILED event - let contributorStateChange = ARTChannelStateChange( - current: .failed, - previous: .attached, // arbitrary - event: .failed, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false // arbitrary - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributors[0].mockChannel.emitStateChange(contributorStateChange) - } - - // Then: - // - the room status transitions to failed, with the error of the status change being the `reason` of the contributor FAILED event - // - and it calls `detach` on all contributors - // - it clears all transient disconnect timeouts - _ = try #require(await failedStatusChange) - #expect(manager.roomStatus.isFailed) - - for contributor in contributors { - #expect(contributor.mockChannel.detachCallCount == 1) - } - - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) - } - - // @specOneOf(1/2) CHA-RL4b7 - Tests that when a transient disconnect timeout already exists, a new one is not created - func contributorAttachingEvent_withNoOperationInProgress_withTransientDisconnectTimeout() async throws { - // Given: A DefaultRoomLifecycleManager, with no operation in progress, with a transient disconnect timeout for the contributor mentioned in "When:" - let contributor = createContributor() - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // arbitrary no-operation-in-progress - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [contributor.id], - contributors: [contributor] - ) - - let idOfExistingTransientDisconnectTimeout = try #require(manager.testsOnly_idOfTransientDisconnectTimeout(for: contributor)) - - // When: A contributor emits an ATTACHING event - let contributorStateChange = ARTChannelStateChange( - current: .attaching, - previous: .detached, // arbitrary - event: .attaching, - reason: nil // arbitrary - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: It does not set a new transient disconnect timeout - #expect(manager.testsOnly_idOfTransientDisconnectTimeout(for: contributor) == idOfExistingTransientDisconnectTimeout) - } - // @specOneOf(2/2) CHA-RL4b7 - Tests that when the conditions of this spec point are fulfilled, a transient disconnect timeout is created - @Test( - arguments: [ - nil, - ARTErrorInfo.create(withCode: 123, message: ""), // arbitrary non-nil - ] + expectDiscontinuity: false + ), + ] ) - func contributorAttachingEvent_withNoOperationInProgress_withNoTransientDisconnectTimeout(contributorStateChangeReason: ARTErrorInfo?) async throws { - // Given: A DefaultRoomLifecycleManager, with no operation in progress, with no transient disconnect timeout for the contributor mentioned in "When:" - let contributor = createContributor() - let sleepOperation = SignallableSleepOperation() - let clock = MockSimpleClock(sleepBehavior: sleepOperation.behavior) - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // arbitrary no-operation-in-progress - contributors: [contributor], - clock: clock - ) - - // When: (1) A contributor emits an ATTACHING event - let contributorStateChange = ARTChannelStateChange( - current: .attaching, - previous: .detached, // arbitrary - event: .attaching, - reason: contributorStateChangeReason - ) - - async let maybeClockSleepArgument = clock.sleepCallArgumentsAsyncSequence.first { _ in true } - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The manager records a 5 second transient disconnect timeout for this contributor - #expect(try #require(await maybeClockSleepArgument) == 5) - #expect(manager.testsOnly_hasTransientDisconnectTimeout(for: contributor)) - - // and When: This transient disconnect timeout completes - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeRoomAttachingStatusChange = roomStatusSubscription.attachingElements().first { _ in true } - - sleepOperation.complete() - - // Then: - // 1. The room status transitions to ATTACHING, using the `reason` from the contributor ATTACHING change in (1) - // 2. The manager no longer has a transient disconnect timeout for this contributor - - let roomAttachingStatusChange = try #require(await maybeRoomAttachingStatusChange) - #expect(roomAttachingStatusChange.error == contributorStateChangeReason) - - #expect(!manager.testsOnly_hasTransientDisconnectTimeout(for: contributor)) - } - - // @specOneOf(1/2) CHA-RL4b10 - @Test - func contributorAttachedEvent_withNoOperationInProgress_clearsTransientDisconnectTimeouts() async throws { - // Given: A DefaultRoomLifecycleManager, with no room lifecycle operation in progress - let contributorThatWillEmitAttachedStateChange = createContributor() - let contributors = [ - contributorThatWillEmitAttachedStateChange, - createContributor(), - createContributor(), - ] - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // case arbitrary, just care that no operation is in progress - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [ - // Give 2 of the 3 contributors a transient disconnect timeout, so we can test that only the timeout for the ATTACHED contributor gets cleared, not all of them - contributorThatWillEmitAttachedStateChange.id, - contributors[1].id, - ], - contributors: contributors - ) - - // When: A contributor emits a state change to ATTACHED - let contributorAttachedStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorAttachedStateChange) { - contributorThatWillEmitAttachedStateChange.mockChannel.emitStateChange(contributorAttachedStateChange) - } - - // Then: The manager clears any transient disconnect timeout for that contributor - #expect(!manager.testsOnly_hasTransientDisconnectTimeout(for: contributorThatWillEmitAttachedStateChange)) - // check the timeout for the other contributors didn’t get cleared - #expect(manager.testsOnly_hasTransientDisconnectTimeout(for: contributors[1])) - } - - // @specOneOf(2/2) CHA-RL4b10 - This test is more elaborate than contributorAttachedEvent_withNoOperationInProgress_clearsTransientDisconnectTimeouts; instead of telling the manager to pretend that it has a transient disconnect timeout, we create a proper one by fulfilling the conditions of CHA-RL4b7, and we then fulfill the conditions of CHA-RL4b10 and check that the _side effects_ of the transient disconnect timeout (i.e. the state change) do not get performed. This is the _only_ test in which we go to these lengths to confirm that a transient disconnect timeout is truly cancelled; I think it’s enough to check it properly only once and then use simpler ways of checking it in other tests. - @Test - func contributorAttachedEvent_withNoOperationInProgress_clearsTransientDisconnectTimeouts_checkThatSideEffectsNotPerformed() async throws { - // Given: A DefaultRoomLifecycleManager, with no operation in progress, with a transient disconnect timeout - let contributor = createContributor() - let sleepOperation = SignallableSleepOperation() - let clock = MockSimpleClock(sleepBehavior: sleepOperation.behavior) - let initialManagerStatus = DefaultRoomLifecycleManager.Status.initialized // arbitrary no-operation-in-progress - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: initialManagerStatus, - contributors: [contributor], - clock: clock - ) - let contributorStateChange = ARTChannelStateChange( - current: .attaching, - previous: .detached, // arbitrary - event: .attaching, - reason: nil // arbitrary - ) - async let maybeClockSleepArgument = clock.sleepCallArgumentsAsyncSequence.first { _ in true } - // We create a transient disconnect timeout by fulfilling the conditions of CHA-RL4b7 - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributor.mockChannel.emitStateChange(contributorStateChange) + func channelStateEvent_discontinuity( + hasAttachedOnce: Bool, + isExplicitlyDetached: Bool, + channelEvent: ARTChannelStateChange, + expectDiscontinuity: Bool + ) async throws { + // Given: A DefaultRoomLifecycleManager, whose hasAttachedOnce and isExplicitlyDetached internal state is set per test arguments + let channelAttachBehavior = hasAttachedOnce ? MockRealtimeChannel.AttachOrDetachBehavior.complete(.success) : nil + let channelDetachBehavior = isExplicitlyDetached ? MockRealtimeChannel.AttachOrDetachBehavior.complete(.success) : nil + + let channel = createChannel(attachBehavior: channelAttachBehavior, detachBehavior: channelDetachBehavior) + let manager = createManager(channel: channel) + + // Perform the operations necessary to get the hasAttachedOnce and isExplicitlyDetached flags to be the values we wish them to be. + if hasAttachedOnce { + try await manager.performAttachOperation() + try #require(manager.testsOnly_hasAttachedOnce) } - try #require(await maybeClockSleepArgument != nil) - - let transientDisconnectTimeoutID = try #require(manager.testsOnly_idOfTransientDisconnectTimeout(for: contributor)) - - // When: A contributor emits a state change to ATTACHED, and we wait for the manager to inform us that any side effects that the transient disconnect timeout may cause have taken place - let contributorAttachedStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: nil // arbitrary - ) - - await waitForManager(manager, toHandleTransientDisconnectTimeoutWithID: transientDisconnectTimeoutID) { - contributor.mockChannel.emitStateChange(contributorAttachedStateChange) + if isExplicitlyDetached { + try await manager.performDetachOperation() + try #require(manager.testsOnly_isExplicitlyDetached) } - // Then: The manager’s status remains unchanged. In particular, it has not changed to ATTACHING, meaning that the CHA-RL4b7 side effect has not happened and hence that the transient disconnect timeout was properly cancelled - #expect(manager.roomStatus == initialManagerStatus.toRoomStatus) - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) - } - - // @specOneOf(1/2) CHA-RL4b8 - @Test - func contributorAttachedEvent_withNoOperationInProgress_roomNotAttached_allContributorsAttached() async throws { - // Given: A DefaultRoomLifecycleManager, with no operation in progress and not in the ATTACHED status, all of whose contributors are in the ATTACHED state (to satisfy the condition of CHA-RL4b8; for the purposes of this test I don’t care that they’re in this state even _before_ the state change of the When) - let contributors = [ - createContributor(initialState: .attached), - createContributor(initialState: .attached), - ] - - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // arbitrary non-ATTACHED - contributors: contributors - ) - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeAttachedRoomStatusChange = roomStatusSubscription.first { $0.current == .attached } + let discontinuitiesSubscription = manager.onDiscontinuity(bufferingPolicy: .unbounded) - // When: A contributor emits a state change to ATTACHED - let contributorStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false // arbitrary - ) + // When: The channel emits a state event + channel.emitEvent(channelEvent) - contributors[0].mockChannel.emitStateChange(contributorStateChange) + // Then: If the state event is a potential discontinuity, and this is confirmed by our internal state, the manager emits a discontinuity + discontinuitiesSubscription.testsOnly_finish() + let emittedDiscontinuities = await Array(discontinuitiesSubscription) - // Then: The room status transitions to ATTACHED - _ = try #require(await maybeAttachedRoomStatusChange) - #expect(manager.roomStatus == .attached) - } + if expectDiscontinuity { + try #require(emittedDiscontinuities.count == 1) + let discontinuityEvent = emittedDiscontinuities[0] - // @specOneOf(2/2) CHA-RL4b8 - Tests that the specified side effect doesn’t happen if part of the condition (i.e. all contributors now being ATTACHED) is not met - @Test - func contributorAttachedEvent_withNoOperationInProgress_roomNotAttached_notAllContributorsAttached() async throws { - // Given: A DefaultRoomLifecycleManager, with no operation in progress and not in the ATTACHED status, one of whose contributors is not in the ATTACHED state state (to simulate the condition of CHA-RL4b8 not being met; for the purposes of this test I don’t care that they’re in this state even _before_ the state change of the When) - let contributors = [ - createContributor(initialState: .attached), - createContributor(initialState: .detached), - ] - - let initialManagerStatus = DefaultRoomLifecycleManager.Status.detached // arbitrary non-ATTACHED, no-operation-in-progress - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: initialManagerStatus, - contributors: contributors - ) - - // When: A contributor emits a state change to ATTACHED - let contributorStateChange = ARTChannelStateChange( - current: .attached, - previous: .attaching, // arbitrary - event: .attached, - reason: ARTErrorInfo(domain: "SomeDomain", code: 123), // arbitrary - resumed: false // arbitrary - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributors[0].mockChannel.emitStateChange(contributorStateChange) - } - - // Then: The room status does not change - #expect(manager.roomStatus == initialManagerStatus.toRoomStatus) - } - - // @spec CHA-RL4b9 - @Test - func contributorSuspendedEvent_withNoOperationInProgress() async throws { - // Given: A DefaultRoomLifecycleManager with no lifecycle operation in progress - let contributorThatWillEmitStateChange = createContributor( - attachBehavior: .success, // So the CHA-RL5f RETRY attachment cycle succeeds - - // This is so that the RETRY operation’s wait-to-become-ATTACHED succeeds - subscribeToStateBehavior: .addSubscriptionAndEmitStateChange( - .init( - current: .attached, - previous: .detached, // arbitrary - event: .attached, - reason: nil // arbitrary + #expect( + isChatError( + discontinuityEvent.error, + withCodeAndStatusCode: .fixedStatusCode(.roomDiscontinuity), + cause: channelEvent.reason ) ) - ) - - var nonSuspendedContributorsDetachOperations: [SignallableChannelOperation] = [] - let contributors = [ - contributorThatWillEmitStateChange, - ] + (1 ... 2).map { _ in - // These contributors will be detached by the RETRY operation; we want to be able to delay their completion (and hence delay the RETRY operation’s transition from SUSPENDED to DETACHED) until we have been able to verify that the room’s status is SUSPENDED - let detachOperation = SignallableChannelOperation() - nonSuspendedContributorsDetachOperations.append(detachOperation) - - return createContributor( - attachBehavior: .success, // So the CHA-RL5f RETRY attachment cycle succeeds - detachBehavior: detachOperation.behavior - ) + } else { + #expect(emittedDiscontinuities.isEmpty) } - - let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .initialized, // case arbitrary, just care that no operation is in progress - // Give 2 of the 3 contributors a transient disconnect timeout, so we can test that _all_ such timeouts get cleared (as the spec point specifies), not just those for the SUSPENDED contributor - forTestingWhatHappensWhenHasTransientDisconnectTimeoutForTheseContributorIDs: [ - contributorThatWillEmitStateChange.id, - contributors[1].id, - ], - contributors: contributors - ) - - let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) - async let maybeSuspendedRoomStatusChange = roomStatusSubscription.suspendedElements().first { _ in true } - - let managerStatusChangeSubscription = manager.testsOnly_onStatusChange() - - // When: A contributor emits a state change to SUSPENDED - let contributorStateChangeReason = ARTErrorInfo(domain: "SomeDomain", code: 123) // arbitrary - let contributorStateChange = ARTChannelStateChange( - current: .suspended, - previous: .attached, // arbitrary - event: .suspended, - reason: contributorStateChangeReason, - resumed: false // arbitrary - ) - - await waitForManager(manager, toHandleContributorStateChange: contributorStateChange) { - contributorThatWillEmitStateChange.mockChannel.emitStateChange(contributorStateChange) - } - - // Then: - // 1. The room transitions to SUSPENDED, and this status change has error equal to the contributor state change’s `reason` - // 2. All transient disconnect timeouts are cancelled - let suspendedRoomStatusChange = try #require(await maybeSuspendedRoomStatusChange) - #expect(suspendedRoomStatusChange.error === contributorStateChangeReason) - - #expect(manager.roomStatus == .suspended(error: contributorStateChangeReason)) - - #expect(!manager.testsOnly_hasTransientDisconnectTimeoutForAnyContributor) - - // and: - // 3. The manager performs a RETRY operation, triggered by the contributor that entered SUSPENDED - - // Allow the RETRY operation’s contributor detaches to complete - for detachOperation in nonSuspendedContributorsDetachOperations { - detachOperation.complete(behavior: .success) // So the CHA-RL5a RETRY detachment cycle succeeds - } - - // Wait for the RETRY to finish - let retryOperationTask = try #require(await managerStatusChangeSubscription.compactMap { statusChange in - if case let .suspendedAwaitingStartOfRetryOperation(retryOperationTask, _) = statusChange.current { - return retryOperationTask - } - return nil - } - .first { @Sendable _ in - true - }) - - await retryOperationTask.value - - // We confirm that a RETRY happened by checking for its expected side effects: - #expect(contributors[0].mockChannel.detachCallCount == 0) // RETRY doesn’t touch this since it’s the one that triggered the RETRY - #expect(contributors[1].mockChannel.detachCallCount == 1) // From the CHA-RL5a RETRY detachment cycle - #expect(contributors[2].mockChannel.detachCallCount == 1) // From the CHA-RL5a RETRY detachment cycle - - #expect(contributors[0].mockChannel.attachCallCount == 1) // From the CHA-RL5f RETRY attachment cycle - #expect(contributors[1].mockChannel.attachCallCount == 1) // From the CHA-RL5f RETRY attachment cycle - #expect(contributors[2].mockChannel.attachCallCount == 1) // From the CHA-RL5f RETRY attachment cycle - - _ = try #require(await roomStatusSubscription.first { @Sendable statusChange in - statusChange.current == .attached - }) // Room status changes to ATTACHED } // MARK: - Waiting to be able to perform presence operations @@ -2088,12 +948,12 @@ struct DefaultRoomLifecycleManagerTests { @Test func waitToBeAbleToPerformPresenceOperations_whenAttaching_whenTransitionsToAttached() async throws { // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status - let contributorAttachOperation = SignallableChannelOperation() + let channelAttachOperation = SignallableChannelOperation() - let contributor = createContributor(attachBehavior: contributorAttachOperation.behavior) + let channel = createChannel(attachBehavior: channelAttachOperation.behavior) let manager = createManager( - contributors: [contributor] + channel: channel ) let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) @@ -2112,7 +972,7 @@ struct DefaultRoomLifecycleManagerTests { _ = try #require(await statusChangeWaitSubscription.first { @Sendable _ in true }) // and When: The ATTACH operation succeeds, thus putting the room in the ATTACHED status - contributorAttachOperation.complete(behavior: .success) + channelAttachOperation.complete(behavior: .success) // Then: The call to `waitToBeAbleToPerformPresenceOperations(requestedByFeature:)` succeeds try await waitToBeAbleToPerformPresenceOperationsResult @@ -2127,12 +987,12 @@ struct DefaultRoomLifecycleManagerTests { @Test func waitToBeAbleToPerformPresenceOperations_whenAttaching_whenTransitionsToNonAttachedStatus() async throws { // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status - let contributorAttachOperation = SignallableChannelOperation() + let channelAttachOperation = SignallableChannelOperation() - let contributor = createContributor(attachBehavior: contributorAttachOperation.behavior) + let channel = createChannel(attachBehavior: channelAttachOperation.behavior) let manager = createManager( - contributors: [contributor] + channel: channel ) let roomStatusSubscription = manager.onRoomStatusChange(bufferingPolicy: .unbounded) @@ -2151,8 +1011,8 @@ struct DefaultRoomLifecycleManagerTests { _ = try #require(await statusChangeWaitSubscription.first { @Sendable _ in true }) // and When: The ATTACH operation fails, thus putting the room in the FAILED status (i.e. a non-ATTACHED status) - let contributorAttachError = ARTErrorInfo.createUnknownError() // arbitrary - contributorAttachOperation.complete(behavior: .completeAndChangeState(.failure(contributorAttachError), newState: .failed)) + let channelAttachError = ARTErrorInfo.createUnknownError() // arbitrary + channelAttachOperation.complete(behavior: .completeAndChangeState(.failure(channelAttachError), newState: .failed)) // Then: The call to `waitToBeAbleToPerformPresenceOperations(requestedByFeature:)` fails with a `roomInInvalidState` error with status code 500, whose cause is the error associated with the room status change var caughtError: Error? @@ -2162,7 +1022,7 @@ struct DefaultRoomLifecycleManagerTests { caughtError = error } - let expectedCause = ARTErrorInfo(chatError: .attachmentFailed(feature: .messages, underlyingError: contributorAttachError)) // using our knowledge of CHA-RL1h4 + let expectedCause = channelAttachError // using our knowledge of CHA-RL1k2 #expect(isChatError(caughtError, withCodeAndStatusCode: .variableStatusCode(.roomInInvalidState, statusCode: 500), cause: expectedCause)) } @@ -2173,7 +1033,7 @@ struct DefaultRoomLifecycleManagerTests { func waitToBeAbleToPerformPresenceOperations_whenAttached() async throws { // Given: A DefaultRoomLifecycleManager in the ATTACHED status let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .attached + forTestingWhatHappensWhenCurrentlyIn: .attached(error: nil) ) // When: `waitToBeAbleToPerformPresenceOperations(requestedByFeature:)` is called on the lifecycle manager @@ -2188,7 +1048,7 @@ struct DefaultRoomLifecycleManagerTests { func waitToBeAbleToPerformPresenceOperations_whenAnyOtherStatus() async throws { // Given: A DefaultRoomLifecycleManager in a status other than ATTACHING or ATTACHED let manager = createManager( - forTestingWhatHappensWhenCurrentlyIn: .detached // arbitrary given the above constraints + forTestingWhatHappensWhenCurrentlyIn: .detached(error: nil) // arbitrary given the above constraints ) // (Note: I wanted to use #expect(…, throws:) below, but for some reason it made the compiler _crash_! No idea why. So, gave up on that.) diff --git a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift index 7a758219..b083f302 100644 --- a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift +++ b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift @@ -19,9 +19,14 @@ struct DefaultRoomOccupancyTests { ) } let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") - let featureChannel = MockFeatureChannel(channel: channel) - let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + let channel = MockRealtimeChannel(name: "basketball::$chat") + let defaultOccupancy = DefaultOccupancy( + channel: channel, + chatAPI: chatAPI, + roomID: "basketball", + logger: TestLogger(), + options: .init() + ) // When let occupancyInfo = try await defaultOccupancy.get() @@ -31,6 +36,8 @@ struct DefaultRoomOccupancyTests { #expect(occupancyInfo.presenceMembers == 2) } + // @specUntested CHA-O4e - We chose to implement this failure with an idiomatic fatalError instead of throwing, but we can’t test this. + // @spec CHA-O4a // @spec CHA-O4c @Test @@ -38,9 +45,14 @@ struct DefaultRoomOccupancyTests { // Given let realtime = MockRealtime() let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages") - let featureChannel = MockFeatureChannel(channel: channel) - let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) + let channel = MockRealtimeChannel(name: "basketball::$chat") + let defaultOccupancy = DefaultOccupancy( + channel: channel, + chatAPI: chatAPI, + roomID: "basketball", + logger: TestLogger(), + options: .init(enableEvents: true) + ) // CHA-O4a, CHA-O4c @@ -53,24 +65,4 @@ struct DefaultRoomOccupancyTests { #expect(occupancyInfo.connections == 5) #expect(occupancyInfo.presenceMembers == 2) } - - // @spec CHA-O5 - @Test - func onDiscontinuity() async throws { - // Given - let realtime = MockRealtime() - let chatAPI = ChatAPI(realtime: realtime) - let channel = MockRealtimeChannel() - let featureChannel = MockFeatureChannel(channel: channel) - let defaultOccupancy = DefaultOccupancy(featureChannel: featureChannel, chatAPI: chatAPI, roomID: "basketball", logger: TestLogger()) - - // When: The feature channel emits a discontinuity through `onDiscontinuity` - let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error - let discontinuitySubscription = defaultOccupancy.onDiscontinuity() - featureChannel.emitDiscontinuity(featureChannelDiscontinuity) - - // Then: The DefaultOccupancy instance emits this discontinuity through `onDiscontinuity` - let discontinuity = try #require(await discontinuitySubscription.first { @Sendable _ in true }) - #expect(discontinuity == featureChannelDiscontinuity) - } } diff --git a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift index 60d78c0a..147db383 100644 --- a/Tests/AblyChatTests/DefaultRoomReactionsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomReactionsTests.swift @@ -4,16 +4,15 @@ import Testing @MainActor struct DefaultRoomReactionsTests { - // @spec CHA-ER3a + // @spec CHA-ER3d @Test func reactionsAreSentInTheCorrectFormat() async throws { // channel name and roomID values are arbitrary // Given - let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") - let featureChannel = MockFeatureChannel(channel: channel) + let channel = MockRealtimeChannel(name: "basketball::$chat") // When - let defaultRoomReactions = DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) let sendReactionParams = SendReactionParams( type: "like", @@ -27,7 +26,7 @@ struct DefaultRoomReactionsTests { // Then #expect(channel.publishedMessages.last?.name == RoomReactionEvents.reaction.rawValue) #expect(channel.publishedMessages.last?.data == ["type": "like", "metadata": ["someMetadataKey": "someMetadataValue"]]) - #expect(channel.publishedMessages.last?.extras == ["headers": ["someHeadersKey": "someHeadersValue"]]) + #expect(channel.publishedMessages.last?.extras == ["headers": ["someHeadersKey": "someHeadersValue"], "ephemeral": true]) } // @spec CHA-ER4 @@ -35,11 +34,10 @@ struct DefaultRoomReactionsTests { func subscribe_returnsSubscription() async throws { // all setup values here are arbitrary // Given - let channel = MockRealtimeChannel(name: "basketball::$chat::$reactions") - let featureChannel = MockFeatureChannel(channel: channel) + let channel = MockRealtimeChannel(name: "basketball::$chat") // When - let defaultRoomReactions = DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) + let defaultRoomReactions = DefaultRoomReactions(channel: channel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) // When let subscription: Subscription? = defaultRoomReactions.subscribe() @@ -47,23 +45,4 @@ struct DefaultRoomReactionsTests { // Then #expect(subscription != nil) } - - // @spec CHA-ER5 - @Test - func onDiscontinuity() async throws { - // all setup values here are arbitrary - // Given: A DefaultRoomReactions instance - let channel = MockRealtimeChannel() - let featureChannel = MockFeatureChannel(channel: channel) - let roomReactions = DefaultRoomReactions(featureChannel: featureChannel, clientID: "mockClientId", roomID: "basketball", logger: TestLogger()) - - // When: The feature channel emits a discontinuity through `onDiscontinuity` - let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError() /* arbitrary */ ) - let messagesDiscontinuitySubscription = roomReactions.onDiscontinuity() - featureChannel.emitDiscontinuity(featureChannelDiscontinuity) - - // Then: The DefaultRoomReactions instance emits this discontinuity through `onDiscontinuity` - let messagesDiscontinuity = try #require(await messagesDiscontinuitySubscription.first { @Sendable _ in true }) - #expect(messagesDiscontinuity == featureChannelDiscontinuity) - } } diff --git a/Tests/AblyChatTests/DefaultRoomTests.swift b/Tests/AblyChatTests/DefaultRoomTests.swift index 23e63a86..54a6df4e 100644 --- a/Tests/AblyChatTests/DefaultRoomTests.swift +++ b/Tests/AblyChatTests/DefaultRoomTests.swift @@ -6,12 +6,27 @@ import Testing struct DefaultRoomTests { // MARK: - Fetching channels + // @spec CHA-RC3c + @Test + func channelName() async throws { + // Given: a DefaultRoom instance + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat"), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime(channels: channels) + let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) + + // Then + #expect(room.testsOnly_internalChannel.name == "basketball::$chat") + } + // @spec CHA-GP2a @Test func disablesImplicitAttach() async throws { // Given: A DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) @@ -25,169 +40,97 @@ struct DefaultRoomTests { // @spec CHA-RC3a @Test - func fetchesEachChannelOnce() async throws { - // Given: A DefaultRoom instance, configured to use presence and occupancy + func fetchesChannelOnce() async throws { + // Given: A DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) + let roomOptions = RoomOptions(presence: .init(enableEvents: false), occupancy: .init(enableEvents: true)) let realtime = MockRealtime(channels: channels) - let roomOptions = RoomOptions(presence: PresenceOptions(), occupancy: OccupancyOptions()) _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: roomOptions, logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) - // Then: It fetches the …$chatMessages channel (which is used by messages, presence, and occupancy) only once, and the options with which it does so are the result of merging the options used by the presence feature and those used by the occupancy feature + // Then: It fetches the …$chat channel only once, and the options with which it does so are the result of merging the options used by the presence feature and those used by the occupancy feature let channelsGetArguments = channels.getArguments - #expect(channelsGetArguments.map(\.name).sorted() == ["basketball::$chat::$chatMessages"]) + #expect(channelsGetArguments.map(\.name).sorted() == ["basketball::$chat"]) - let chatMessagesChannelGetOptions = try #require(channelsGetArguments.first { $0.name == "basketball::$chat::$chatMessages" }?.options) + let chatMessagesChannelGetOptions = try #require(channelsGetArguments.first { $0.name == "basketball::$chat" }?.options) #expect(chatMessagesChannelGetOptions.params?["occupancy"] == "metrics") - // TODO: Restore this code once we understand weird Realtime behaviour and spec points (https://github.com/ably-labs/ably-chat-swift/issues/133) -// #expect(chatMessagesChannelGetOptions.modes == [.presence, .presenceSubscribe]) + #expect(chatMessagesChannelGetOptions.modes == [.publish, .subscribe, .presence]) } - // MARK: - Features - - // @spec CHA-M1 - @Test - func messagesChannelName() async throws { - // Given: a DefaultRoom instance - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + // @spec CHA-O6a + // @spec CHA-O6b + @Test(arguments: + [ + ( + enableEvents: true, + expectedOccupancyChannelParam: "metrics" + ), + ( + enableEvents: false, + expectedOccupancyChannelParam: nil + ), ] - let channels = MockChannels(channels: channelsList) - let realtime = MockRealtime(channels: channels) - let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) - - // Then - let defaultMessages = try #require(room.messages as? DefaultMessages) - #expect(defaultMessages.testsOnly_internalChannel.name == "basketball::$chat::$chatMessages") - } - - // @spec CHA-ER1 - @Test - func reactionsChannelName() async throws { - // Given: a DefaultRoom instance + ) + func occupancyEnableEvents(enableEvents: Bool, expectedOccupancyChannelParam: String?) async throws { + // Given: A DefaultRoom instance, with the occupancy.enableEvents room option set per the test argument let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) + let roomOptions = RoomOptions(occupancy: .init(enableEvents: enableEvents)) let realtime = MockRealtime(channels: channels) - let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(reactions: .init()), logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) + _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: roomOptions, logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) - // Then - let defaultReactions = try #require(room.reactions as? DefaultRoomReactions) - #expect(defaultReactions.testsOnly_internalChannel.name == "basketball::$chat::$reactions") + // Then: When fetching the realtime channel, it sets the "occupancy" channel param accordingly + let chatMessagesChannelGetOptions = try #require(channels.getArguments.first?.options) + #expect(chatMessagesChannelGetOptions.params?["occupancy"] == expectedOccupancyChannelParam) } - // @spec CHA-RC2c - // @spec CHA-RC2d - // @spec CHA-RC2f - // @spec CHA-RL5a1 - We implement this spec point by _not allowing multiple contributors to share a channel_; this is an approach that I’ve suggested in https://github.com/ably/specification/issues/240. - @Test - func fetchesChannelAndCreatesLifecycleContributorForEnabledFeatures() async throws { - // Given: a DefaultRoom instance, initialized with options that request that the room use a strict subset of the possible features + // @spec CHA-PR9c2 + @Test(arguments: + [ + ( + enableEvents: true, + // i.e. it doesn't explicitly set any modes (so that Realtime will use the default modes) + expectedChannelModes: [] as ARTChannelMode + ), + ( + enableEvents: false, + expectedChannelModes: [.publish, .subscribe, .presence] as ARTChannelMode + ), + ] + ) + func presenceEnableEvents(enableEvents: Bool, expectedChannelModes: ARTChannelMode) async throws { + // Given: A DefaultRoom instance, with the presence.enableEvents room option set per the test argument let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) + let roomOptions = RoomOptions(presence: .init(enableEvents: enableEvents)) let realtime = MockRealtime(channels: channels) - let roomOptions = RoomOptions( - presence: .init(), - // Note that typing indicators are not enabled, to give us the aforementioned strict subset of features - reactions: .init() - ) - let lifecycleManagerFactory = MockRoomLifecycleManagerFactory() - _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: roomOptions, logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - - // Then: It: - // - fetches the channel that corresponds to each feature requested by the room options, plus the messages feature - // - initializes the RoomLifecycleManager with a contributor for each fetched channel, and the feature assigned to each contributor is the feature, of the enabled features that correspond to that channel, which appears first in the CHA-RC2e list - // - initializes the RoomLifecycleManager with a contributor for each feature requested by the room options, plus the messages feature - let lifecycleManagerCreationArguments = try #require(lifecycleManagerFactory.createManagerArguments.first) - let expectedFeatures: [RoomFeature] = [.messages, .reactions] // i.e. since messages and presence share a channel, we create a single contributor for this channel and its assigned feature is messages - #expect(lifecycleManagerCreationArguments.contributors.count == expectedFeatures.count) - #expect(Set(lifecycleManagerCreationArguments.contributors.map(\.feature)) == Set(expectedFeatures)) + _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: roomOptions, logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) - let channelsGetArguments = channels.getArguments - let expectedFetchedChannelNames = [ - "basketball::$chat::$chatMessages", // This is the channel used by the messages and presence features - "basketball::$chat::$reactions", - ] - #expect(channelsGetArguments.count == expectedFetchedChannelNames.count) - #expect(Set(channelsGetArguments.map(\.name)) == Set(expectedFetchedChannelNames)) + // Then: When fetching the realtime channel, it sets the channel modes accordingly + let chatMessagesChannelGetOptions = try #require(channels.getArguments.first?.options) + #expect(chatMessagesChannelGetOptions.modes == expectedChannelModes) } - // @spec CHA-RC2e - // @spec CHA-RL10 + // MARK: - Features + @Test - func lifecycleContributorOrder() async throws { - // Given: a DefaultRoom, instance, with all room features enabled - let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), - MockRealtimeChannel(name: "basketball::$chat::$reactions"), - MockRealtimeChannel(name: "basketball::$chat"), - ] - let channels = MockChannels(channels: channelsList) + func passesChannelToLifecycleManager() async throws { + // Given: a DefaultRoom instance + let channel = MockRealtimeChannel(name: "basketball::$chat") + let channels = MockChannels(channels: [channel]) let realtime = MockRealtime(channels: channels) let lifecycleManagerFactory = MockRoomLifecycleManagerFactory() - _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .allFeaturesEnabled, logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) + _ = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) - // Then: The array of contributors with which it initializes the RoomLifecycleManager are in the same order as the following list: - // - // messages, presence, typing, reactions, occupancy - // - // (note that we do not say that it is the _same_ list, because we combine multiple features into a single contributor) + // Then: It creates a lifecycle manager using the fetched channel let lifecycleManagerCreationArguments = try #require(lifecycleManagerFactory.createManagerArguments.first) - #expect(lifecycleManagerCreationArguments.contributors.map(\.feature) == [.messages, .typing, .reactions]) - } - - // @specUntested CHA-RC2b - We chose to implement this failure with an idiomatic fatalError instead of throwing, but we can’t test this. - - // This is just a basic sense check to make sure the room getters are working as expected, since we don’t have unit tests for some of the features at the moment. - @Test(arguments: [.messages, .presence, .reactions, .occupancy, .typing] as[RoomFeature]) - func whenFeatureEnabled_propertyGetterReturns(feature: RoomFeature) async throws { - // Given: A RoomOptions with the (test argument `feature`) feature enabled in the room options - let roomOptions: RoomOptions - var namesOfChannelsToMock = ["basketball::$chat::$chatMessages"] - switch feature { - case .messages: - // Messages should always be enabled without needing any special options - roomOptions = .init() - case .presence: - roomOptions = .init(presence: .init()) - case .reactions: - roomOptions = .init(reactions: .init()) - namesOfChannelsToMock.append("basketball::$chat::$reactions") - case .occupancy: - roomOptions = .init(occupancy: .init()) - case .typing: - roomOptions = .init(typing: .init()) - namesOfChannelsToMock.append("basketball::$chat") - } - - let channelsList = namesOfChannelsToMock.map { name in - MockRealtimeChannel(name: name) - } - let channels = MockChannels(channels: channelsList) - let realtime = MockRealtime(channels: channels) - let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: roomOptions, logger: TestLogger(), lifecycleManagerFactory: MockRoomLifecycleManagerFactory()) - - // When: We call the room’s getter for that feature - // Then: It returns an object (i.e. does not `fatalError()`) - switch feature { - case .messages: - #expect(room.messages is DefaultMessages) - case .presence: - #expect(room.presence is DefaultPresence) - case .reactions: - #expect(room.reactions is DefaultRoomReactions) - case .occupancy: - #expect(room.occupancy is DefaultOccupancy) - case .typing: - #expect(room.typing is DefaultTyping) - } + #expect(lifecycleManagerCreationArguments.channel === channel) } // MARK: - Attach @@ -201,7 +144,7 @@ struct DefaultRoomTests { func attach(managerAttachResult: Result) async throws { // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) @@ -237,7 +180,7 @@ struct DefaultRoomTests { func detach(managerDetachResult: Result) async throws { // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) @@ -269,7 +212,7 @@ struct DefaultRoomTests { func release() async throws { // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) @@ -295,12 +238,12 @@ struct DefaultRoomTests { func status() async throws { // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) - let lifecycleManagerRoomStatus = RoomStatus.attached // arbitrary + let lifecycleManagerRoomStatus = RoomStatus.attached(error: nil) // arbitrary let lifecycleManager = MockRoomLifecycleManager(roomStatus: lifecycleManagerRoomStatus) let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) @@ -315,7 +258,7 @@ struct DefaultRoomTests { func onStatusChange() async throws { // Given: a DefaultRoom instance let channelsList = [ - MockRealtimeChannel(name: "basketball::$chat::$chatMessages"), + MockRealtimeChannel(name: "basketball::$chat"), ] let channels = MockChannels(channels: channelsList) let realtime = MockRealtime(channels: channels) @@ -326,7 +269,7 @@ struct DefaultRoomTests { let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) // When: The room lifecycle manager emits a status change through `subscribeToState` - let managerStatusChange = RoomStatusChange(current: .detached, previous: .detaching) // arbitrary + let managerStatusChange = RoomStatusChange(current: .detached(error: nil), previous: .detaching(error: nil)) // arbitrary let roomStatusSubscription = room.onStatusChange() lifecycleManager.emitStatusChange(managerStatusChange) @@ -334,6 +277,36 @@ struct DefaultRoomTests { let roomStatusChange = try #require(await roomStatusSubscription.first { @Sendable _ in true }) #expect(roomStatusChange == managerStatusChange) } + + // MARK: - Discontinuties + + // @spec CHA-RL15a + @Test + func onDiscontinuity() async throws { + // Given: a DefaultRoom instance + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat"), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime(channels: channels) + + let lifecycleManager = MockRoomLifecycleManager() + let lifecycleManagerFactory = MockRoomLifecycleManagerFactory(manager: lifecycleManager) + + let room = try DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(), logger: TestLogger(), lifecycleManagerFactory: lifecycleManagerFactory) + + // When: The room lifecycle manager emits a status change through `subscribeToState` + let managerDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError() /* arbitrary */ ) + let roomDiscontinuitiesSubscription = room.onDiscontinuity() + lifecycleManager.emitDiscontinuity(managerDiscontinuity) + + // Then: The room emits this discontinuity through `onDiscontinuity` + let roomDiscontinuity = try #require(await roomDiscontinuitiesSubscription.first { @Sendable _ in true }) + #expect(roomDiscontinuity == managerDiscontinuity) + } + + // @specNotApplicable CHA-RL15b - We do not have an explicit unsubscribe API, since we use AsyncSequence instead of listeners. + // @specNotApplicable CHA-RL15c - We do not have an explicit unsubscribe API, since we use AsyncSequence instead of listeners. } private extension Result { diff --git a/Tests/AblyChatTests/DefaultRoomsTests.swift b/Tests/AblyChatTests/DefaultRoomsTests.swift index 28f7db68..8fc8d40a 100644 --- a/Tests/AblyChatTests/DefaultRoomsTests.swift +++ b/Tests/AblyChatTests/DefaultRoomsTests.swift @@ -1,7 +1,7 @@ @testable import AblyChat import Testing -// The channel name of basketball::$chat::$chatMessages is passed in to these tests due to `DefaultRoom` kicking off the `DefaultMessages` initialization. This in turn needs a valid `roomId` or else the `MockChannels` class will throw an error as it would be expecting a channel with the name \(roomID)::$chat::$chatMessages to exist (where `roomId` is the property passed into `rooms.get`). +// The channel name of basketball::$chat is passed in to these tests due to `DefaultRoom` kicking off the `DefaultMessages` initialization. This in turn needs a valid `roomId` or else the `MockChannels` class will throw an error as it would be expecting a channel with the name \(roomID)::$chat to exist (where `roomId` is the property passed into `rooms.get`). @MainActor struct DefaultRoomsTests { // MARK: - Test helpers @@ -32,13 +32,36 @@ struct DefaultRoomsTests { // MARK: - Get a room + // @spec CHA-RC4a + @Test + func get_withoutOptions_usesDefaultOptions() async throws { + // Given: an instance of DefaultRooms + let realtime = MockRealtime(channels: .init(channels: [ + .init(name: "basketball::$chat"), + ])) + let options = RoomOptions() + let roomToReturn = MockRoom(options: options) + let roomFactory = MockRoomFactory(room: roomToReturn) + let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory) + + // When: get(roomID:) is called + let roomID = "basketball" + _ = try await rooms.get(roomID: roomID) + + // Then: It uses the default options + let createRoomArguments = try #require(roomFactory.createRoomArguments) + #expect(createRoomArguments.options == RoomOptions()) + } + + // @specNotApplicable CHA-RC4b - Our API does not have a concept of "partial options" unlike the JS API which this spec item considers. + // @spec CHA-RC1f // @spec CHA-RC1f3 @Test func get_returnsRoomWithGivenIDAndOptions() async throws { // Given: an instance of DefaultRooms let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() let roomToReturn = MockRoom(options: options) @@ -66,7 +89,7 @@ struct DefaultRoomsTests { func get_whenRoomExistsInRoomMap_returnsExistingRoomWithGivenID() async throws { // Given: an instance of DefaultRooms, which has, per CHA-RC1f3, a room in the room map with a given ID let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() let roomToReturn = MockRoom(options: options) @@ -89,7 +112,7 @@ struct DefaultRoomsTests { func get_whenFutureExistsInRoomMap_returnsExistingRoomWithGivenID() async throws { // Given: an instance of DefaultRooms, for which, per CHA-RC1f4, a previous call to get(roomID:options:) with a given ID is waiting for a CHA-RC1g release operation to complete let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() @@ -137,7 +160,7 @@ struct DefaultRoomsTests { func get_whenRoomExistsInRoomMap_throwsErrorWhenOptionsDoNotMatch() async throws { // Given: an instance of DefaultRooms, which has, per CHA-RC1f3, a room in the room map with a given ID and options let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() @@ -149,7 +172,7 @@ struct DefaultRoomsTests { // When: get(roomID:options:) is called with the same ID but different options // Then: It throws a `badRequest` error - let differentOptions = RoomOptions(presence: .init(subscribe: false)) + let differentOptions = RoomOptions(presence: .init(enableEvents: false)) // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released let doIt = { @@ -167,7 +190,7 @@ struct DefaultRoomsTests { func get_whenFutureExistsInRoomMap_throwsErrorWhenOptionsDoNotMatch() async throws { // Given: an instance of DefaultRooms, for which, per CHA-RC1f4, a previous call to get(roomID:options:) with a given ID and options is waiting for a CHA-RC1g release operation to complete let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() @@ -195,7 +218,7 @@ struct DefaultRoomsTests { // When: get(roomID:options:) is called with the same ID but different options // Then: The second call to get(roomID:options:) throws a `badRequest` error - let differentOptions = RoomOptions(presence: .init(subscribe: false)) + let differentOptions = RoomOptions(presence: .init(enableEvents: false)) // TODO: avoids compiler crash (https://github.com/ably/ably-chat-swift/issues/233), revert once Xcode 16.3 released let doIt = { @@ -216,7 +239,7 @@ struct DefaultRoomsTests { func get_whenReleaseInProgress() async throws { // Given: an instance of DefaultRooms, for which a CHA-RC1g release operation is in progrss let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let roomReleaseOperation = SignallableReleaseOperation() @@ -260,7 +283,7 @@ struct DefaultRoomsTests { func release_withNoRoomMapEntry_andNoReleaseInProgress() async throws { // Given: An instance of DefaultRooms, with neither a room map entry nor a release operation in progress for a given room ID let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let roomFactory = MockRoomFactory() let rooms = DefaultRooms(realtime: realtime, clientOptions: .init(), logger: TestLogger(), roomFactory: roomFactory) @@ -276,7 +299,7 @@ struct DefaultRoomsTests { func release_withNoRoomMapEntry_andReleaseInProgress() async throws { // Given: an instance of DefaultRooms, for which a release operation is in progress let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let roomReleaseOperation = SignallableReleaseOperation() @@ -318,7 +341,7 @@ struct DefaultRoomsTests { func release_withReleaseInProgress_failsPendingGetOperations() async throws { // Given: an instance of DefaultRooms, for which there is a release operation already in progress, and a CHA-RC1f4 future in the room map awaiting the completion of this release operation let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let roomReleaseOperation = SignallableReleaseOperation() @@ -374,7 +397,7 @@ struct DefaultRoomsTests { func release() async throws { // Given: an instance of DefaultRooms, which has a room map entry for a given room ID and has no release operation in progress for that room ID let realtime = MockRealtime(channels: .init(channels: [ - .init(name: "basketball::$chat::$chatMessages"), + .init(name: "basketball::$chat"), ])) let options = RoomOptions() let hasExistingRoomAtMomentRoomReleaseCalledStreamComponents = AsyncStream.makeStream(of: Bool.self) diff --git a/Tests/AblyChatTests/Helpers/RoomLifecycle+Error.swift b/Tests/AblyChatTests/Helpers/RoomLifecycle+Error.swift deleted file mode 100644 index bbd86298..00000000 --- a/Tests/AblyChatTests/Helpers/RoomLifecycle+Error.swift +++ /dev/null @@ -1,21 +0,0 @@ -import Ably -import AblyChat - -extension RoomStatus { - var error: ARTErrorInfo? { - switch self { - case let .failed(error): - error - case let .suspended(error): - error - case .initialized, - .attached, - .attaching, - .detached, - .detaching, - .releasing, - .released: - nil - } - } -} diff --git a/Tests/AblyChatTests/IntegrationTests.swift b/Tests/AblyChatTests/IntegrationTests.swift index b2856342..b69690a8 100644 --- a/Tests/AblyChatTests/IntegrationTests.swift +++ b/Tests/AblyChatTests/IntegrationTests.swift @@ -85,7 +85,7 @@ struct IntegrationTests { presence: .init(), typing: .init(heartbeatThrottle: 2), reactions: .init(), - occupancy: .init() + occupancy: .init(enableEvents: true) ) ) @@ -97,9 +97,9 @@ struct IntegrationTests { // (5) Check that we received an ATTACHED status change as a result of attaching the room _ = try #require(await rxRoomStatusSubscription.first { @Sendable statusChange in - statusChange.current == .attached + statusChange.current == .attached(error: nil) }) - #expect(rxRoom.status == .attached) + #expect(rxRoom.status == .attached(error: nil)) // MARK: - Send and receive messages @@ -400,9 +400,9 @@ struct IntegrationTests { // (2) Check that we received a DETACHED status change as a result of detaching the room _ = try #require(await rxRoomStatusSubscription.first { @Sendable statusChange in - statusChange.current == .detached + statusChange.current == .detached(error: nil) }) - #expect(rxRoom.status == .detached) + #expect(rxRoom.status == .detached(error: nil)) // MARK: - Release diff --git a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift b/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift deleted file mode 100644 index 723c76fe..00000000 --- a/Tests/AblyChatTests/Mocks/MockFeatureChannel.swift +++ /dev/null @@ -1,36 +0,0 @@ -import Ably -@testable import AblyChat - -final class MockFeatureChannel: FeatureChannel { - let channel: any InternalRealtimeChannelProtocol - private var discontinuitySubscriptions = SubscriptionStorage() - private let resultOfWaitToBeAbleToPerformPresenceOperations: Result? - - init( - channel: any InternalRealtimeChannelProtocol, - resultOfWaitToBeAblePerformPresenceOperations: Result? = nil - ) { - self.channel = channel - resultOfWaitToBeAbleToPerformPresenceOperations = resultOfWaitToBeAblePerformPresenceOperations - } - - func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { - discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) - } - - func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) { - discontinuitySubscriptions.emit(discontinuity) - } - - func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(InternalError) { - guard let resultOfWaitToBeAbleToPerformPresenceOperations else { - fatalError("resultOfWaitToBeAblePerformPresenceOperations must be set before waitToBeAbleToPerformPresenceOperations is called") - } - - do { - try resultOfWaitToBeAbleToPerformPresenceOperations.get() - } catch { - throw error.toInternalError() - } - } -} diff --git a/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift index ed894699..6df9330b 100644 --- a/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift +++ b/Tests/AblyChatTests/Mocks/MockHTTPPaginatedResponse.swift @@ -86,8 +86,6 @@ extension MockHTTPPaginatedResponse { // MARK: ChatAPI.getMessages mocked responses extension MockHTTPPaginatedResponse { - private static let messagesRoomId = "basketball::$chat::$chatMessages" - static let successGetMessagesWithNoItems = MockHTTPPaginatedResponse( items: [], statusCode: 200, @@ -101,7 +99,7 @@ extension MockHTTPPaginatedResponse { "serial": "3446456", "action": "message.create", "createdAt": 1_730_943_049_269, - "roomId": "basketball::$chat::$chatMessages", + "roomId": "basketball", "text": "hello", "metadata": [:], "headers": [:], @@ -112,7 +110,7 @@ extension MockHTTPPaginatedResponse { "clientId": "random", "serial": "3446457", "action": "message.create", - "roomId": "basketball::$chat::$chatMessages", + "roomId": "basketball", "text": "hello response", "metadata": [:], "headers": [:], @@ -131,14 +129,14 @@ extension MockHTTPPaginatedResponse { items: [ [ "serial": "3446450", - "roomId": "basketball::$chat::$chatMessages", + "roomId": "basketball", "text": "previous message", "metadata": [:], "headers": [:], ], [ "serial": "3446451", - "roomId": "basketball::$chat::$chatMessages", + "roomId": "basketball", "text": "previous response", "metadata": [:], "headers": [:], diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index 126b5b76..b3ad4ea0 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -28,8 +28,7 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { initialErrorReason: ARTErrorInfo? = nil, attachBehavior: AttachOrDetachBehavior? = nil, detachBehavior: AttachOrDetachBehavior? = nil, - messageToEmitOnSubscribe: ARTMessage? = nil, - subscribeToStateBehavior: SubscribeToStateBehavior? = nil + messageToEmitOnSubscribe: ARTMessage? = nil ) { _name = name _state = initialState @@ -37,7 +36,6 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { self.detachBehavior = detachBehavior errorReason = initialErrorReason self.messageToEmitOnSubscribe = messageToEmitOnSubscribe - self.subscribeToStateBehavior = subscribeToStateBehavior ?? .justAddSubscription attachSerial = properties.attachSerial channelSerial = properties.channelSerial } @@ -153,12 +151,22 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { // no-op; revisit if we need to test something that depends on this method actually stopping `subscribe` from emitting more events } + private var stateSubscriptionCallbacks: [@MainActor (ARTChannelStateChange) -> Void] = [] + func on(_: ARTChannelEvent, callback _: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { ARTEventListener() } - func on(_: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { - fatalError("Not implemented") + func on(_ callback: @escaping @MainActor (ARTChannelStateChange) -> Void) -> ARTEventListener { + stateSubscriptionCallbacks.append(callback) + + return ARTEventListener() + } + + func emitEvent(_ event: ARTChannelStateChange) { + for callback in stateSubscriptionCallbacks { + callback(event) + } } func off(_: ARTEventListener) { @@ -175,29 +183,4 @@ final class MockRealtimeChannel: InternalRealtimeChannelProtocol { func publish(_ name: String?, data: JSONValue?, extras: [String: JSONValue]?) { publishedMessages.append(TestMessage(name: name, data: data, extras: extras)) } - - enum SubscribeToStateBehavior { - case justAddSubscription - case addSubscriptionAndEmitStateChange(ARTChannelStateChange) - } - - private let subscribeToStateBehavior: SubscribeToStateBehavior - private var stateChangeSubscriptions = SubscriptionStorage() - - func subscribeToState() -> Subscription { - let subscription = stateChangeSubscriptions.create(bufferingPolicy: .unbounded) - - switch subscribeToStateBehavior { - case .justAddSubscription: - break - case let .addSubscriptionAndEmitStateChange(stateChange): - emitStateChange(stateChange) - } - - return subscription - } - - func emitStateChange(_ stateChange: ARTChannelStateChange) { - stateChangeSubscriptions.emit(stateChange) - } } diff --git a/Tests/AblyChatTests/Mocks/MockRoom.swift b/Tests/AblyChatTests/Mocks/MockRoom.swift index fe0139cc..7e307383 100644 --- a/Tests/AblyChatTests/Mocks/MockRoom.swift +++ b/Tests/AblyChatTests/Mocks/MockRoom.swift @@ -67,4 +67,12 @@ class MockRoom: InternalRoom { } private let _releaseCallsAsyncSequence: (stream: AsyncStream, continuation: AsyncStream.Continuation) + + func onDiscontinuity(bufferingPolicy _: BufferingPolicy) -> Subscription { + fatalError("Not implemented") + } + + nonisolated var channel: any RealtimeChannelProtocol { + fatalError("Not implemented") + } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift deleted file mode 100644 index 305913d6..00000000 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributor.swift +++ /dev/null @@ -1,22 +0,0 @@ -import Ably -@testable import AblyChat - -class MockRoomLifecycleContributor: RoomLifecycleContributor { - nonisolated let feature: RoomFeature - /// Provides access to the non-type-erased underlying mock channel (so that you can call mocking-related methods on it). - nonisolated let mockChannel: MockRealtimeChannel - nonisolated var channel: any InternalRealtimeChannelProtocol { - mockChannel - } - - private(set) var emitDiscontinuityArguments: [DiscontinuityEvent] = [] - - init(feature: RoomFeature, channel: MockRealtimeChannel) { - self.feature = feature - mockChannel = channel - } - - func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) { - emitDiscontinuityArguments.append(discontinuity) - } -} diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift index 764e5505..269af25c 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManager.swift @@ -8,7 +8,8 @@ class MockRoomLifecycleManager: RoomLifecycleManager { private(set) var detachCallCount = 0 private(set) var releaseCallCount = 0 private let _roomStatus: RoomStatus? - private var subscriptions = SubscriptionStorage() + private let roomStatusSubscriptions = SubscriptionStorage() + private let discontinuitySubscriptions = SubscriptionStorage() init(attachResult: Result? = nil, detachResult: Result? = nil, roomStatus: RoomStatus? = nil) { self.attachResult = attachResult @@ -52,14 +53,22 @@ class MockRoomLifecycleManager: RoomLifecycleManager { } func onRoomStatusChange(bufferingPolicy: BufferingPolicy) -> Subscription { - subscriptions.create(bufferingPolicy: bufferingPolicy) + roomStatusSubscriptions.create(bufferingPolicy: bufferingPolicy) } func emitStatusChange(_ statusChange: RoomStatusChange) { - subscriptions.emit(statusChange) + roomStatusSubscriptions.emit(statusChange) } func waitToBeAbleToPerformPresenceOperations(requestedByFeature _: RoomFeature) async throws(InternalError) { fatalError("Not implemented") } + + func onDiscontinuity(bufferingPolicy: BufferingPolicy) -> Subscription { + discontinuitySubscriptions.create(bufferingPolicy: bufferingPolicy) + } + + func emitDiscontinuity(_ discontinuity: DiscontinuityEvent) { + discontinuitySubscriptions.emit(discontinuity) + } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift index 3fd4cac3..a0b6074f 100644 --- a/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleManagerFactory.swift @@ -2,14 +2,14 @@ class MockRoomLifecycleManagerFactory: RoomLifecycleManagerFactory { private let manager: MockRoomLifecycleManager - private(set) var createManagerArguments: [(contributors: [DefaultRoomLifecycleContributor], logger: any InternalLogger)] = [] + private(set) var createManagerArguments: [(channel: any InternalRealtimeChannelProtocol, logger: any InternalLogger)] = [] init(manager: MockRoomLifecycleManager = .init()) { self.manager = manager } - func createManager(contributors: [DefaultRoomLifecycleContributor], logger: any InternalLogger) -> MockRoomLifecycleManager { - createManagerArguments.append((contributors: contributors, logger: logger)) + func createManager(channel: any InternalRealtimeChannelProtocol, logger: any InternalLogger) -> MockRoomLifecycleManager { + createManagerArguments.append((channel: channel, logger: logger)) return manager } } diff --git a/Tests/AblyChatTests/RoomOptionsTests.swift b/Tests/AblyChatTests/RoomOptionsTests.swift new file mode 100644 index 00000000..dec56e60 --- /dev/null +++ b/Tests/AblyChatTests/RoomOptionsTests.swift @@ -0,0 +1,16 @@ +import AblyChat +import Testing + +struct RoomOptionsTests { + // @spec CHA-PR9c1 + @Test + func defaultValue_presence_enableEvents() async throws { + #expect(RoomOptions().presence.enableEvents) + } + + // @spec CHA-O6c + @Test + func defaultValue_occupancy_enableEvents() async throws { + #expect(!RoomOptions().occupancy.enableEvents) + } +} diff --git a/Tests/AblyChatTests/RoomReactionDTOTests.swift b/Tests/AblyChatTests/RoomReactionDTOTests.swift index 0ee09c17..0a96317c 100644 --- a/Tests/AblyChatTests/RoomReactionDTOTests.swift +++ b/Tests/AblyChatTests/RoomReactionDTOTests.swift @@ -101,7 +101,8 @@ enum RoomReactionDTOTests { @Test func toJSONValue_withNilHeaders() { // i.e. should create an empty object for headers - #expect(RoomReactionDTO.Extras(headers: nil).toJSONValue == .object(["headers": .object([:])])) + // swiftlint:disable:next empty_collection_literal + #expect(RoomReactionDTO.Extras(headers: nil).toJSONValue.objectValue?["headers"] == [:]) } @Test @@ -113,6 +114,7 @@ enum RoomReactionDTOTests { "someStringKey": "someStringValue", "someNumberKey": 123, ], + "ephemeral": true, ]) } }