diff --git a/Sources/AblyChat/DefaultTyping.swift b/Sources/AblyChat/DefaultTyping.swift index 89796ca..800a953 100644 --- a/Sources/AblyChat/DefaultTyping.swift +++ b/Sources/AblyChat/DefaultTyping.swift @@ -153,6 +153,11 @@ internal final class DefaultTyping: Typing { // (CHA-T5b) If typing is in progress, he CHA-T3 timeout is cancelled. The client then leaves presence. await timerManager.cancelTimer() channel.presence.leaveClient(clientID, data: nil) + #if DEBUG + for subscription in testStopTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif } else { // (CHA-T5a) If typing is not in progress, this operation is no-op. logger.log(message: "User is not typing. No need to leave presence.", level: .debug) @@ -202,12 +207,47 @@ internal final class DefaultTyping: Typing { try await stop() } } + #if DEBUG + for subscription in testStartTypingEventSubscriptions { + subscription.emit(.init()) + } + #endif } } } } +#if DEBUG + /// The `DefaultTyping` emits a `TestTypingEvent` each time ``start`` or ``stop`` is called. + internal struct TestTypingEvent: Equatable { + let timestamp = Date() + } + + /// Subscription of typing start events for testing purposes. + private var testStartTypingEventSubscriptions: [Subscription] = [] + + /// Subscription of typing stop events for testing purposes. + private var testStopTypingEventSubscriptions: [Subscription] = [] + + /// Returns a subscription which emits a typing start events for testing purposes. + internal func testsOnly_subscribeToStartTestTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testStartTypingEventSubscriptions.append(subscription) + return subscription + } + + /// Returns a subscription which emits a typing stop events for testing purposes. + internal func testsOnly_subscribeToStopTestTypingEvents() -> Subscription { + let subscription = Subscription(bufferingPolicy: .unbounded) + testStopTypingEventSubscriptions.append(subscription) + return subscription + } +#endif } +#if DEBUG +extension DefaultTyping: @unchecked Sendable { } +#endif + private final actor EventTracker { private var latestEventID: UUID = .init() diff --git a/Tests/AblyChatTests/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift new file mode 100644 index 0000000..a1bf55f --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomTypingTests.swift @@ -0,0 +1,270 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomTypingTests { + // @spec CHA-T2 + // @spec CHA-T2d + @Test + func retrieveCurrentlyTypingClientIDs() async throws { + // Given + let typingPresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // When + let typingInfo = try await defaultTyping.get() + + // Then + #expect(typingInfo.sorted() == ["client1", "client2"]) + } + + // @specPartial CHA-T2c + @Test + func retrieveCurrentlyTypingClientIDsWhileAttaching() async throws { + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.success, newState: .attached, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And presence get is called + _ = try await defaultTyping.get() + + // Then: The manager was waiting for its room status to change before presence `get` was called + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @specPartial CHA-T2c + @Test + func retrieveCurrentlyTypingClientIDsWhileAttachingWithFailure() async throws { + // Given: attachment failure + let attachError = ARTErrorInfo(domain: "SomeDomain", code: 123) + + // Given: A DefaultRoomLifecycleManager, with an ATTACH operation in progress and hence in the ATTACHING status + let contributor = RoomLifecycleHelper.createContributor(feature: .presence, attachBehavior: .completeAndChangeState(.failure(attachError), newState: .failed, delayInMilliseconds: RoomLifecycleHelper.fakeNetworkDelay)) + let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor]) + + // Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor + let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(mockPresence: realtimePresence) + let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + let attachingStatusWaitSubscription = await lifecycleManager.testsOnly_subscribeToStatusChangeWaitEvents() + + // When: The room is in the attaching state + let roomStatusSubscription = await lifecycleManager.onRoomStatusChange(bufferingPolicy: .unbounded) + + let attachOperationID = UUID() + async let _ = lifecycleManager.performAttachOperation(testsOnly_forcingOperationID: attachOperationID) + + // Wait for room to become ATTACHING + _ = try #require(await roomStatusSubscription.attachingElements().first { _ in true }) + + // When: And fails to attach + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultTyping.get() + } catch { + // Then: An exception with status code of 500 should be thrown + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 500) + #expect(error.code == ErrorCode.roomInInvalidState.rawValue) + throw error + } + } + // Then: The manager were waiting for its room status to change from attaching + _ = try #require(await attachingStatusWaitSubscription.first { _ in true }) + } + + // @spec CHA-T2g + @Test + func failToRetrieveCurrentlyTypingClientIDsWhenRoomInInvalidState() async throws { + // Given + let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) }) + let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence) + let error = ARTErrorInfo(chatError: .presenceOperationRequiresRoomAttach(feature: .presence)) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .failure(error)) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // Then + await #expect(throws: ARTErrorInfo.self) { + do { + _ = try await defaultTyping.get() + } catch { + let error = try #require(error as? ARTErrorInfo) + #expect(error.statusCode == 400) + #expect(error.localizedDescription.contains("attach")) + throw error + } + } + } + + // @spec CHA-T3 + @Test + func usersMayConfigureTimeoutForTyping() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + let channelsList = [ + MockRealtimeChannel(name: "basketball::$chat::$chatMessages", attachResult: .success), + MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", attachResult: .success, mockPresence: typingPresence), + ] + let channels = MockChannels(channels: channelsList) + let realtime = MockRealtime.create(channels: channels) + + // Given + let timeout = 0.5 // default is 5 (seconds) + let room = try await DefaultRoom(realtime: realtime, chatAPI: ChatAPI(realtime: realtime), roomID: "basketball", options: .init(typing: .init(timeout: timeout)), logger: TestLogger(), lifecycleManagerFactory: DefaultRoomLifecycleManagerFactory()) + + let defaultTyping = try #require(room.typing as? DefaultTyping) + let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents() + + try await room.attach() + + // When + try await defaultTyping.start() + let typingStartedAt = Date() + + // Then: The `DefaultTyping` will emit typing stop event in `timeout` interval +/- + let typingStopped = try #require(await typingStoppedSubscription.first { _ in true }) + let interval = typingStartedAt.distance(to: typingStopped.timestamp) + #expect(interval.isEqual(to: timeout, tolerance: 0.1)) + } + + // @spec CHA-T4a + // @spec CHA-T4a1 + // @spec CHA-T5a + // @spec CHA-T5b + @Test + func usersMayIndicateThatTheyHaveStartedOrStoppedTyping() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // CHA-T4a + + // When + try await defaultTyping.start() + + // Then: CHA-T4a1 + var typingInfo = try await defaultTyping.get() + #expect(typingInfo == ["client1"]) + + // CHA-T5b + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + + // CHA-T5a + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + } + + // @spec CHA-T4a2 + // @spec CHA-T4b + @Test + func ifTypingIsAlreadyInProgressThenTimeoutIsExtended() async throws { + // Given + let timeout = 0.5 + let typingPresence = MockRealtimePresence([]) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: timeout) + + let typingStartedSubscription = defaultTyping.testsOnly_subscribeToStartTestTypingEvents() + let typingStoppedSubscription = defaultTyping.testsOnly_subscribeToStopTestTypingEvents() + + // When: Typing is already in progress, the CHA-T3 timeout is extended to be timeoutMs from now + let timeoutExtension = 0.3 + try await defaultTyping.start() + try? await Task.sleep(nanoseconds: UInt64(timeoutExtension * 1_000_000_000)) + try await defaultTyping.start() // CHA-T4b + + let typingStarted = try #require(await typingStartedSubscription.first { _ in true }) + let typingStopped = try #require(await typingStoppedSubscription.first { _ in true }) // CHA-T4a2 + + // Then + let interval = typingStarted.timestamp.distance(to: typingStopped.timestamp) + #expect(interval.isEqual(to: timeout + timeoutExtension, tolerance: 0.1)) + } + + // @spec CHA-T6a + // @spec CHA-T6b + @Test + func usersMaySubscribeToTypingEvents() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators", mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // CHA-T6a + + // When + let subscription = await defaultTyping.subscribe() + + try await defaultTyping.start() + + // Then + let typingEvent = try #require(await subscription.first { _ in true }) + #expect(typingEvent.currentlyTyping == ["client1"]) + + // CHA-T6b + + // When + subscription.unsubscribe() + try await defaultTyping.stop() + try await defaultTyping.start() + + // Then + let nilTypingEvento = await subscription.first { _ in true } + #expect(nilTypingEvento == nil) + } + + // @spec CHA-T7 + @Test + func onDiscontinuity() async throws { + // Given + let typingPresence = MockRealtimePresence([]) + let channel = MockRealtimeChannel(mockPresence: typingPresence) + let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger(), timeout: 5) + + // When: The feature channel emits a discontinuity through `onDiscontinuity` + let featureChannelDiscontinuity = DiscontinuityEvent(error: ARTErrorInfo.createUnknownError()) // arbitrary error + let discontinuitySubscription = await defaultTyping.onDiscontinuity() + await featureChannel.emitDiscontinuity(featureChannelDiscontinuity) + + // Then: The DefaultOccupancy instance emits this discontinuity through `onDiscontinuity` + let discontinuity = try #require(await discontinuitySubscription.first { _ in true }) + #expect(discontinuity == featureChannelDiscontinuity) + } +} diff --git a/Tests/AblyChatTests/Helpers/Helpers.swift b/Tests/AblyChatTests/Helpers/Helpers.swift index 294a7be..fda4dbd 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -81,3 +81,9 @@ struct RoomLifecycleHelper { ) } } + +extension Double { + func isEqual(to other: Double, tolerance: Double) -> Bool { + self >= other && self < other + tolerance + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift index 46ca625..5e6bec5 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -5,6 +5,7 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc let syncComplete: Bool private var members: [ARTPresenceMessage] private var currentMember: ARTPresenceMessage? + private var subscribeCallback: ARTPresenceMessageCallback? init(syncComplete: Bool = true, _ members: [ARTPresenceMessage]) { self.syncComplete = syncComplete @@ -49,12 +50,16 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc func enterClient(_ clientId: String, data: Any?) { currentMember = ARTPresenceMessage(clientId: clientId, data: data) members.append(currentMember!) + currentMember!.action = .enter + subscribeCallback?(currentMember!) } func enterClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { currentMember = ARTPresenceMessage(clientId: clientId, data: data) members.append(currentMember!) callback?(nil) + currentMember!.action = .enter + subscribeCallback?(currentMember!) } func updateClient(_ clientId: String, data: Any?) { @@ -62,7 +67,12 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc } func updateClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { - members.first { $0.clientId == clientId }?.data = data + guard let member = members.first(where: { $0.clientId == clientId }) else { + preconditionFailure("Client \(clientId) doesn't exist in this presence set.") + } + member.action = .update + member.data = data + subscribeCallback?(member) callback?(nil) } @@ -74,8 +84,12 @@ final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenc members.removeAll { $0.clientId == clientId } } - func subscribe(_: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { - ARTEventListener() + func subscribe(_ callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + subscribeCallback = callback + for member in members { + subscribeCallback?(member) + } + return ARTEventListener() } func subscribe(attachCallback _: ARTCallback?, callback _: @escaping ARTPresenceMessageCallback) -> ARTEventListener? {