Skip to content

Commit

Permalink
Implement room status change upon attach or detach
Browse files Browse the repository at this point in the history
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 DefaultRoomStatus 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.
  • Loading branch information
lawrence-forooghian committed Sep 3, 2024
1 parent 09e54f6 commit 785c765
Show file tree
Hide file tree
Showing 4 changed files with 102 additions and 7 deletions.
7 changes: 5 additions & 2 deletions Sources/AblyChat/Room.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ internal actor DefaultRoom: Room {
fatalError("Not yet implemented")
}

public nonisolated var status: any RoomStatus {
fatalError("Not yet implemented")
private let _status = DefaultRoomStatus()
internal nonisolated var status: any RoomStatus {
_status
}

/// Fetches the channels that contribute to this room.
Expand All @@ -69,11 +70,13 @@ internal actor DefaultRoom: Room {
for channel in channels() {
try await channel.attachAsync()
}
await _status.transition(to: .attached)
}

public func detach() async throws {
for channel in channels() {
try await channel.detachAsync()
}
await _status.transition(to: .detached)
}
}
30 changes: 27 additions & 3 deletions Sources/AblyChat/RoomStatus.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import Ably

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

public enum RoomLifecycle: Sendable {
Expand All @@ -31,3 +31,27 @@ public struct RoomStatusChange: Sendable {
self.error = error
}
}

internal actor DefaultRoomStatus: RoomStatus {
internal private(set) var current: RoomLifecycle = .initialized
// TODO: populate this (https://github.com/ably-labs/ably-chat-swift/issues/28)
internal private(set) var error: ARTErrorInfo?

// TODO: clean up old subscriptions (https://github.com/ably-labs/ably-chat-swift/issues/36)
private var subscriptions: [Subscription<RoomStatusChange>] = []

internal func onChange(bufferingPolicy: BufferingPolicy) -> Subscription<RoomStatusChange> {
let subscription: Subscription<RoomStatusChange> = .init(bufferingPolicy: bufferingPolicy)
subscriptions.append(subscription)
return subscription
}

/// Sets ``current`` to the given state, and emits a status change to all subscribers added via ``onChange(bufferingPolicy:)``.
internal func transition(to newState: RoomLifecycle) {
let statusChange = RoomStatusChange(current: newState, previous: current)
current = newState
for subscription in subscriptions {
subscription.emit(statusChange)
}
}
}
46 changes: 46 additions & 0 deletions Tests/AblyChatTests/DefaultRoomStatusTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
@testable import AblyChat
import XCTest

class DefaultRoomStatusTests: XCTestCase {
func test_current_startsAsInitialized() async {
let status = DefaultRoomStatus()
let current = await status.current
XCTAssertEqual(current, .initialized)
}

func test_error_startsAsNil() async {
let status = DefaultRoomStatus()
let error = await status.error
XCTAssertNil(error)
}

func test_transition() async {
// Given: A RoomStatus
let status = DefaultRoomStatus()
let originalState = await status.current
let newState = RoomLifecycle.attached // arbitrary

let subscription1 = await status.onChange(bufferingPolicy: .unbounded)
let subscription2 = await status.onChange(bufferingPolicy: .unbounded)

async let statusChange1 = subscription1.first { $0.current == newState }
async let statusChange2 = subscription2.first { $0.current == newState }

// When: transition(to:) is called
await status.transition(to: newState)

// Then: It emits a status change to all subscribers added via onChange(bufferingPolicy:), and updates its `current` property to the new state
guard let statusChange1 = await statusChange1, let statusChange2 = await statusChange2 else {
XCTFail("Expected status changes to be emitted")
return
}

for statusChange in [statusChange1, statusChange2] {
XCTAssertEqual(statusChange.previous, originalState)
XCTAssertEqual(statusChange.current, newState)
}

let current = await status.current
XCTAssertEqual(current, .attached)
}
}
26 changes: 24 additions & 2 deletions Tests/AblyChatTests/DefaultRoomTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,24 @@ class DefaultRoomTests: XCTestCase {
let realtime = MockRealtime.create(channels: channels)
let room = DefaultRoom(realtime: realtime, roomID: "basketball", options: .init())

let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
async let attachedStatusChange = subscription.first { $0.current == .attached }

// When: `attach` is called on the room
try await room.attach()

// Then: `attach(_:)` is called on each of the channels, and the room `attach` call succeeds
// Then: `attach(_:)` is called on each of the channels, the room `attach` call succeeds, and the room transitions to ATTACHED
for channel in channelsList {
XCTAssertTrue(channel.attachCallCounter.isNonZero)
}

guard let attachedStatusChange = await attachedStatusChange else {
XCTFail("Expected status change to ATTACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .attached)
XCTAssertEqual(attachedStatusChange.current, .attached)
}

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

let subscription = await room.status.onChange(bufferingPolicy: .unbounded)
async let detachedStatusChange = subscription.first { $0.current == .detached }

// When: `detach` is called on the room
try await room.detach()

// Then: `detach(_:)` is called on each of the channels, and the room `detach` call succeeds
// Then: `detach(_:)` is called on each of the channels, the room `detach` call succeeds, and the room transitions to DETACHED
for channel in channelsList {
XCTAssertTrue(channel.detachCallCounter.isNonZero)
}

guard let detachedStatusChange = await detachedStatusChange else {
XCTFail("Expected status change to DETACHED but didn't get one")
return
}
let currentStatus = await room.status.current
XCTAssertEqual(currentStatus, .detached)
XCTAssertEqual(detachedStatusChange.current, .detached)
}

func test_detach_detachesAllChannels_andFailsIfOneFails() async throws {
Expand Down

0 comments on commit 785c765

Please sign in to comment.