Skip to content

Commit a63b22b

Browse files
Implement room status change upon attach or detach
Based on the simplified requirements described in #19. The decision from 7d6acde to use actors as the mechanism for managing mutable state means that I’ve had to make RoomStatus.{ current, error, onChange(bufferingPolicy:) } async. As mentioned there, if later on we decide this is too weird an API, then we can think of alternatives. I really wanted to avoid making DefaultRoomStatus an actor; I tried to find a way to isolate this state to the DefaultRoom actor (who logically owns this state), by trying to make the DefaultRoomStatus access the DefaultRoom-managed state, but I was not successful and didn’t want to sink much time into it. It means that DefaultRoom has suspension points in order to access its current state, which I am not happy about. But we can revisit later, perhaps armed with more knowledge of concurrency and in less of a rush to get some initial functionality implemented. Resolves #19.
1 parent 09e54f6 commit a63b22b

File tree

4 files changed

+103
-7
lines changed

4 files changed

+103
-7
lines changed

Sources/AblyChat/Room.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ internal actor DefaultRoom: Room {
2424
// Exposed for testing.
2525
internal nonisolated let realtime: RealtimeClient
2626

27+
private let _status = DefaultRoomStatus()
28+
2729
internal init(realtime: RealtimeClient, roomID: String, options: RoomOptions) {
2830
self.realtime = realtime
2931
self.roomID = roomID
@@ -50,8 +52,8 @@ internal actor DefaultRoom: Room {
5052
fatalError("Not yet implemented")
5153
}
5254

53-
public nonisolated var status: any RoomStatus {
54-
fatalError("Not yet implemented")
55+
internal nonisolated var status: any RoomStatus {
56+
_status
5557
}
5658

5759
/// Fetches the channels that contribute to this room.
@@ -69,11 +71,13 @@ internal actor DefaultRoom: Room {
6971
for channel in channels() {
7072
try await channel.attachAsync()
7173
}
74+
await _status.transition(to: .attached)
7275
}
7376

7477
public func detach() async throws {
7578
for channel in channels() {
7679
try await channel.detachAsync()
7780
}
81+
await _status.transition(to: .detached)
7882
}
7983
}

Sources/AblyChat/RoomStatus.swift

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import Ably
22

33
public protocol RoomStatus: AnyObject, Sendable {
4-
var current: RoomLifecycle { get }
4+
var current: RoomLifecycle { get async }
55
// TODO: (https://github.com/ably-labs/ably-chat-swift/issues/12): consider how to avoid the need for an unwrap
6-
var error: ARTErrorInfo? { get }
7-
func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange>
6+
var error: ARTErrorInfo? { get async }
7+
func onChange(bufferingPolicy: BufferingPolicy) async -> Subscription<RoomStatusChange>
88
}
99

1010
public enum RoomLifecycle: Sendable {
@@ -31,3 +31,27 @@ public struct RoomStatusChange: Sendable {
3131
self.error = error
3232
}
3333
}
34+
35+
internal actor DefaultRoomStatus: RoomStatus {
36+
internal private(set) var current: RoomLifecycle = .initialized
37+
// TODO: populate this (https://github.com/ably-labs/ably-chat-swift/issues/28)
38+
internal private(set) var error: ARTErrorInfo?
39+
40+
// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
41+
private var subscriptions: [Subscription<RoomStatusChange>] = []
42+
43+
internal func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange> {
44+
let subscription: Subscription<RoomStatusChange> = .init(bufferingPolicy: bufferingPolicy)
45+
subscriptions.append(subscription)
46+
return subscription
47+
}
48+
49+
/// Sets ``current`` to the given state, and emits a status change to all subscribers added via ``onChange(bufferingPolicy:)``.
50+
internal func transition(to newState: RoomLifecycle) {
51+
let statusChange = RoomStatusChange(current: newState, previous: current)
52+
current = newState
53+
for subscription in subscriptions {
54+
subscription.emit(statusChange)
55+
}
56+
}
57+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
@testable import AblyChat
2+
import XCTest
3+
4+
class DefaultRoomStatusTests: XCTestCase {
5+
func test_current_startsAsInitialized() async {
6+
let status = DefaultRoomStatus()
7+
let current = await status.current
8+
XCTAssertEqual(current, .initialized)
9+
}
10+
11+
func test_error_startsAsNil() async {
12+
let status = DefaultRoomStatus()
13+
let error = await status.error
14+
XCTAssertNil(error)
15+
}
16+
17+
func test_transition() async {
18+
// Given: A RoomStatus
19+
let status = DefaultRoomStatus()
20+
let originalState = await status.current
21+
let newState = RoomLifecycle.attached // arbitrary
22+
23+
let subscription1 = await status.onChange(bufferingPolicy: .unbounded)
24+
let subscription2 = await status.onChange(bufferingPolicy: .unbounded)
25+
26+
async let statusChange1 = subscription1.first { $0.current == newState }
27+
async let statusChange2 = subscription2.first { $0.current == newState }
28+
29+
// When: transition(to:) is called
30+
await status.transition(to: newState)
31+
32+
// Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `current` property to the new state
33+
guard let statusChange1 = await statusChange1, let statusChange2 = await statusChange2 else {
34+
XCTFail("Expected status changes to be emitted")
35+
return
36+
}
37+
38+
for statusChange in [statusChange1, statusChange2] {
39+
XCTAssertEqual(statusChange.previous, originalState)
40+
XCTAssertEqual(statusChange.current, newState)
41+
}
42+
43+
let current = await status.current
44+
XCTAssertEqual(current, .attached)
45+
}
46+
}

Tests/AblyChatTests/DefaultRoomTests.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,24 @@ class DefaultRoomTests: XCTestCase {
1818
let realtime = MockRealtime.create(channels: channels)
1919
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())
2020

21+
let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
22+
async let attachedStatusChange = subscription.first { $0.current == .attached }
23+
2124
// When: `attach` is called on the room
2225
try await room.attach()
2326

24-
// Then: `attach(_:)` is called on each of the channels, and the room `attach` call succeeds
27+
// Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED
2528
for channel in channelsList {
2629
XCTAssertTrue(channel.attachCallCounter.isNonZero)
2730
}
31+
32+
guard let attachedStatusChange = await attachedStatusChange else {
33+
XCTFail("Expected status change to ATTACHED but didn't get one")
34+
return
35+
}
36+
let currentStatus = await room.status.current
37+
XCTAssertEqual(currentStatus, .attached)
38+
XCTAssertEqual(attachedStatusChange.current, .attached)
2839
}
2940

3041
func test_attach_attachesAllChannels_andFailsIfOneFails() async throws {
@@ -73,13 +84,24 @@ class DefaultRoomTests: XCTestCase {
7384
let realtime = MockRealtime.create(channels: channels)
7485
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())
7586

87+
let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
88+
async let detachedStatusChange = subscription.first { $0.current == .detached }
89+
7690
// When: `detach` is called on the room
7791
try await room.detach()
7892

79-
// Then: `detach(_:)` is called on each of the channels, and the room `detach` call succeeds
93+
// Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED
8094
for channel in channelsList {
8195
XCTAssertTrue(channel.detachCallCounter.isNonZero)
8296
}
97+
98+
guard let detachedStatusChange = await detachedStatusChange else {
99+
XCTFail("Expected status change to DETACHED but didn't get one")
100+
return
101+
}
102+
let currentStatus = await room.status.current
103+
XCTAssertEqual(currentStatus, .detached)
104+
XCTAssertEqual(detachedStatusChange.current, .detached)
83105
}
84106

85107
func test_detach_detachesAllChannels_andFailsIfOneFails() async throws {

0 commit comments

Comments
 (0)