diff --git a/Sources/AblyChat/Dependencies.swift b/Sources/AblyChat/Dependencies.swift index 28c0891d..bdaedb50 100644 --- a/Sources/AblyChat/Dependencies.swift +++ b/Sources/AblyChat/Dependencies.swift @@ -25,6 +25,8 @@ public protocol RealtimeChannelsProtocol: ARTRealtimeChannelsProtocol, Sendable /// Expresses the requirements of the object returned by ``RealtimeChannelsProtocol.get(_:)``. public protocol RealtimeChannelProtocol: ARTRealtimeChannelProtocol, Sendable {} +public protocol RealtimePresenceProtocol: ARTRealtimePresenceProtocol, Sendable {} + public protocol ConnectionProtocol: ARTConnectionProtocol, Sendable {} /// Like (a subset of) `ARTRealtimeChannelOptions` but with value semantics. (It’s unfortunate that `ARTRealtimeChannelOptions` doesn’t have a `-copy` method.) diff --git a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift index 198a7b18..9b8d9707 100644 --- a/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift +++ b/Tests/AblyChatTests/DefaultRoomOccupancyTests.swift @@ -5,7 +5,7 @@ import Testing struct DefaultRoomOccupancyTests { // @spec CHA-O1 @Test - func init_channelNameIsSetAsChatMessagesChannelName() async throws { + func channelNameIsSetAsChatMessagesChannelName() async throws { // Given let realtime = MockRealtime.create() let chatAPI = ChatAPI(realtime: realtime) diff --git a/Tests/AblyChatTests/DefaultRoomTypingTests.swift b/Tests/AblyChatTests/DefaultRoomTypingTests.swift new file mode 100644 index 00000000..5d27e449 --- /dev/null +++ b/Tests/AblyChatTests/DefaultRoomTypingTests.swift @@ -0,0 +1,101 @@ +import Ably +@testable import AblyChat +import Testing + +struct DefaultRoomTypingTests { + // @spec CHA-T1 + @Test + func channelNameIsSetAsChatMessagesChannelName() async throws { + // Given + let channel = MockRealtimeChannel(name: "basketball::$chat::$typingIndicators") + let featureChannel = MockFeatureChannel(channel: channel) + + // When + let defaultTyping = DefaultTyping(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger(), timeout: 5) + + // Then + #expect(defaultTyping.channel.name == "basketball::$chat::$typingIndicators") + } + + // @spec CHA-T2 + @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"]) + } + + // @spec CHA-T4 + // @spec CHA-T5 + @Test + func usersMayIndicateThatTheyHaveStartedTyping() 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-T4 + + // When + try await defaultTyping.start() + + // Then + var typingInfo = try await defaultTyping.get() + #expect(typingInfo == ["client1"]) + + // CHA-T5 + + // When + try await defaultTyping.stop() + + // Then + typingInfo = try await defaultTyping.get() + #expect(typingInfo.isEmpty) + } + + // @spec CHA-T6 + @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) + + // When + let subscription = await defaultTyping.subscribe() + subscription.emit(TypingEvent(currentlyTyping: ["client1"])) + + // Then + let typingEvent = try #require(await subscription.first { _ in true }) + #expect(typingEvent.currentlyTyping == ["client1"]) + } + + // @spec CHA-T7 + @Test + func onDiscontinuity() 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) + + // 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 289adcd1..427f62ed 100644 --- a/Tests/AblyChatTests/Helpers/Helpers.swift +++ b/Tests/AblyChatTests/Helpers/Helpers.swift @@ -21,3 +21,10 @@ func isChatError(_ maybeError: (any Error)?, withCodeAndStatusCode codeAndStatus return ablyError.message == message }() } + +extension ARTPresenceMessage { + convenience init(clientId: String) { + self.init() + self.clientId = clientId + } +} diff --git a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift index d84900dd..9705e97d 100644 --- a/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift +++ b/Tests/AblyChatTests/Mocks/MockRealtimeChannel.swift @@ -2,16 +2,15 @@ import Ably import AblyChat final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { - var presence: ARTRealtimePresenceProtocol { - fatalError("Not implemented") - } - private let attachSerial: String? private let channelSerial: String? private let _name: String? + private let mockPresence: MockRealtimePresence! var properties: ARTChannelProperties { .init(attachSerial: attachSerial, channelSerial: channelSerial) } + var presence: ARTRealtimePresenceProtocol { mockPresence } + // I don't see why the nonisolated(unsafe) keyword would cause a problem when used for tests in this context. nonisolated(unsafe) var lastMessagePublishedName: String? nonisolated(unsafe) var lastMessagePublishedData: Any? @@ -22,13 +21,15 @@ final class MockRealtimeChannel: NSObject, RealtimeChannelProtocol { properties: ARTChannelProperties = .init(), state _: ARTRealtimeChannelState = .suspended, attachResult: AttachOrDetachResult? = nil, - detachResult: AttachOrDetachResult? = nil + detachResult: AttachOrDetachResult? = nil, + mockPresence: MockRealtimePresence! = nil ) { _name = name self.attachResult = attachResult self.detachResult = detachResult attachSerial = properties.attachSerial channelSerial = properties.channelSerial + self.mockPresence = mockPresence } /// A threadsafe counter that starts at zero. diff --git a/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift new file mode 100644 index 00000000..bc9bb603 --- /dev/null +++ b/Tests/AblyChatTests/Mocks/MockRealtimePresence.swift @@ -0,0 +1,105 @@ +import Ably +import AblyChat + +final class MockRealtimePresence: NSObject, @unchecked Sendable, RealtimePresenceProtocol { + let syncComplete: Bool + private var members: [ARTPresenceMessage] + + init(syncComplete: Bool = true, _ members: [ARTPresenceMessage]) { + self.syncComplete = syncComplete + self.members = members + } + + func get(_ callback: @escaping ARTPresenceMessagesCallback) { + callback(members, nil) + } + + func get(_ query: ARTRealtimePresenceQuery, callback: @escaping ARTPresenceMessagesCallback) { + fatalError("Not implemented") + } + + func enter(_ data: Any?) { + fatalError("Not implemented") + } + + func enter(_ data: Any?, callback: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func update(_ data: Any?) { + fatalError("Not implemented") + } + + func update(_ data: Any?, callback: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func leave(_ data: Any?) { + fatalError("Not implemented") + } + + func leave(_ data: Any?, callback: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func enterClient(_ clientId: String, data: Any?) { + members.append(ARTPresenceMessage(clientId: clientId)) + } + + func enterClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + members.append(ARTPresenceMessage(clientId: clientId)) + callback?(nil) + } + + func updateClient(_ clientId: String, data: Any?) { + fatalError("Not implemented") + } + + func updateClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + fatalError("Not implemented") + } + + func leaveClient(_ clientId: String, data: Any?) { + members.removeAll { $0.clientId == clientId } + } + + func leaveClient(_ clientId: String, data: Any?, callback: ARTCallback? = nil) { + members.removeAll { $0.clientId == clientId } + } + + func subscribe(_ callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(attachCallback onAttach: ARTCallback?, callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(_ action: ARTPresenceAction, callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func subscribe(_ action: ARTPresenceAction, onAttach: ARTCallback?, callback: @escaping ARTPresenceMessageCallback) -> ARTEventListener? { + ARTEventListener() + } + + func unsubscribe() { + fatalError("Not implemented") + } + + func unsubscribe(_ listener: ARTEventListener) { + fatalError("Not implemented") + } + + func unsubscribe(_ action: ARTPresenceAction, listener: ARTEventListener) { + fatalError("Not implemented") + } + + func history(_ callback: @escaping ARTPaginatedPresenceCallback) { + fatalError("Not implemented") + } + + func history(_ query: ARTRealtimeHistoryQuery?, callback: @escaping ARTPaginatedPresenceCallback) throws { + fatalError("Not implemented") + } +}