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 610064c
Show file tree
Hide file tree
Showing 5 changed files with 450 additions and 26 deletions.
85 changes: 80 additions & 5 deletions Sources/AblyChat/DefaultTyping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ internal final class DefaultTyping: Typing {
private let clientID: String
private let logger: InternalLogger
private let timeout: TimeInterval
private let maxPresenseGetRetryDuration: TimeInterval // Max duration as specified in CHA-T6c1
private let timerManager = TimerManager()

internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval) {
internal init(featureChannel: FeatureChannel, roomID: String, clientID: String, logger: InternalLogger, timeout: TimeInterval, maxPresenseGetRetryDuration: TimeInterval = 30.0) {
self.roomID = roomID
self.featureChannel = featureChannel
self.clientID = clientID
self.logger = logger
self.timeout = timeout
self.maxPresenseGetRetryDuration = maxPresenseGetRetryDuration
}

internal nonisolated var channel: any RealtimeChannelProtocol {
Expand All @@ -32,18 +34,21 @@ internal final class DefaultTyping: Typing {
logger.log(message: "Received presence message: \(message)", level: .debug)
Task {
let currentEventID = await eventTracker.updateEventID()
let maxRetryDuration: TimeInterval = 30.0 // Max duration as specified in CHA-T6c1
let baseDelay: TimeInterval = 1.0 // Initial retry delay
let maxDelay: TimeInterval = 5.0 // Maximum delay between retries

var totalElapsedTime: TimeInterval = 0
var delay: TimeInterval = baseDelay

while totalElapsedTime < maxRetryDuration {
while totalElapsedTime < maxPresenseGetRetryDuration {
do {
// (CHA-T6c) When a presence event is received from the realtime client, the Chat client will perform a presence.get() operation to get the current presence set. This guarantees that we get a fully synced presence set. This is then used to emit the typing clients to the subscriber.
let latestTypingMembers = try await get()

#if DEBUG
for subscription in testPresenceGetTypingEventSubscriptions {
subscription.emit(.init())
}
#endif
// (CHA-T6c2) If multiple presence events are received resulting in concurrent presence.get() calls, then we guarantee that only the “latest” event is emitted. That is to say, if presence event A and B occur in that order, then only the typing event generated by B’s call to presence.get() will be emitted to typing subscribers.
let isLatestEvent = await eventTracker.isLatestEvent(currentEventID)
guard isLatestEvent else {
Expand All @@ -67,9 +72,19 @@ internal final class DefaultTyping: Typing {

// Exponential backoff (double the delay)
delay = min(delay * 2, maxDelay)
#if DEBUG
for subscription in testPresenceGetRetryTypingEventSubscriptions {
subscription.emit(.init())
}
#endif
}
}
logger.log(message: "Failed to fetch presence set after \(maxRetryDuration) seconds. Giving up.", level: .error)
#if DEBUG
for subscription in testPresenceGetRetryTypingEventSubscriptions {
subscription.unsubscribe()
}
#endif
logger.log(message: "Failed to fetch presence set after \(maxPresenseGetRetryDuration) seconds. Giving up.", level: .error)
}
}
return subscription
Expand Down Expand Up @@ -153,6 +168,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 +222,67 @@ 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>] = []

/// Subscription of presence get events for testing purposes.
private var testPresenceGetTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []

/// Subscription of retry presence get events for testing purposes.
private var testPresenceGetRetryTypingEventSubscriptions: [Subscription<TestTypingEvent>] = []

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

/// Returns a subscription which emits presence get events for testing purposes.
internal func testsOnly_subscribeToPresenceGetTypingEvents() -> Subscription<TestTypingEvent> {
let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
testPresenceGetTypingEventSubscriptions.append(subscription)
return subscription
}

/// Returns a subscription which emits retry presence get events for testing purposes.
internal func testsOnly_subscribeToPresenceGetRetryTypingEvents() -> Subscription<TestTypingEvent> {
let subscription = Subscription<TestTypingEvent>(bufferingPolicy: .unbounded)
testPresenceGetRetryTypingEventSubscriptions.append(subscription)
return subscription
}
#endif
}

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

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

Expand Down
32 changes: 16 additions & 16 deletions Tests/AblyChatTests/DefaultRoomPresenceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct DefaultRoomPresenceTests {
@Test
func usersMayEnterPresence() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client2", logger: TestLogger())
Expand All @@ -51,7 +51,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -85,7 +85,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -121,7 +121,7 @@ struct DefaultRoomPresenceTests {
@Test
func failToEnterPresenceWhenRoomInInvalidState() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].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))
Expand All @@ -147,7 +147,7 @@ struct DefaultRoomPresenceTests {
@Test
func usersMayUpdatePresence() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger())
Expand All @@ -171,7 +171,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -205,7 +205,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -241,7 +241,7 @@ struct DefaultRoomPresenceTests {
@Test
func failToUpdatePresenceWhenRoomInInvalidState() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].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))
Expand All @@ -266,7 +266,7 @@ struct DefaultRoomPresenceTests {
@Test
func usersMayLeavePresence() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger())
Expand All @@ -285,7 +285,7 @@ struct DefaultRoomPresenceTests {
@Test
func ifUserIsPresent() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) // CHA-PR6d
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand All @@ -306,7 +306,7 @@ struct DefaultRoomPresenceTests {
@Test
func retrieveAllTheMembersOfThePresenceSet() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand All @@ -322,7 +322,7 @@ struct DefaultRoomPresenceTests {
@Test
func failToRetrieveAllTheMembersOfThePresenceSetWhenRoomInInvalidState() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["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))
Expand All @@ -349,7 +349,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -383,7 +383,7 @@ struct DefaultRoomPresenceTests {
let lifecycleManager = await RoomLifecycleHelper.createManager(contributors: [contributor])

// Given: A DefaultPresence with DefaultFeatureChannel and MockRoomLifecycleContributor
let realtimePresence = MockRealtimePresence(["client1"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = DefaultFeatureChannel(channel: channel, contributor: contributor, roomLifecycleManager: lifecycleManager)
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -423,7 +423,7 @@ struct DefaultRoomPresenceTests {
@Test
func usersMaySubscribeToAllPresenceEvents() async throws {
// Given
let realtimePresence = MockRealtimePresence(["client1", "client2"].map { .init(clientId: $0) })
let realtimePresence = MockRealtimePresence(members: ["client1", "client2"].map { .init(clientId: $0) })
let channel = MockRealtimeChannel(name: "basketball::$chat::$chatMessages", mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(())) // CHA-PR6d
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "mockClientId", logger: TestLogger())
Expand Down Expand Up @@ -485,7 +485,7 @@ struct DefaultRoomPresenceTests {
@Test
func onDiscontinuity() async throws {
// Given
let realtimePresence = MockRealtimePresence([])
let realtimePresence = MockRealtimePresence(members: [])
let channel = MockRealtimeChannel(mockPresence: realtimePresence)
let featureChannel = MockFeatureChannel(channel: channel, resultOfWaitToBeAblePerformPresenceOperations: .success(()))
let defaultPresence = await DefaultPresence(featureChannel: featureChannel, roomID: "basketball", clientID: "client1", logger: TestLogger())
Expand Down
Loading

0 comments on commit 610064c

Please sign in to comment.