diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1501f9c6..7bd0a471 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,6 +28,43 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - We should aim to make it easy for consumers of the SDK to be able to mock out the SDK in the tests for their own code. A couple of things that will aid with this: - Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift). - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) +- When writing code that implements behaviour specified by the Chat SDK features spec, add a comment that references the identifier of the relevant spec item. + +### Testing guidelines + +#### Attributing tests to a spec point + +When writing a test that relates to a spec point from the Chat SDK features spec, add a comment that contains one of the following tags: + +- `@spec ` — The test case directly tests all the functionality documented in the spec item. +- `@specOneOf(m/n) ` — The test case is the mth of n test cases which, together, test all of the functionality documented in the spec item. +- `@specPartial ` — The test case tests some, but not all, of the functionality documented in the spec item. This is different to `@specOneOf` in that it implies that the test suite does not fully test this spec item. + +`` should be a spec item identifier such as `CHA-RL3g`. + +Each of the above tags can optionally be followed by a hyphen and a comment which explains how the test relates to the given spec item. + +Examples: + +```swift +// @spec CHA-EX3f +func test1 { … } +``` + +```swift +// @specOneOf(1/2) CHA-EX2h — Tests the case where the room is FAILED +func test2 { … } + +// @specOneOf(2/2) CHA-EX2h — Tests the case where the room is SUSPENDED +func test3 { … } +``` + +```swift +// @specIncomplete CHA-EX1h4 - Tests that we retry, but not the retry attempt limit because we’ve not implemented it yet +func test4 { … } +``` + +In [#46](https://github.com/ably-labs/ably-chat-swift/issues/46), we’ll write a script that uses these tags to generate a report about how much of the feature spec we’ve implemented. ## Building for Swift 6 diff --git a/Sources/AblyChat/Errors.swift b/Sources/AblyChat/Errors.swift index 8a3070cf..22de4213 100644 --- a/Sources/AblyChat/Errors.swift +++ b/Sources/AblyChat/Errors.swift @@ -16,11 +16,25 @@ public enum ErrorCode: Int { /// TODO this code is a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 case inconsistentRoomOptions = 1 + // TODO: describe, and code is a guess + case channelAttachResultedInSuspended = 2 + case channelAttachResultedInFailed = 3 + + case roomInFailedState = 102_101 // CHA-RL2d + case roomIsReleasing = 102_102 // CHA-RL1b, CHA-RL2b + case roomIsReleased = 102_103 // CHA-RL1c, CHA-RL2c + + case messagesDetachmentFailed = 102_050 + case presenceDetachmentFailed = 102_051 + case reactionsDetachmentFailed = 102_052 + case occupancyDetachmentFailed = 102_053 + case typingDetachmentFailed = 102_054 + /// The ``ARTErrorInfo.statusCode`` that should be returned for this error. internal var statusCode: Int { // TODO: These are currently a guess, revisit in https://github.com/ably-labs/ably-chat-swift/issues/32 switch self { - case .inconsistentRoomOptions: + case .inconsistentRoomOptions, .channelAttachResultedInSuspended, .channelAttachResultedInFailed, .roomInFailedState, .roomIsReleasing, .roomIsReleased, .messagesDetachmentFailed, .presenceDetachmentFailed, .reactionsDetachmentFailed, .occupancyDetachmentFailed, .typingDetachmentFailed: 400 } } @@ -29,16 +43,45 @@ public enum ErrorCode: Int { /** The errors thrown by the Chat SDK. - This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription`. + This type exists in addition to ``ErrorCode`` to allow us to attach metadata which can be incorporated into the error’s `localizedDescription` and `cause`. */ internal enum ChatError { case inconsistentRoomOptions(requested: RoomOptions, existing: RoomOptions) + case channelAttachResultedInSuspended(underlyingError: ARTErrorInfo) + case channelAttachResultedInFailed(underlyingError: ARTErrorInfo) + case roomInFailedState + case roomIsReleasing + case roomIsReleased + case detachmentFailed(feature: RoomFeature, underlyingError: ARTErrorInfo) /// The ``ARTErrorInfo.code`` that should be returned for this error. internal var code: ErrorCode { switch self { case .inconsistentRoomOptions: .inconsistentRoomOptions + case .channelAttachResultedInSuspended: + .channelAttachResultedInSuspended + case .channelAttachResultedInFailed: + .channelAttachResultedInFailed + case .roomInFailedState: + .roomInFailedState + case .roomIsReleasing: + .roomIsReleasing + case .roomIsReleased: + .roomIsReleased + case let .detachmentFailed(feature, _): + switch feature { + case .messages: + .messagesDetachmentFailed + case .occupancy: + .occupancyDetachmentFailed + case .presence: + .presenceDetachmentFailed + case .reactions: + .reactionsDetachmentFailed + case .typing: + .typingDetachmentFailed + } } } @@ -47,6 +90,49 @@ internal enum ChatError { switch self { 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 .channelAttachResultedInSuspended: + "TODO" + case .channelAttachResultedInFailed: + "TODO" + case .roomInFailedState: + "Cannot perform operation because the room is in a failed state." + case .roomIsReleasing: + "Cannot perform operation because the room is in a releasing state." + case .roomIsReleased: + "Cannot perform operation because the room is in a released state." + case let .detachmentFailed(feature, _): + { + let description = switch feature { + case .messages: + "messages" + case .occupancy: + "occupancy" + case .presence: + "presence" + case .reactions: + "reactions" + case .typing: + "typing" + } + return "The \(description) feature failed to detach." + }() + } + } + + /// The ``ARTErrorInfo.cause`` that should be returned for this error. + internal var cause: ARTErrorInfo? { + switch self { + case let .channelAttachResultedInSuspended(underlyingError): + underlyingError + case let .channelAttachResultedInFailed(underlyingError): + underlyingError + case let .detachmentFailed(_, underlyingError): + underlyingError + case .inconsistentRoomOptions, + .roomInFailedState, + .roomIsReleasing, + .roomIsReleased: + nil } } } @@ -58,6 +144,11 @@ internal extension ARTErrorInfo { userInfo["ARTErrorInfoStatusCode"] = chatError.code.statusCode userInfo[NSLocalizedDescriptionKey] = chatError.localizedDescription + // TODO: This is kind of an implementation detail (that NSUnderlyingErrorKey is what populates `cause`); consider documenting in ably-cocoa as part of https://github.com/ably-labs/ably-chat-swift/issues/32. + if let cause = chatError.cause { + userInfo[NSUnderlyingErrorKey] = cause + } + self.init( domain: errorDomain, code: chatError.code.rawValue, diff --git a/Sources/AblyChat/RoomFeature.swift b/Sources/AblyChat/RoomFeature.swift new file mode 100644 index 00000000..e88aead5 --- /dev/null +++ b/Sources/AblyChat/RoomFeature.swift @@ -0,0 +1,8 @@ +/// The features offered by a chat room. +internal enum RoomFeature { + case messages + case presence + case reactions + case occupancy + case typing +} diff --git a/Sources/AblyChat/RoomLifecycleManager.swift b/Sources/AblyChat/RoomLifecycleManager.swift new file mode 100644 index 00000000..7533cdb5 --- /dev/null +++ b/Sources/AblyChat/RoomLifecycleManager.swift @@ -0,0 +1,276 @@ +import Ably + +/// The interface that the lifecycle manager expects its contributing realtime channels to conform to. +/// +/// We use this instead of the ``RealtimeChannel`` interface as its ``attach`` and ``detach`` methods are `async` instead of using callbacks. This makes it easier to write mocks for (since ``RealtimeChannel`` doesn’t express to the type system that the callbacks it receives need to be `Sendable`, it’s hard to, for example, create a mock that creates a `Task` and then calls the callback from inside this task). +/// +/// We choose to also mark the channel’s mutable state as `async`. This is a way of highlighting at the call site of accessing this state that, since `ARTRealtimeChannel` mutates this state on a separate thread, it’s possible for this state to have changed since the last time you checked it, or since the last time you performed an operation that might have mutated it, or since the last time you recieved an event informing you that it changed. +internal protocol RoomLifecycleContributorChannel: Sendable { + func attach() async throws + func detach() async throws + + var state: ARTRealtimeChannelState { get async } + var errorReason: ARTErrorInfo? { get async } +} + +// TODO: integrate with the rest of the SDK (this includes implementing CHA-RL3h, which is to tell ably-cocoa to release the channel when the `release` operation completes) +internal actor RoomLifecycleManager { + internal struct Contributor { + /// The room feature that this contributor corresponds to. Used only for choosing which error to throw when a contributor operation fails. + internal var feature: RoomFeature + + internal var channel: Channel + } + + internal private(set) var current: RoomLifecycle = .initialized + internal private(set) var error: ARTErrorInfo? + + private let logger: InternalLogger + private let clock: SimpleClock + private let contributors: [Contributor] + + internal init(contributors: [Contributor], logger: InternalLogger, clock: SimpleClock) { + self.contributors = contributors + self.logger = logger + self.clock = clock + } + + internal init(forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, contributors: [Contributor], logger: InternalLogger, clock: SimpleClock) { + self.current = current + self.contributors = contributors + self.logger = logger + self.clock = clock + } + + // TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36) + private var subscriptions: [Subscription] = [] + + internal func onChange(bufferingPolicy: BufferingPolicy) -> Subscription { + let subscription: Subscription = .init(bufferingPolicy: bufferingPolicy) + subscriptions.append(subscription) + return subscription + } + + /// Updates ``current`` and ``error`` and emits a status change event. + private func changeStatus(to new: RoomLifecycle, error: ARTErrorInfo? = nil) { + logger.log(message: "Transitioning from \(current) to \(new), error \(String(describing: error))", level: .info) + let previous = current + current = new + self.error = error + let statusChange = RoomStatusChange(current: current, previous: previous, error: error) + emitStatusChange(statusChange) + } + + private func emitStatusChange(_ change: RoomStatusChange) { + for subscription in subscriptions { + subscription.emit(change) + } + } + + /// Implements CHA-RL1’s `ATTACH` operation. + internal func performAttachOperation() async throws { + switch current { + case .attached: + // CHA-RL1a + return + case .releasing: + // CHA-RL1b + throw ARTErrorInfo(chatError: .roomIsReleasing) + case .released: + // CHA-RL1c + throw ARTErrorInfo(chatError: .roomIsReleased) + case .initialized, .suspended, .attaching, .detached, .detaching, .failed: + break + } + + // CHA-RL1e + changeStatus(to: .attaching) + + // CHA-RL1f + for contributor in contributors { + do { + logger.log(message: "Attaching contributor \(contributor)", level: .info) + try await contributor.channel.attach() + } catch { + let contributorState = await contributor.channel.state + logger.log(message: "Failed to attach contributor \(contributor), which is now in state \(contributorState), error \(error)", level: .info) + + switch contributorState { + case .suspended: + // CHA-RL1h2 TODO it's not clear what error is meant to be used now that Andy’s changed CHA-RL1h4 + guard let contributorError = await contributor.channel.errorReason else { + // TODO: something about this + preconditionFailure("Contributor entered SUSPENDED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .channelAttachResultedInSuspended(underlyingError: contributorError)) + changeStatus(to: .suspended, error: error) + + // CHA-RL1h3 + throw contributorError + case .failed: + // CHA-RL1h4 TODO Andy's updated the spec to say to use the error from attach + guard let contributorError = await contributor.channel.errorReason else { + // TODO: something about this + preconditionFailure("Contributor entered FAILED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .channelAttachResultedInFailed(underlyingError: contributorError)) + changeStatus(to: .failed, error: error) + + // CHA-RL1h5 — TODO Andy’s updated the spec to now say "asynchronously with respect to @CHA-RL1h4@", and also to specify the status code + await detachNonFailedContributors() + + // CHA-RL1h1 + throw contributorError + default: + // TODO: something about this; quite possible due to thread timing stuff + preconditionFailure("Attach failure left contributor in unexpected state \(contributorState)") + } + } + } + + // CHA-RL1g + changeStatus(to: .attached) + } + + /// Implements CHA-RL1h5’s "detach all channels that are not in the FAILED state". + private func detachNonFailedContributors() async { + for contributor in contributors where await (contributor.channel.state) != .failed { + // CHA-RL1h6: Retry until detach succeeds + while true { + do { + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.channel.detach() + break + } catch { + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Retrying.", level: .info) + // Loop repeats + } + } + } + } + + /// Implements CHA-RL2’s DETACH operation. + internal func performDetachOperation() async throws { + switch current { + case .detached: + // CHA-RL2a + return + case .releasing: + // CHA-RL2b + throw ARTErrorInfo(chatError: .roomIsReleasing) + case .released: + // CHA-RL2c + throw ARTErrorInfo(chatError: .roomIsReleased) + case .failed: + // CHA-RL2d TODO test + throw ARTErrorInfo(chatError: .roomInFailedState) + case .initialized, .suspended, .attaching, .attached, .detaching: + break + } + + // CHA-RL2e + changeStatus(to: .detaching) + + // CHA-RL2f + var firstDetachError: Error? + for contributor in contributors { + logger.log(message: "Detaching contributor \(contributor)", level: .info) + do { + try await contributor.channel.detach() + } catch { + let contributorState = await contributor.channel.state + logger.log(message: "Failed to detach contributor \(contributor), which is now in state \(contributorState), error \(error)", level: .info) + + switch contributorState { + case .failed: + // CHA-RL2h1 + guard let contributorError = await contributor.channel.errorReason else { + // TODO: something about this + preconditionFailure("Contributor entered FAILED but its errorReason is not set") + } + + let error = ARTErrorInfo(chatError: .detachmentFailed(feature: contributor.feature, underlyingError: contributorError)) + + if firstDetachError == nil { + // We’ll throw this after we’ve tried detaching all the channels + firstDetachError = error + } + + if current != .failed /* This check is CHA-RL2h2 (TODO: How to test?) */ { + changeStatus(to: .failed, error: error) + } + default: + // CHA-RL2h3: Retry until detach succeeds, with a pause before each attempt + while true { + do { + logger.log(message: "Will attempt to detach non-failed contributor \(contributor) in 1s.", level: .info) + // TODO: what's the correct wait time? + try await clock.sleep(nanoseconds: 1_000_000_000) + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.channel.detach() + break + } catch { + // Loop repeats + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Will retry.", level: .info) + } + } + } + } + } + + if let firstDetachError { + // CHA-RL2f + throw firstDetachError + } + + // CHA-RL2g + changeStatus(to: .detached) + } + + /// Implementes CHA-RL3’s RELEASE operation. + internal func performReleaseOperation() async { + switch current { + case .released: + // CHA-RL3a + return + case .detached: + // CHA-RL3b + changeStatus(to: .released) + return + case .releasing, .initialized, .attached, .attaching, .detaching, .suspended, .failed: + break + } + + changeStatus(to: .releasing) + + // CHA-RL3d, CHA-RL3e + for contributor in contributors where await (contributor.channel.state != .failed) { + logger.log(message: "Detaching contributor \(contributor)", level: .info) + do { + try await contributor.channel.detach() + } catch { + logger.log(message: "Failed to detach contributor \(contributor), error \(error)", level: .info) + + // CHA-RL3f: Retry until detach succeeds, with a pause before each attempt + while true { + do { + logger.log(message: "Will attempt to detach non-failed contributor \(contributor) in 1s.", level: .info) + // TODO: what's the correct wait time? + try await clock.sleep(nanoseconds: 1_000_000_000) + logger.log(message: "Detaching non-failed contributor \(contributor)", level: .info) + try await contributor.channel.detach() + break + } catch { + // Loop repeats + logger.log(message: "Failed to detach non-failed contributor \(contributor), error \(error). Will retry.", level: .info) + } + } + } + } + + // CHA-RL3g + changeStatus(to: .released) + } +} diff --git a/Sources/AblyChat/SimpleClock.swift b/Sources/AblyChat/SimpleClock.swift new file mode 100644 index 00000000..a7aeac15 --- /dev/null +++ b/Sources/AblyChat/SimpleClock.swift @@ -0,0 +1,7 @@ +/// A clock that causes the current task to sleep. +/// +/// Exists for mocking in tests. Note that we can’t use the Swift `Clock` type since it doesn’t exist in our minimum supported OS versions. +internal protocol SimpleClock: Sendable { + /// Behaves like `Task.sleep(nanoseconds:)`. + func sleep(nanoseconds duration: UInt64) async throws +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 669c23ed..ba88ab25 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -5,11 +5,30 @@ import XCTest /** Asserts that a given optional `Error` is an `ARTErrorInfo` in the chat error domain with a given code. */ -func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, file: StaticString = #filePath, line: UInt = #line) throws { +func assertIsChatError(_ maybeError: (any Error)?, withCode code: AblyChat.ErrorCode, cause: ARTErrorInfo? = nil, file: StaticString = #filePath, line: UInt = #line) throws { let error = try XCTUnwrap(maybeError, "Expected an error", file: file, line: line) let ablyError = try XCTUnwrap(error as? ARTErrorInfo, "Expected an ARTErrorInfo", file: file, line: line) XCTAssertEqual(ablyError.domain, AblyChat.errorDomain as String, file: file, line: line) XCTAssertEqual(ablyError.code, code.rawValue, file: file, line: line) XCTAssertEqual(ablyError.statusCode, code.statusCode, file: file, line: line) + XCTAssertEqual(ablyError.cause, cause, file: file, line: line) +} + +/** + Asserts that a given async expression throws an `ARTErrorInfo` in the chat error domain with a given code. + + Doesn't take an autoclosure because for whatever reason one of our linting tools removes the `await` on the expression. + */ +func assertThrowsARTErrorInfo(withCode code: AblyChat.ErrorCode, cause: ARTErrorInfo? = nil, _ expression: () async throws -> some Any, file: StaticString = #filePath, line: UInt = #line) async throws { + let caughtError: Error? + + do { + _ = try await expression() + caughtError = nil + } catch { + caughtError = error + } + + try assertIsChatError(caughtError, withCode: code, cause: cause, file: file, line: line) } diff --git a/Tests/AblyChatTests/Helpers/TestLogger.swift b/Tests/AblyChatTests/Helpers/TestLogger.swift index 265ec22b..27b19887 100644 --- a/Tests/AblyChatTests/Helpers/TestLogger.swift +++ b/Tests/AblyChatTests/Helpers/TestLogger.swift @@ -1,7 +1,8 @@ @testable import AblyChat struct TestLogger: InternalLogger { - func log(message _: String, level _: LogLevel, codeLocation _: CodeLocation) { + func log(message: String, level _: LogLevel, codeLocation _: CodeLocation) { // No-op; currently we don’t log in tests to keep the test logs easy to read. Can reconsider if necessary. + print(message) } } diff --git a/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift new file mode 100644 index 00000000..3778b750 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRoomLifecycleContributorChannel.swift @@ -0,0 +1,95 @@ +import Ably +@testable import AblyChat + +final actor MockRoomLifecycleContributorChannel: RoomLifecycleContributorChannel { + private let attachBehavior: AttachOrDetachBehavior? + private let detachBehavior: AttachOrDetachBehavior? + + var state: ARTRealtimeChannelState + var errorReason: ARTErrorInfo? + + private(set) var attachCallCount = 0 + private(set) var detachCallCount = 0 + + init( + initialState: ARTRealtimeChannelState, + attachBehavior: AttachOrDetachBehavior?, + detachBehavior: AttachOrDetachBehavior? + ) { + state = initialState + self.attachBehavior = attachBehavior + self.detachBehavior = detachBehavior + } + + enum AttachOrDetachResult { + case success + case failure(ARTErrorInfo) + + func performCallback(_ callback: ARTCallback?) { + switch self { + case .success: + callback?(nil) + case let .failure(error): + callback?(error) + } + } + } + + enum AttachOrDetachBehavior { + /// Receives an argument indicating how many times (including the current call) the method for which this is providing a mock implementation has been called. + case fromFunction(@Sendable (Int) async -> AttachOrDetachResult) + case complete(AttachOrDetachResult) + case completeAndChangeState(AttachOrDetachResult, newState: ARTRealtimeChannelState) + + static var success: Self { + .complete(.success) + } + + static func failure(_ error: ARTErrorInfo) -> Self { + .complete(.failure(error)) + } + } + + func attach() async throws { + attachCallCount += 1 + + guard let attachBehavior else { + fatalError("attachBehavior must be set before attach is called") + } + + try await performBehavior(attachBehavior, callCount: attachCallCount) + } + + func detach() async throws { + detachCallCount += 1 + + guard let detachBehavior else { + fatalError("detachBehavior must be set before detach is called") + } + + try await performBehavior(detachBehavior, callCount: detachCallCount) + } + + private func performBehavior(_ behavior: AttachOrDetachBehavior, callCount: Int) async throws { + let result: AttachOrDetachResult + switch behavior { + case let .fromFunction(function): + result = await function(callCount) + case let .complete(completeResult): + result = completeResult + case let .completeAndChangeState(completeResult, newState): + state = newState + if case let .failure(error) = completeResult { + errorReason = error + } + result = completeResult + } + + switch result { + case .success: + return + case let .failure(error): + throw error + } + } +} diff --git a/Tests/AblyChatTests/Mocks/MockSimpleClock.swift b/Tests/AblyChatTests/Mocks/MockSimpleClock.swift new file mode 100644 index 00000000..9ca1ffdf --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockSimpleClock.swift @@ -0,0 +1,9 @@ +@testable import AblyChat + +actor MockSimpleClock: SimpleClock { + private(set) var sleepCallArguments: [UInt64] = [] + + func sleep(nanoseconds duration: UInt64) async throws { + sleepCallArguments.append(duration) + } +} diff --git a/Tests/AblyChatTests/RoomLifecycleManagerTests.swift b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift new file mode 100644 index 00000000..6472fb60 --- /dev/null +++ b/Tests/AblyChatTests/RoomLifecycleManagerTests.swift @@ -0,0 +1,668 @@ +import Ably +@testable import AblyChat +import XCTest + +// TODO: note that this function can't be called multiple times +// TODO: document and see whether this is a good function +private func makeAsyncFunction() -> (returnFromFunction: @Sendable (MockRoomLifecycleContributorChannel.AttachOrDetachResult) -> Void, function: @Sendable (Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult) { + let (stream, continuation) = AsyncStream.makeStream(of: MockRoomLifecycleContributorChannel.AttachOrDetachResult.self) + return ( + returnFromFunction: { result in + continuation.yield(result) + }, + function: { _ in + await (stream.first { _ in true })! + } + ) +} + +private func createManager(contributors: [RoomLifecycleManager.Contributor] = [], clock: SimpleClock = MockSimpleClock()) -> RoomLifecycleManager { + .init(contributors: contributors, logger: TestLogger(), clock: clock) +} + +private func createManager(forTestingWhatHappensWhenCurrentlyIn current: RoomLifecycle, contributors: [RoomLifecycleManager.Contributor] = [], clock: SimpleClock = MockSimpleClock()) -> RoomLifecycleManager { + .init(forTestingWhatHappensWhenCurrentlyIn: current, contributors: contributors, logger: TestLogger(), clock: clock) +} + +private func createContributor( + initialState: ARTRealtimeChannelState = .initialized, + feature: RoomFeature = .messages, // Arbitrarily chosen, its value only matters in test cases where we check which error is thrown + attachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil, + detachBehavior: MockRoomLifecycleContributorChannel.AttachOrDetachBehavior? = nil +) -> RoomLifecycleManager.Contributor { + .init(feature: feature, channel: .init(initialState: initialState, attachBehavior: attachBehavior, detachBehavior: detachBehavior)) +} + +final class RoomLifecycleManagerTests: XCTestCase { + // @spec CHA-RS2a (TODO what's the best way to test this) + // @spec CHA-RS3 + func test_current_startsAsInitialized() async { + let manager = createManager() + + let current = await manager.current + XCTAssertEqual(current, .initialized) + } + + func test_error_startsAsNil() async { + let manager = createManager() + let error = await manager.error + XCTAssertNil(error) + } + + // MARK: - ATTACH operation + + // @spec CHA-RL1a + func test_attach_whenAlreadyAttached() async throws { + // Given: A RoomLifecycleManager in the ATTACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .attached, contributors: [contributor]) + + // 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) + let attachCallCount = await contributor.channel.attachCallCount + XCTAssertEqual(attachCallCount, 0) + } + + // @spec CHA-RL1b + func test_attach_whenReleasing() async throws { + // Given: A RoomLifecycleManager in the RELEASING state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleasing error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleasing) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1c + func test_attach_whenReleased() async throws { + // Given: A RoomLifecycleManager in the RELEASED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleased error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleased) { + try await manager.performAttachOperation() + } + } + + // @spec CHA-RL1e + func test_attach_transitionsToAttaching() async throws { + // Given: A RoomLifecycleManager, 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 state as being ATTACHING) + let (returnAttachResult, attachResult) = makeAsyncFunction() + + let manager = createManager(contributors: [createContributor(attachBehavior: .fromFunction(attachResult))]) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let _ = try await manager.performAttachOperation() + + // Then: It emits a status change to ATTACHING, and its current state is ATTACHING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .attaching) + + let current = await manager.current + XCTAssertEqual(current, .attaching) + + // Post-test: Now that we’ve seen the ATTACHING state, allow the contributor `attach` call to complete + returnAttachResult(.success) + } + + // @spec CHA-RL1f, CHA-RL1g + func test_attach_attachesAllContributors_andWhenTheyAllAttachSuccessfully_transitionsToAttached() async throws { + // Given: A RoomLifecycleManager, all of whose contributors’ calls to `attach` succeed + let contributors = (1 ... 3).map { _ in createContributor(attachBehavior: .complete(.success)) } + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let attachedStatusChange = statusChangeSubscription.first { $0.current == .attached } + + // 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 state is ATTACHED + for contributor in contributors { + let attachCallCount = await contributor.channel.attachCallCount + XCTAssertGreaterThan(attachCallCount, 0) + } + + guard let statusChange = await attachedStatusChange else { + XCTFail("Expected status change to ATTACHED but didn't get one") + return + } + + XCTAssertEqual(statusChange.current, .attached) + + let current = await manager.current + XCTAssertEqual(current, .attached) + } + + // @spec CHA-RL1h2 + // @specpartial CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering SUSPENDED + // @specpartial CHA-RL1h3 - tests which error gets thrown when room enters SUSPENDED + func test_attach_whenContributorFailsToAttachAndEntersSuspended_transitionsToSuspended() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the SUSPENDED state + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + createContributor(attachBehavior: .completeAndChangeState(.failure(attachError), newState: .suspended)) + } else { + createContributor(attachBehavior: .complete(.success)) + } + } + + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let suspendedStatusChange = statusChangeSubscription.first { $0.current == .suspended } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let roomAttachResult: Void = manager.performAttachOperation() + + // Then: + // + // 1. the room status transitions to SUSPENDED, with the state change’s `error` having `cause` equal to the channel’s `errorReason` + // 2. the room status’s `error` is set to this same error + // TODO: Andy has updated CHA-RL1h3; they're meant to be the same error now + // 3. the room attach operation fails with the channel’s `errorReason` + guard let suspendedStatusChange = await suspendedStatusChange else { + XCTFail("Expected status change to SUSPENDED but didn’t get one") + return + } + + XCTAssertEqual(suspendedStatusChange.error?.cause, attachError) + + let (current, error) = await (manager.current, manager.error) + XCTAssertEqual(current, .suspended) + XCTAssertEqual(error?.cause, attachError) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + let roomAttachARTErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo) + XCTAssertEqual(roomAttachARTErrorInfo, attachError) + } + + // @specpartial CHA-RL1h1 - tests that an error gets thrown when channel attach fails due to entering FAILED, but that spec point isn’t clear about what error should be thrown + // @spec CHA-RL1h4 + func test_attach_whenContributorFailsToAttachAndEntersFailed_transitionsToFailed() async throws { + // Given: A RoomLifecycleManager, one of whose contributors’ call to `attach` fails causing it to enter the FAILED state + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + let contributors = (1 ... 3).map { i in + if i == 1 { + createContributor( + attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed) + ) + } else { + createContributor( + attachBehavior: .complete(.success), + // The room is going to try to detach per CHA-RL1h6, so even though that's not what this test is testing, we need a detachBehavior so the mock doesn’t blow up + detachBehavior: .complete(.success) + ) + } + } + + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = statusChangeSubscription.first { $0.current == .failed } + + // When: `performAttachOperation()` is called on the lifecycle manager + async let roomAttachResult: Void = manager.performAttachOperation() + + // Then: + // 1. the room status transitions to FAILED, with the state change’s `error` having `cause` equal to the channel’s `errorReason` + // 2. the room status’s `error` is set to this same error + // 3. the room attach operation fails with this same error + guard let failedStatusChange = await failedStatusChange else { + XCTFail("Expected status change to FAILED but didn’t get one") + return + } + + XCTAssertEqual(failedStatusChange.error?.cause, attachError) + + let (current, error) = await (manager.current, manager.error) + XCTAssertEqual(current, .failed) + XCTAssertEqual(error?.cause, attachError) + + var roomAttachError: Error? + do { + _ = try await roomAttachResult + } catch { + roomAttachError = error + } + + let roomAttachARTErrorInfo = try XCTUnwrap(roomAttachError as? ARTErrorInfo) + XCTAssertEqual(roomAttachARTErrorInfo, attachError) + } + + // @spec CHA-RL1h5 + func test_attach_whenAttachPutsChannelIntoFailedState_detachesAllNonFailedChannels() 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: .complete(.success) + ), + createContributor( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + createContributor( + detachBehavior: .complete(.success) + ), + ] + + let manager = createManager(contributors: contributors) + + // When: `performAttachOperation()` is called on the lifecycle manager + try? await manager.performAttachOperation() + + // Then: + // + // - the lifecycle manager will call `detach` on contributors 0 and 2 + // - the lifecycle manager will not call `detach` on contributor 1 + // + // (TODO Note that we aren’t testing that the room _waits_ for the detach calls to complete, because I didn’t think of a good way) + let contributor0DetachCallCount = await contributors[0].channel.detachCallCount + XCTAssertGreaterThan(contributor0DetachCallCount, 0) + let contributor2DetachCallCount = await contributors[2].channel.detachCallCount + XCTAssertGreaterThan(contributor2DetachCallCount, 0) + let contributor1DetachCallCount = await contributors[1].channel.detachCallCount + XCTAssertEqual(contributor1DetachCallCount, 0) + } + + // @spec CHA-RL1h6 + // TODO: Andy has changed the wording of CHA-RL1h6, it no longer refers to the room status so change this test name + func test_attach_whenAttachPutsRoomIntoFailedState_ifADetachFailsItIsRetriedUntilSuccess() 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) + // - and for whom subsequently calling `detach` will fail on the first attempt and succeed on the second + // 1. a channel for whom calling `attach` will fail, putting it in the FAILED state (we won’t make any assertions about this channel; it’s just to trigger the room’s channel detach behaviour) + + let detachResult = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount == 1 { + return .failure(.create(withCode: 123, message: "")) + } else { + return .success + } + } + + let contributors = [ + createContributor( + attachBehavior: .completeAndChangeState(.success, newState: .attached), + detachBehavior: .fromFunction(detachResult) + ), + createContributor( + attachBehavior: .completeAndChangeState(.failure(.create(withCode: 123, message: "")), newState: .failed) + ), + ] + + let manager = createManager(contributors: contributors) + + // When: `performAttachOperation()` is called on the lifecycle manager + try? await manager.performAttachOperation() + + // Then: the lifecycle manager will call `detach` twice on contributor 0 (i.e. it will retry the failed detach) + let detachCallCount = await contributors[0].channel.detachCallCount + XCTAssertEqual(detachCallCount, 2) + } + + // MARK: - DETACH operation + + // @spec CHA-RL2a + func test_detach_whenAlreadyDetached() async throws { + // Given: A RoomLifecycleManager in the DETACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + + // 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) + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @spec CHA-RL2b + func test_detach_whenReleasing() async throws { + // Given: A RoomLifecycleManager in the RELEASING state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .releasing) + + // When: `performDetachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleasing error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleasing) { + try await manager.performDetachOperation() + } + } + + // @spec CHA-RL2c + func test_detach_whenReleased() async throws { + // Given: A RoomLifecycleManager in the RELEASED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomIsReleased error + try await assertThrowsARTErrorInfo(withCode: .roomIsReleased) { + try await manager.performDetachOperation() + } + } + + // @spec CHA-RL2c + func test_detach_whenFailed() async throws { + // Given: A RoomLifecycleManager in the FAILED state + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .failed) + + // When: `performAttachOperation()` is called on the lifecycle manager + // Then: It throws a roomInFailedState error + try await assertThrowsARTErrorInfo(withCode: .roomInFailedState) { + try await manager.performDetachOperation() + } + } + + // @specpartial CHA-RL2e - TODO I don't know what the "transient disconnect timeouts" means yet + func test_detach_transitionsToDetaching() async throws { + // Given: A RoomLifecycleManager, 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 state as being DETACHING) + let (returnDetachResult, detachResult) = makeAsyncFunction() + + let manager = createManager(contributors: [createContributor(detachBehavior: .fromFunction(detachResult))]) + let statusChangeSubscription = await manager.onChange(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, and its current state is DETACHING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .detaching) + + let current = await manager.current + XCTAssertEqual(current, .detaching) + + // Post-test: Now that we’ve seen the DETACHING state, allow the contributor `detach` call to complete + returnDetachResult(.success) + } + + // @spec CHA-RL2f, CHA-RL2g + func test_detach_detachesAllContributors_andWhenTheyAllDetachSuccessfully_transitionsToDetached() async throws { + // Given: A RoomLifecycleManager, all of whose contributors’ calls to `detach` succeed + let contributors = (1 ... 3).map { _ in createContributor(detachBehavior: .complete(.success)) } + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let detachedStatusChange = statusChangeSubscription.first { $0.current == .detached } + + // 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 state is DETACHED + for contributor in contributors { + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertGreaterThan(detachCallCount, 0) + } + + guard let statusChange = await detachedStatusChange else { + XCTFail("Expected status change to DETACHED but didn't get one") + return + } + + XCTAssertEqual(statusChange.current, .detached) + + let current = await manager.current + XCTAssertEqual(current, .detached) + } + + // @spec CHA-RL2h1 + func test_detach_whenAContributorFailsToDetachAndEntersFailed_detachesRemainingContributorsAndTransitionsToFailed() async throws { + // Given: A RoomLifecycleManager, 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) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let failedStatusChange = statusChangeSubscription.first { $0.current == .failed } + + // When: `performDetachOperation()` is called on the lifecycle manager + let maybeRoomDetachError: Error? + do { + try await manager.performDetachOperation() + maybeRoomDetachError = nil + } catch { + maybeRoomDetachError = error + } + + // Then: It: + // - calls `detach` on all of the contributors + // - emits a state change to FAILED and the call to `performDetachOperation()` fails; the error associated with the state change and the `performDetachOperation()` has the *DetachmentFailed code corresponding to contributor 1’s feature, and its `cause` is contributor 1’s `errorReason` (contributor 1 because it’s the "first feature to fail" as the spec says) + for contributor in contributors { + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertGreaterThan(detachCallCount, 0) + } + + guard let failedStateChange = await failedStatusChange else { + XCTFail("Expected state change to FAILED") + return + } + + for maybeError in [maybeRoomDetachError, failedStateChange.error] { + try assertIsChatError(maybeError, withCode: .presenceDetachmentFailed, cause: contributor1DetachError) + } + } + + // @spec CHA-RL2h3 + func test_detach_whenAContributorFailsToDetachAndEntersANonFailedState_pausesAWhileThenRetriesDetach() async throws { + // Given: A RoomLifecycleManager, 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 -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount < 3 { + return .failure(ARTErrorInfo(domain: "SomeDomain", code: 123)) // exact error is unimportant + } + return .success + } + let contributor = createContributor(initialState: .attached, detachBehavior: .fromFunction(detachImpl)) + let clock = MockSimpleClock() + + let manager = createManager(contributors: [contributor], clock: clock) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let asyncLetStatusChanges = Array(statusChangeSubscription.prefix(2)) + + // When: `performDetachOperation()` is called on the manager + // TODO: why does this need to capture the error? we just want it to succeed. check elsewhere for this pattern + let roomDetachError: Error? + do { + try await manager.performDetachOperation() + roomDetachError = nil + } catch { + roomDetachError = error + } + + // Then: It attempts to detach the channel 3 times, waiting 1s between each attempt, the room transitions from DETACHING to DETACHED with no status updates in between, and the call to `performDetachOperation()` succeeds + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(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?" + let clockSleepArguments = await clock.sleepCallArguments + XCTAssertEqual(clockSleepArguments, Array(repeating: 1_000_000_000, count: 2)) + + let statusChanges = await asyncLetStatusChanges + XCTAssertEqual(statusChanges.map(\.current), [.detaching, .detached]) + + XCTAssertNil(roomDetachError) + } + + // MARK: - RELEASE operation + + // @spec CHA-RL3a + func test_release_whenAlreadyReleased() async { + // Given: A RoomLifecycleManager in the RELEASED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .released, contributors: [contributor]) + + // 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) + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @spec CHA-RL3b + func test_release_whenDetached() async { + // Given: A RoomLifecycleManager in the DETACHED state + let contributor = createContributor() + let manager = createManager(forTestingWhatHappensWhenCurrentlyIn: .detached, contributors: [contributor]) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // 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") + guard let statusChange = await statusChange else { + XCTFail("Expected status change") + return + } + + XCTAssertEqual(statusChange.current, .released) + + let current = await manager.current + XCTAssertEqual(current, .released) + + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 0) + } + + // @specpartial CHA-RL3c - TODO I don't know what the "transient disconnect timeouts" means yet + func test_release_transitionsToReleasing() async { + // Given: A RoomLifecycleManager, 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 state as being RELEASING) + let (returnDetachResult, detachResult) = makeAsyncFunction() + + let manager = createManager(contributors: [createContributor(detachBehavior: .fromFunction(detachResult))]) + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let statusChange = statusChangeSubscription.first { _ in true } + + // When: `performReleaseOperation()` is called on the lifecycle manager + async let _ = await manager.performReleaseOperation() + + // Then: It emits a status change to RELEASING, and its current state is RELEASING + guard let statusChange = await statusChange else { + XCTFail("Expected status change but didn’t get one") + return + } + XCTAssertEqual(statusChange.current, .releasing) + + let current = await manager.current + XCTAssertEqual(current, .releasing) + + // Post-test: Now that we’ve seen the RELEASING state, allow the contributor `detach` call to complete + returnDetachResult(.success) + } + + // @spec CHA-RL3d, CHA-RL3e, CHA-RL3g + func test_release_detachesAllNonFailedContributors() async throws { + // Given: A RoomLifecycleManager, 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: .complete(.success)), + createContributor(initialState: .failed, detachBehavior: .complete(.failure(.init(domain: "SomeDomain", code: 123) /* arbitrary error */ ))), + createContributor(initialState: .detached /* arbitrary non-FAILED */, detachBehavior: .complete(.success)), + ] + + let manager = createManager(contributors: contributors) + + let statusChangeSubscription = await manager.onChange(bufferingPolicy: .unbounded) + async let releasedStatusChange = statusChangeSubscription.first { $0.current == .released } + + // When: `performReleaseOperation()` is called on the lifecycle manager + await manager.performReleaseOperation() + + // Then: + // - it calls `detach()` on the non-FAILED contributors + // - it does not call `detach()` on the FAILED contributor + // - the room transitions to RELEASED + // - the call to `performReleaseOperation()` completes + for nonFailedContributor in [contributors[0], contributors[2]] { + let detachCallCount = await nonFailedContributor.channel.detachCallCount + XCTAssertEqual(detachCallCount, 1) + } + + let failedContributorDetachCallCount = await contributors[1].channel.detachCallCount + XCTAssertEqual(failedContributorDetachCallCount, 0) + + _ = await releasedStatusChange + + let current = await manager.current + XCTAssertEqual(current, .released) + } + + // TODO: check CHA-RL3e for CHA-RL3f retries + + // @spec CHA-RL3f + func test_release_whenDetachFails_ifContributorIsNotFailed_retriesAfterPause() async { + // Given: A RoomLifecycleManager, with a contributor for which: + // - the first two times that `detach()` is called, it fails, leaving the contributor into a non-FAILED state + // - the third time that `detach()` is called, it succeeds + let detachImpl = { @Sendable (callCount: Int) async -> MockRoomLifecycleContributorChannel.AttachOrDetachResult in + if callCount < 3 { + return .failure(ARTErrorInfo(domain: "SomeDomain", code: 123)) // exact error is unimportant + } + return .success + } + let contributor = createContributor(detachBehavior: .fromFunction(detachImpl)) + + let clock = MockSimpleClock() + + let manager = createManager(contributors: [contributor], clock: clock) + + // Then: When `performReleaseOperation()` is called on the manager + await manager.performReleaseOperation() + + // It: calls `detach()` on the channel 3 times, with a 0.5s pause between each attempt, and the call to `performReleaseOperation` completes + let detachCallCount = await contributor.channel.detachCallCount + XCTAssertEqual(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?" + let clockSleepArguments = await clock.sleepCallArguments + XCTAssertEqual(clockSleepArguments, Array(repeating: 1_000_000_000, count: 2)) + } +}