Skip to content

Commit

Permalink
Added typing tests.
Browse files Browse the repository at this point in the history
  • Loading branch information
maratal committed Jan 16, 2025
1 parent 444de76 commit 0f32941
Show file tree
Hide file tree
Showing 4 changed files with 333 additions and 3 deletions.
40 changes: 40 additions & 0 deletions Sources/AblyChat/DefaultTyping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<TestTypingEvent>] = []

/// Subscription of typing stop events for testing purposes.
private var testStopTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []

/// Returns a subscription which emits a typing start events for testing purposes.
internal func testsOnly_subscribeToStartTestTypingEvents() -> Subscription<TestTypingEvent> {
let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
testStartTypingEventSubscriptions.append(subscription)
return subscription
}

/// Returns a subscription which emits a typing stop events for testing purposes.
internal func testsOnly_subscribeToStopTestTypingEvents() -> Subscription<TestTypingEvent> {
let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
testStopTypingEventSubscriptions.append(subscription)
return subscription
}
#endif
}

#if DEBUG
extension DefaultTyping: @unchecked Sendable { }
#endif

private final actor EventTracker {
private var latestEventID: UUID = .init()

Expand Down
270 changes: 270 additions & 0 deletions Tests/AblyChatTests/DefaultRoomTypingTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
6 changes: 6 additions & 0 deletions Tests/AblyChatTests/Helpers/Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,9 @@ struct RoomLifecycleHelper {
)
}
}

extension Double {
func isEqual(to other: Double, tolerance: Double) -> Bool {
self >= other && self < other + tolerance
}
}
20 changes: 17 additions & 3 deletions Tests/AblyChatTests/Mocks/MockRealtimePresence.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -49,20 +50,29 @@ 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?) {
members.first { $0.clientId == clientId }?.data = data
}

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)
}

Expand All @@ -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? {
Expand Down

0 comments on commit 0f32941

Please sign in to comment.