From 05d8f9dbb1ee6a29041fe220b7612c7a7e190ee0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:24:24 -0300 Subject: [PATCH 1/4] Update API used for fetching underlying objects from AblyPlugin See accompanying ably-cocoa change. The additional test dependencies in Package.swift are because I was getting "Undefined symbol: _OBJC_CLASS_$_APDefaultPublicRealtimeChannelUnderlyingObjects" when compiling. Now, not entirely sure _what_ was causing this error, but given that the tests do indeed import these two packages it seems reasonable enough to declare them as dependencies. --- Package.swift | 8 ++++++++ .../AblyLiveObjects/Internal/DefaultInternalPlugin.swift | 6 +++--- ably-cocoa | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index fef2737b..25fd4fce 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,14 @@ let package = Package( name: "AblyLiveObjectsTests", dependencies: [ "AblyLiveObjects", + .product( + name: "Ably", + package: "ably-cocoa", + ), + .product( + name: "AblyPlugin", + package: "ably-cocoa", + ), ], resources: [ .copy("ably-common"), diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index caeaf216..4859c08a 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -21,8 +21,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// /// We expect this value to have been previously set by ``prepare(_:)``. internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { - let pluginChannel = pluginAPI.channel(forPublicRealtimeChannel: channel) - return realtimeObjects(for: pluginChannel, pluginAPI: pluginAPI) + let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: channel) + return realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) } /// Retrieves the `RealtimeObjects` for this channel. @@ -41,7 +41,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: AblyPlugin.RealtimeChannel) { + internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client _: AblyPlugin.RealtimeClient) { let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) diff --git a/ably-cocoa b/ably-cocoa index 291758b8..4e0601a4 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 291758b843f80b088136a985f0ef8dd0c5627d05 +Subproject commit 4e0601a4567235a2e5afd36eb19d363057b642da From 2a0b3a216cc823089fb7db1d74582b58177c73b0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 14:52:51 -0300 Subject: [PATCH 2/4] Make individual options optional in client options passed to realtimeWithObjects helper Preparation for pulling this out to be used more widely. --- .../Helpers/ClientHelper.swift | 36 +++++++++++++++++++ .../ObjectsIntegrationTests.swift | 27 +++++++------- 2 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift new file mode 100644 index 00000000..8c6f9a0a --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -0,0 +1,36 @@ +import Ably +import AblyLiveObjects + +/// Helper for creating ably-cocoa objects, for use in integration tests. +enum ClientHelper { + /// Creates a sandbox Realtime client with LiveObjects support. + static func realtimeWithObjects(options: PartialClientOptions = .init()) async throws -> ARTRealtime { + let key = try await Sandbox.fetchSharedAPIKey() + let clientOptions = ARTClientOptions(key: key) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + clientOptions.environment = "sandbox" + + clientOptions.testOptions.transportFactory = TestProxyTransportFactory() + + if TestLogger.loggingEnabled { + clientOptions.logLevel = .verbose + } + + if let useBinaryProtocol = options.useBinaryProtocol { + clientOptions.useBinaryProtocol = useBinaryProtocol + } + + return ARTRealtime(options: clientOptions) + } + + /// Creates channel options that include the channel modes needed for LiveObjects. + static func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + let options = ARTRealtimeChannelOptions() + options.modes = [.objectSubscribe, .objectPublish] + return options + } + + struct PartialClientOptions: Encodable, Hashable { + var useBinaryProtocol: Bool? + } +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 502d0b91..b2a3d870 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -9,7 +9,7 @@ import Testing // MARK: - Top-level helpers -private func realtimeWithObjects(options: PartialClientOptions?) async throws -> ARTRealtime { +private func realtimeWithObjects(options: PartialClientOptions = .init()) async throws -> ARTRealtime { let key = try await Sandbox.fetchSharedAPIKey() let clientOptions = ARTClientOptions(key: key) clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] @@ -21,8 +21,8 @@ private func realtimeWithObjects(options: PartialClientOptions?) async throws -> clientOptions.logLevel = .verbose } - if let options { - clientOptions.useBinaryProtocol = options.useBinaryProtocol + if let useBinaryProtocol = options.useBinaryProtocol { + clientOptions.useBinaryProtocol = useBinaryProtocol } return ARTRealtime(options: clientOptions) @@ -152,7 +152,7 @@ private let objectsFixturesChannel = "objects_fixtures" private struct TestCase: Identifiable, CustomStringConvertible { var disabled: Bool var scenario: TestScenario - var options: PartialClientOptions? + var options: PartialClientOptions var channelName: String /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) @@ -164,8 +164,8 @@ private struct TestCase: Identifiable, CustomStringConvertible { var description: String { var result = scenario.description - if let options { - result += " (\(options.useBinaryProtocol ? "binary" : "text"))" + if let useBinaryProtocol = options.useBinaryProtocol { + result += " (\(useBinaryProtocol ? "binary" : "text"))" } return result @@ -179,7 +179,7 @@ private struct TestCaseID: Encodable, Hashable { } private struct PartialClientOptions: Encodable, Hashable { - var useBinaryProtocol: Bool + var useBinaryProtocol: Bool? } /// The input to `forScenarios`. @@ -193,19 +193,16 @@ private struct TestScenario { private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { scenarios.map { scenario -> [TestCase] in if scenario.allTransportsAndProtocols { - [ - PartialClientOptions(useBinaryProtocol: true), - PartialClientOptions(useBinaryProtocol: false), - ].map { options -> TestCase in + [true, false].map { useBinaryProtocol -> TestCase in .init( disabled: scenario.disabled, scenario: scenario, - options: options, - channelName: "\(scenario.description) \(options.useBinaryProtocol ? "binary" : "text")", + options: .init(useBinaryProtocol: useBinaryProtocol), + channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", ) } } else { - [.init(disabled: scenario.disabled, scenario: scenario, options: nil, channelName: scenario.description)] + [.init(disabled: scenario.disabled, scenario: scenario, options: .init(), channelName: scenario.description)] } } .flatMap(\.self) @@ -271,7 +268,7 @@ private struct ObjectsIntegrationTests { var channelName: String var channel: ARTRealtimeChannel var client: ARTRealtime - var clientOptions: PartialClientOptions? + var clientOptions: PartialClientOptions } static let scenarios: [TestScenario] = { From ed082236aa9f03bf654684e57175806b2d462462 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 14:59:20 -0300 Subject: [PATCH 3/4] Extract integration tests helper Will use in some upcoming tests for object lifetimes. --- .../ObjectsIntegrationTests.swift | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index b2a3d870..9b14499a 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -9,29 +9,12 @@ import Testing // MARK: - Top-level helpers -private func realtimeWithObjects(options: PartialClientOptions = .init()) async throws -> ARTRealtime { - let key = try await Sandbox.fetchSharedAPIKey() - let clientOptions = ARTClientOptions(key: key) - clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] - clientOptions.environment = "sandbox" - - clientOptions.testOptions.transportFactory = TestProxyTransportFactory() - - if TestLogger.loggingEnabled { - clientOptions.logLevel = .verbose - } - - if let useBinaryProtocol = options.useBinaryProtocol { - clientOptions.useBinaryProtocol = useBinaryProtocol - } - - return ARTRealtime(options: clientOptions) +private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { + try await ClientHelper.realtimeWithObjects(options: options) } private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { - let options = ARTRealtimeChannelOptions() - options.modes = [.objectSubscribe, .objectPublish] - return options + ClientHelper.channelOptionsWithObjects() } // Swift version of the JS lexicoTimeserial function @@ -152,7 +135,7 @@ private let objectsFixturesChannel = "objects_fixtures" private struct TestCase: Identifiable, CustomStringConvertible { var disabled: Bool var scenario: TestScenario - var options: PartialClientOptions + var options: ClientHelper.PartialClientOptions var channelName: String /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) @@ -175,11 +158,7 @@ private struct TestCase: Identifiable, CustomStringConvertible { /// Enables `TestCase`'s conformance to `Identifiable`. private struct TestCaseID: Encodable, Hashable { var description: String - var options: PartialClientOptions? -} - -private struct PartialClientOptions: Encodable, Hashable { - var useBinaryProtocol: Bool? + var options: ClientHelper.PartialClientOptions? } /// The input to `forScenarios`. @@ -268,7 +247,7 @@ private struct ObjectsIntegrationTests { var channelName: String var channel: ARTRealtimeChannel var client: ARTRealtime - var clientOptions: PartialClientOptions + var clientOptions: ClientHelper.PartialClientOptions } static let scenarios: [TestScenario] = { From dcbe617cf9c154ff5ead1dde7b601f8e79525d50 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 9 Jul 2025 13:06:45 -0300 Subject: [PATCH 4/4] Implement memory management pattern similar to ably-cocoa We adopt a pattern whereby the LiveObjects SDK functions correctly as long as the user is holding a reference to any object vended by the public API of the SDK. We do this by copying the ably-cocoa approach of having separate public and internal versions of objects. All written by me, except for getting Cursor to help with updating the tests in response to the new way of injecting CoreSDK. Resolves #9. --- CONTRIBUTING.md | 22 ++ .../AblyLiveObjects/Internal/CoreSDK.swift | 19 +- .../Internal/DefaultInternalPlugin.swift | 17 +- ...swift => InternalDefaultLiveCounter.swift} | 42 ++- ...Map.swift => InternalDefaultLiveMap.swift} | 157 ++++------- .../InternalDefaultRealtimeObjects.swift} | 44 +-- .../Internal/InternalLiveMapValue.swift | 54 ++++ .../Internal/LiveObjectMutableState.swift | 2 +- .../Internal/ObjectsPool.swift | 36 +-- .../Public/ARTRealtimeChannel+Objects.swift | 27 +- Sources/AblyLiveObjects/Public/Plugin.swift | 5 +- .../InternalLiveMapValue+ToPublic.swift | 38 +++ .../PublicDefaultLiveCounter.swift | 52 ++++ .../PublicDefaultLiveMap.swift | 98 +++++++ .../PublicDefaultRealtimeObjects.swift | 81 ++++++ .../PublicObjectsStore.swift | 129 +++++++++ Sources/AblyLiveObjects/Utility/WeakRef.swift | 8 - .../AblyLiveObjectsTests.swift | 12 +- .../Helpers/ClientHelper.swift | 4 + .../Helpers/TestFactories.swift | 8 +- ... => InternalDefaultLiveCounterTests.swift} | 87 +++--- ...wift => InternalDefaultLiveMapTests.swift} | 260 ++++++++---------- ...InternalDefaultRealtimeObjectsTests.swift} | 139 +++++----- .../ObjectsIntegrationTests.swift | 18 -- .../ObjectLifetimesTests.swift | 237 ++++++++++++++++ .../ObjectsPoolTests.swift | 119 ++++---- 26 files changed, 1137 insertions(+), 578 deletions(-) rename Sources/AblyLiveObjects/Internal/{DefaultLiveCounter.swift => InternalDefaultLiveCounter.swift} (87%) rename Sources/AblyLiveObjects/Internal/{DefaultLiveMap.swift => InternalDefaultLiveMap.swift} (81%) rename Sources/AblyLiveObjects/{DefaultRealtimeObjects.swift => Internal/InternalDefaultRealtimeObjects.swift} (91%) create mode 100644 Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift rename Tests/AblyLiveObjectsTests/{DefaultLiveCounterTests.swift => InternalDefaultLiveCounterTests.swift} (75%) rename Tests/AblyLiveObjectsTests/{DefaultLiveMapTests.swift => InternalDefaultLiveMapTests.swift} (80%) rename Tests/AblyLiveObjectsTests/{DefaultRealtimeObjectsTests.swift => InternalDefaultRealtimeObjectsTests.swift} (87%) create mode 100644 Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75fe28d0..462785bc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,28 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. - `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this. +### Memory management + +We follow an approach to memory management that is broadly similar to that of ably-cocoa: we keep all of the internal components of the SDK alive as long as the user is holding a strong reference to any object vended by the public API of the SDK. This means that, for example, a user can use LiveObjects functionality by maintaining only a reference to the root `LiveMap`; even if they relinquish their references to, say, the `ARTRealtime` or `ARTRealtimeChannel` instance, they will continue to receive events from the `LiveMap` and they will be able to use its methods. + +We achieve this by vending a set of public types which maintain strong references to all of the internal components of the SDK which are needed in order for the public type to function correctly. For example, the public `PublicDefaultLiveMap` type wraps an `InternalDefaultLiveMap`, and holds strong references to the `CoreSDK` object, which in turn holds the following sequence of strong references: `CoreSDK` → `RealtimeClient` → `RealtimeChannel` → `InternalDefaultRealtimeObjects`, thus ensuring that: + +1. the `InternalDefaultLiveMap` can always perform actions on these dependencies in response to a user action +2. these dependencies, which deliver events to the `InternalDefaultLiveMap`, remain alive and thus remain delivering events + +The key rules that must be followed in order to avoid a strong reference cycle are that the SDK's _internal_ classes (that is `InternalDefaultLiveMap` etc) _must never hold a strong reference to_: + +- any of the corresponding public types (e.g. `PublicDefaultLiveMap`) +- any of the ably-cocoa components that hold a strong reference to these internal components (that is, the realtime client or channel); thus, they must never hold a strong reference to a `CoreSDK` object + +Note that, unlike ably-cocoa, the internal components do not even hold weak references to their dependencies; rather, these dependencies are passed in by the public object when the user performs an action that requires one of these dependencies. (There may turn out to be limitations to this approach, but haven't found them yet.) + +Also note that, unlike ably-cocoa, we aim to provide a stable pointer identity for the public objects vended by the SDK, instead of creating a new object each time the user requests one. See the `PublicObjectsStore` class for more details. + +The `Public…` classes all follow the same pattern and are not very interesting; the business logic should be in the `Internal…` classes and those should be where we focus our unit testing effort. + +`ObjectLifetimesTests.swift` contains tests of the behaviour described in this section. + ### Testing guidelines #### Attributing tests to a spec point diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 9f4f623a..e7bc5b9e 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -12,29 +12,20 @@ internal protocol CoreSDK: AnyObject, Sendable { } internal final class DefaultCoreSDK: CoreSDK { - // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. - private let weakChannel: WeakRef + private let channel: AblyPlugin.RealtimeChannel + private let client: AblyPlugin.RealtimeClient private let pluginAPI: PluginAPIProtocol internal init( channel: AblyPlugin.RealtimeChannel, + client: AblyPlugin.RealtimeClient, pluginAPI: PluginAPIProtocol ) { - weakChannel = .init(referenced: channel) + self.channel = channel + self.client = client self.pluginAPI = pluginAPI } - // MARK: - Fetching channel - - private var channel: AblyPlugin.RealtimeChannel { - guard let channel = weakChannel.referenced else { - // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. - preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") - } - - return channel - } - // MARK: - CoreSDK conformance internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 4859c08a..b80a86aa 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -17,25 +17,17 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property. private static let pluginDataKey = "LiveObjects" - /// Retrieves the value that should be returned by `ARTRealtimeChannel.objects`. - /// - /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { - let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: channel) - return realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) - } - /// Retrieves the `RealtimeObjects` for this channel. /// /// We expect this value to have been previously set by ``prepare(_:)``. - private static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { + internal static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { guard let pluginData = pluginAPI.pluginDataValue(forKey: pluginDataKey, channel: channel) else { // InternalPlugin.prepare was not called fatalError("To access LiveObjects functionality, you must pass the LiveObjects plugin in the client options when creating the ARTRealtime instance: `clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]`") } // swiftlint:disable:next force_cast - return pluginData as! DefaultRealtimeObjects + return pluginData as! InternalDefaultRealtimeObjects } // MARK: - LiveObjectsInternalPluginProtocol @@ -45,13 +37,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI) - let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } /// Retrieves the internally-typed `objects` property for the channel. - private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> DefaultRealtimeObjects { + private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> InternalDefaultRealtimeObjects { Self.realtimeObjects(for: channel, pluginAPI: pluginAPI) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift similarity index 87% rename from Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index b4c9603a..7876aa0b 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -2,8 +2,8 @@ import Ably internal import AblyPlugin import Foundation -/// Our default implementation of ``LiveCounter``. -internal final class DefaultLiveCounter: LiveCounter { +/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +internal final class InternalDefaultLiveCounter: Sendable { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() @@ -27,7 +27,6 @@ internal final class DefaultLiveCounter: LiveCounter { } } - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -35,20 +34,17 @@ internal final class DefaultLiveCounter: LiveCounter { internal convenience init( testsOnly_data data: Double, objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { - self.init(data: data, objectID: objectID, coreSDK: coreSDK, logger: logger) + self.init(data: data, objectID: objectID, logger: logger) } private init( data: Double, objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) - self.coreSDK = coreSDK self.logger = logger } @@ -58,35 +54,31 @@ internal final class DefaultLiveCounter: LiveCounter { /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) -> Self { .init( data: 0, objectID: objectID, - coreSDK: coreSDK, logger: logger, ) } - // MARK: - LiveCounter conformance + // MARK: - Internal methods that back LiveCounter conformance - internal var value: Double { - get throws(ARTErrorInfo) { - // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveCounter.value", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveCounter.value", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLC5c - mutableState.data - } + return mutex.withLock { + // RTLC5c + mutableState.data } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift similarity index 81% rename from Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index d9a5af6b..56be4a2f 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -7,8 +7,8 @@ internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { func getObjectFromPool(id: String) -> ObjectsPool.Entry? } -/// Our default implementation of ``LiveMap``. -internal final class DefaultLiveMap: LiveMap { +/// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. +internal final class InternalDefaultLiveMap: Sendable { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() @@ -44,13 +44,6 @@ internal final class DefaultLiveMap: LiveMap { } } - /// Delegate for accessing objects from the pool - private let delegate: WeakLiveMapObjectPoolDelegateRef - internal var testsOnly_delegate: LiveMapObjectPoolDelegate? { - delegate.referenced - } - - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -59,16 +52,12 @@ internal final class DefaultLiveMap: LiveMap { testsOnly_data data: [String: ObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { self.init( data: data, objectID: objectID, semantics: semantics, - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) } @@ -77,13 +66,9 @@ internal final class DefaultLiveMap: LiveMap { data: [String: ObjectsMapEntry], objectID: String, semantics: WireEnum?, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) - self.delegate = .init(referenced: delegate) - self.coreSDK = coreSDK self.logger = logger } @@ -95,24 +80,20 @@ internal final class DefaultLiveMap: LiveMap { internal static func createZeroValued( objectID: String, semantics: WireEnum? = nil, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) -> Self { .init( data: [:], objectID: objectID, semantics: semantics, - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) } - // MARK: - LiveMap conformance + // MARK: - Internal methods that back LiveMap conformance /// Returns the value associated with a given key, following RTLM5d specification. - internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { @@ -133,73 +114,65 @@ internal final class DefaultLiveMap: LiveMap { } // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return convertEntryToLiveMapValue(entry) + return convertEntryToLiveMapValue(entry, delegate: delegate) } - internal var size: Int { - get throws(ARTErrorInfo) { - // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.size", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func size(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Int { + // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.size", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map - mutableState.data.values.count { entry in - // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true - // RTLM14b: Otherwise, it returns false - entry.tombstone != true - } + return mutex.withLock { + // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map + mutableState.data.values.count { entry in + // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true + // RTLM14b: Otherwise, it returns false + entry.tombstone != true } } } - internal var entries: [(key: String, value: LiveMapValue)] { - get throws(ARTErrorInfo) { - // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.entries", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.entries", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLM11d: Returns key-value pairs from the internal data map - // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned - var result: [(key: String, value: LiveMapValue)] = [] + return mutex.withLock { + // RTLM11d: Returns key-value pairs from the internal data map + // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned + var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in mutableState.data { - // Convert entry to LiveMapValue using the same logic as get(key:) - if let value = convertEntryToLiveMapValue(entry) { - result.append((key: key, value: value)) - } + for (key, entry) in mutableState.data { + // Convert entry to LiveMapValue using the same logic as get(key:) + if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { + result.append((key: key, value: value)) } - - return result } + + return result } } - internal var keys: [String] { - get throws(ARTErrorInfo) { - // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map - try entries.map(\.key) - } + internal func keys(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [String] { + // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map + try entries(coreSDK: coreSDK, delegate: delegate).map(\.key) } - internal var values: [LiveMapValue] { - get throws(ARTErrorInfo) { - // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map - try entries.map(\.value) - } + internal func values(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [InternalLiveMapValue] { + // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map + try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) } internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { @@ -237,8 +210,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.replaceData( using: state, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -250,8 +221,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.mergeInitialValue( from: operation, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -263,8 +232,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.applyMapCreateOperation( operation, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -283,8 +250,6 @@ internal final class DefaultLiveMap: LiveMap { objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -305,8 +270,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: operationTimeserial, operationData: operationData, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -343,8 +306,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func replaceData( using state: ObjectState, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials @@ -361,8 +322,6 @@ internal final class DefaultLiveMap: LiveMap { mergeInitialValue( from: createOp, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -372,8 +331,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func mergeInitialValue( from operation: ObjectOperation, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries @@ -394,8 +351,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: entry.timeserial, operationData: entry.data, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -411,8 +366,6 @@ internal final class DefaultLiveMap: LiveMap { objectMessageSerial: String?, objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: Logger, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { @@ -430,8 +383,6 @@ internal final class DefaultLiveMap: LiveMap { applyMapCreateOperation( operation, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) case .known(.mapSet): @@ -450,8 +401,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: applicableOperation.objectMessageSerial, operationData: data, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) case .known(.mapRemove): @@ -476,8 +425,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: String?, operationData: ObjectData, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM7a: If an entry exists in the private data for the specified key @@ -505,7 +452,7 @@ internal final class DefaultLiveMap: LiveMap { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK, logger: logger) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger) } } @@ -580,8 +527,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func applyMapCreateOperation( _ operation: ObjectOperation, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { if liveObject.createOperationIsMerged { @@ -596,8 +541,6 @@ internal final class DefaultLiveMap: LiveMap { mergeInitialValue( from: operation, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -607,7 +550,7 @@ internal final class DefaultLiveMap: LiveMap { /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry) -> LiveMapValue? { + private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null // This is also equivalent to the RTLM14 check if entry.tombstone == true { @@ -645,7 +588,7 @@ internal final class DefaultLiveMap: LiveMap { // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool if let objectId = entry.data.objectId { // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null - guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else { + guard let poolEntry = delegate.getObjectFromPool(id: objectId) else { return nil } diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift similarity index 91% rename from Sources/AblyLiveObjects/DefaultRealtimeObjects.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 730f519d..ec25db75 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -1,14 +1,13 @@ import Ably internal import AblyPlugin -/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolDelegate { +/// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. +internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() private nonisolated(unsafe) var mutableState: MutableState! - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. @@ -70,13 +69,12 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } } - internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { - self.coreSDK = coreSDK + internal init(logger: AblyPlugin.Logger) { self.logger = logger (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK, logger: logger)) + mutableState = .init(objectsPool: .init(logger: logger)) } // MARK: - LiveMapObjectPoolDelegate @@ -87,9 +85,9 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } } - // MARK: `RealtimeObjects` protocol + // MARK: - Internal methods that power RealtimeObjects conformance - internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { + internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { @@ -159,8 +157,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD mutableState.onChannelAttached( hasObjects: hasObjects, logger: logger, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -176,8 +172,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD objectMessages: objectMessages, logger: logger, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -194,8 +188,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -203,16 +195,16 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD /// Creates a zero-value LiveObject in the object pool for this object ID. /// /// Intended as a way for tests to populate the object pool. - internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String, coreSDK: CoreSDK) -> ObjectsPool.Entry? { + internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK, logger: logger) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger) } } // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { try await coreSDK.sendObject(objectMessages: objectMessages) } @@ -241,8 +233,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD internal mutating func onChannelAttached( hasObjects: Bool, logger: Logger, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) @@ -255,7 +245,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458 - objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK, logger: logger) + objectsPool = .init(logger: logger) // I have, for now, not directly implemented the "perform the actions for object sync completion" of RTO4b4 since my implementation doesn't quite match the model given there; here you only have a SyncObjectsPool if you have an OBJECT_SYNC in progress, which you might not have upon receiving an ATTACHED. Instead I've just implemented what seem like the relevant side effects. Can revisit this if "the actions for object sync completion" get more complex. @@ -270,8 +260,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD protocolMessageChannelSerial: String?, logger: Logger, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -326,8 +314,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO5c objectsPool.applySyncObjectsPool( completedSyncObjectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) @@ -338,8 +324,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, - mapDelegate: mapDelegate, - coreSDK: coreSDK, ) } } @@ -356,8 +340,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD objectMessages: [InboundObjectMessage], logger: Logger, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -375,8 +357,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, - mapDelegate: mapDelegate, - coreSDK: coreSDK, ) } } @@ -386,8 +366,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD private mutating func applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, logger: Logger, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -402,8 +380,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } else { guard let newEntry = objectsPool.createZeroValueObject( forObjectID: operation.objectId, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift new file mode 100644 index 00000000..f63d2713 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +internal enum InternalLiveMapValue: Sendable { + case primitive(PrimitiveObjectValue) + case liveMap(InternalDefaultLiveMap) + case liveCounter(InternalDefaultLiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + internal var primitiveValue: PrimitiveObjectValue? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + internal var liveMapValue: InternalDefaultLiveMap? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + internal var liveCounterValue: InternalDefaultLiveCounter? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `primitive` with a string value, this returns that value. Else, it returns `nil`. + internal var stringValue: String? { + primitiveValue?.stringValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + internal var numberValue: Double? { + primitiveValue?.numberValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + internal var boolValue: Bool? { + primitiveValue?.boolValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + internal var dataValue: Data? { + primitiveValue?.dataValue + } +} diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index c81bec58..3454909a 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -2,7 +2,7 @@ internal import AblyPlugin /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// -/// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. +/// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 4e2efc4b..c2e35855 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -6,11 +6,11 @@ internal import AblyPlugin internal struct ObjectsPool { /// The possible `ObjectsPool` entries, as described by RTO3a. internal enum Entry { - case map(DefaultLiveMap) - case counter(DefaultLiveCounter) + case map(InternalDefaultLiveMap) + case counter(InternalDefaultLiveCounter) /// Convenience getter for accessing the map value if this entry is a map - internal var mapValue: DefaultLiveMap? { + internal var mapValue: InternalDefaultLiveMap? { switch self { case let .map(map): map @@ -20,7 +20,7 @@ internal struct ObjectsPool { } /// Convenience getter for accessing the counter value if this entry is a counter - internal var counterValue: DefaultLiveCounter? { + internal var counterValue: InternalDefaultLiveCounter? { switch self { case .map: nil @@ -67,34 +67,28 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( - rootDelegate: LiveMapObjectPoolDelegate?, - rootCoreSDK: CoreSDK, logger: AblyPlugin.Logger, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( - rootDelegate: rootDelegate, - rootCoreSDK: rootCoreSDK, logger: logger, otherEntries: otherEntries, ) } private init( - rootDelegate: LiveMapObjectPoolDelegate?, - rootCoreSDK: CoreSDK, logger: AblyPlugin.Logger, otherEntries: [String: Entry]? ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK, logger: logger)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger)) } // MARK: - Typed root /// Fetches the root object. - internal var root: DefaultLiveMap { + internal var root: InternalDefaultLiveMap { guard let rootEntry = entries[Self.rootKey] else { preconditionFailure("ObjectsPool should always contain a root object") } @@ -113,11 +107,9 @@ internal struct ObjectsPool { /// /// - Parameters: /// - objectID: The ID of the object to create - /// - mapDelegate: The delegate to use for any created LiveMap - /// - coreSDK: The CoreSDK to use for any created LiveObject /// - logger: The logger to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, logger: AblyPlugin.Logger) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -135,9 +127,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK, logger: logger)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK, logger: logger)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger)) default: return nil } @@ -148,14 +140,8 @@ internal struct ObjectsPool { } /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1. - /// - /// - Parameters: - /// - mapDelegate: The delegate to use for any created LiveMap - /// - coreSDK: The CoreSDK to use for any created LiveObject internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -188,14 +174,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK, logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger) counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 2df0f68e..964baaba 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -4,15 +4,30 @@ internal import AblyPlugin public extension ARTRealtimeChannel { /// A ``RealtimeObjects`` object. var objects: RealtimeObjects { - internallyTypedObjects + nonTypeErasedObjects } - private var internallyTypedObjects: DefaultRealtimeObjects { - DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) + private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { + let pluginAPI = Plugin.defaultPluginAPI + let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: self) + let internalObjects = DefaultInternalPlugin.realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) + + let coreSDK = DefaultCoreSDK( + channel: underlyingObjects.channel, + client: underlyingObjects.client, + pluginAPI: Plugin.defaultPluginAPI, + ) + + return PublicObjectsStore.shared.getOrCreateRealtimeObjects( + proxying: internalObjects, + creationArgs: .init( + coreSDK: coreSDK, + ), + ) } - /// For tests to access the non-public API of `DefaultRealtimeObjects`. - internal var testsOnly_internallyTypedObjects: DefaultRealtimeObjects { - internallyTypedObjects + /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. + internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { + nonTypeErasedObjects } } diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift index 20c709cd..26a377d4 100644 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -22,7 +22,10 @@ import ObjectiveC.NSObject /// ``` @objc public class Plugin: NSObject { + /// The `AblyPlugin.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). + internal static let defaultPluginAPI: AblyPlugin.PluginAPIProtocol = AblyPlugin.PluginAPI.sharedInstance() + // MARK: - Informal conformance to AblyPlugin.LiveObjectsPluginProtocol - @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) + @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: defaultPluginAPI) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift new file mode 100644 index 00000000..92acbe1a --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -0,0 +1,38 @@ +internal extension InternalLiveMapValue { + // MARK: - Mapping to public types + + struct PublicValueCreationArgs { + internal var coreSDK: CoreSDK + internal var mapDelegate: LiveMapObjectPoolDelegate + + internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { + .init(coreSDK: coreSDK) + } + + internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { + .init(coreSDK: coreSDK, delegate: mapDelegate) + } + } + + /// Fetches the cached public object that wraps this `InternalLiveMapValue`'s associated value, creating a new public object if there isn't already one. + func toPublic(creationArgs: PublicValueCreationArgs) -> LiveMapValue { + switch self { + case let .primitive(primitive): + .primitive(primitive) + case let .liveMap(internalLiveMap): + .liveMap( + PublicObjectsStore.shared.getOrCreateMap( + proxying: internalLiveMap, + creationArgs: creationArgs.toMapCreationArgs, + ), + ) + case let .liveCounter(internalLiveCounter): + .liveCounter( + PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalLiveCounter, + creationArgs: creationArgs.toCounterCreationArgs, + ), + ) + } + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift new file mode 100644 index 00000000..1d1e5053 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -0,0 +1,52 @@ +import Ably + +/// Our default implementation of ``LiveCounter``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveCounter``. +internal final class PublicDefaultLiveCounter: LiveCounter { + private let proxied: InternalDefaultLiveCounter + internal var testsOnly_proxied: InternalDefaultLiveCounter { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK) { + self.proxied = proxied + self.coreSDK = coreSDK + } + + // MARK: - `LiveCounter` protocol + + internal var value: Double { + get throws(ARTErrorInfo) { + try proxied.value(coreSDK: coreSDK) + } + } + + internal func increment(amount: Double) async throws(ARTErrorInfo) { + try await proxied.increment(amount: amount) + } + + internal func decrement(amount: Double) async throws(ARTErrorInfo) { + try await proxied.decrement(amount: amount) + } + + internal func subscribe(listener: sending (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + proxied.subscribe(listener: listener) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift new file mode 100644 index 00000000..52edb50c --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -0,0 +1,98 @@ +import Ably + +/// Our default implementation of ``LiveMap``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveMap``. +internal final class PublicDefaultLiveMap: LiveMap { + private let proxied: InternalDefaultLiveMap + internal var testsOnly_proxied: InternalDefaultLiveMap { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let delegate: LiveMapObjectPoolDelegate + + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) { + self.proxied = proxied + self.coreSDK = coreSDK + self.delegate = delegate + } + + // MARK: - `LiveMap` protocol + + internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + try proxied.get(key: key, coreSDK: coreSDK, delegate: delegate)?.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + } + + internal var size: Int { + get throws(ARTErrorInfo) { + try proxied.size(coreSDK: coreSDK) + } + } + + internal var entries: [(key: String, value: LiveMapValue)] { + get throws(ARTErrorInfo) { + try proxied.entries(coreSDK: coreSDK, delegate: delegate).map { entry in + ( + entry.key, + entry.value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + ) + } + } + } + + internal var keys: [String] { + get throws(ARTErrorInfo) { + try proxied.keys(coreSDK: coreSDK, delegate: delegate) + } + } + + internal var values: [LiveMapValue] { + get throws(ARTErrorInfo) { + try proxied.values(coreSDK: coreSDK, delegate: delegate).map { value in + value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + } + } + } + + internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { + try await proxied.set(key: key, value: value) + } + + internal func remove(key: String) async throws(ARTErrorInfo) { + try await proxied.remove(key: key) + } + + internal func subscribe(listener: sending (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + proxied.subscribe(listener: listener) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift new file mode 100644 index 00000000..c0fd7dcf --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -0,0 +1,81 @@ +import Ably + +/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. +/// +/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. +internal final class PublicDefaultRealtimeObjects: RealtimeObjects { + private let proxied: InternalDefaultRealtimeObjects + internal var testsOnly_proxied: InternalDefaultRealtimeObjects { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK) { + self.proxied = proxied + self.coreSDK = coreSDK + } + + // MARK: - `RealtimeObjects` protocol + + internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { + let internalMap = try await proxied.getRoot(coreSDK: coreSDK) + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + delegate: proxied, + ), + ) + } + + internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { + try await proxied.createMap(entries: entries) + } + + internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { + try await proxied.createMap() + } + + internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { + try await proxied.createCounter(count: count) + } + + internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { + try await proxied.createCounter() + } + + internal func batch(callback: sending (sending any BatchContext) -> Void) async throws { + try await proxied.batch(callback: callback) + } + + internal func on(event: ObjectsEvent, callback: sending () -> Void) -> any OnObjectsEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } + + // MARK: - Test-only APIs + + // These are only used by our plumbingSmokeTest (the rest of our unit tests test the internal classes, not the public ones). + + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + proxied.testsOnly_onChannelAttachedHasObjects + } + + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { + proxied.testsOnly_receivedObjectProtocolMessages + } + + internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await proxied.testsOnly_sendObject(objectMessages: objectMessages, coreSDK: coreSDK) + } + + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + proxied.testsOnly_receivedObjectSyncProtocolMessages + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift new file mode 100644 index 00000000..39e7f907 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Stores the public objects that wrap the SDK's internal components. +/// +/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: +/// +/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` +/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` +/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) +/// +/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. +/// +/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +internal final class PublicObjectsStore: Sendable { + // Used to synchronize access to mutable state + private let mutex = NSLock() + private nonisolated(unsafe) var mutableState = MutableState() + + internal static let shared = PublicObjectsStore() + + internal struct RealtimeObjectsCreationArgs { + internal var coreSDK: CoreSDK + } + + /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. + internal func getOrCreateRealtimeObjects(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs) -> PublicDefaultRealtimeObjects { + mutex.withLock { + mutableState.getOrCreateRealtimeObjects(proxying: proxied, creationArgs: creationArgs) + } + } + + internal struct CounterCreationArgs { + internal var coreSDK: CoreSDK + } + + /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. + internal func getOrCreateCounter(proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs) -> PublicDefaultLiveCounter { + mutex.withLock { + mutableState.getOrCreateCounter(proxying: proxied, creationArgs: creationArgs) + } + } + + internal struct MapCreationArgs { + internal var coreSDK: CoreSDK + internal var delegate: LiveMapObjectPoolDelegate + } + + /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. + internal func getOrCreateMap(proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs) -> PublicDefaultLiveMap { + mutex.withLock { + mutableState.getOrCreateMap(proxying: proxied, creationArgs: creationArgs) + } + } + + private struct MutableState { + private var realtimeObjectsProxies = Proxies() + private var counterProxies = Proxies() + private var mapProxies = Proxies() + + /// Stores weak references to proxy objects. + private struct Proxies { + private var proxiesByProxiedObjectIdentifier: [ObjectIdentifier: WeakRef] = [:] + + /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. + mutating func getOrCreate( + proxying proxied: some AnyObject, + createProxy: () -> Proxy, + ) -> Proxy { + // Remove any entries that are no longer useful + removeDeallocatedEntries() + + // Do the get-or-create + let proxiedObjectIdentifier = ObjectIdentifier(proxied) + + if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { + return existing + } + + let created = createProxy() + proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) + + return created + } + + private mutating func removeDeallocatedEntries() { + proxiesByProxiedObjectIdentifier = proxiesByProxiedObjectIdentifier.filter { entry in + entry.value.referenced != nil + } + } + } + + internal mutating func getOrCreateRealtimeObjects( + proxying proxied: InternalDefaultRealtimeObjects, + creationArgs: RealtimeObjectsCreationArgs, + ) -> PublicDefaultRealtimeObjects { + realtimeObjectsProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + ) + } + } + + internal mutating func getOrCreateCounter( + proxying proxied: InternalDefaultLiveCounter, + creationArgs: CounterCreationArgs, + ) -> PublicDefaultLiveCounter { + counterProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + ) + } + } + + internal mutating func getOrCreateMap( + proxying proxied: InternalDefaultLiveMap, + creationArgs: MapCreationArgs, + ) -> PublicDefaultLiveMap { + mapProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + delegate: creationArgs.delegate, + ) + } + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift index 0678728a..c7175a7e 100644 --- a/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -6,11 +6,3 @@ internal struct WeakRef { } extension WeakRef: Sendable where Referenced: Sendable {} - -// MARK: - Specialized versions of WeakRef - -// These are protocol-specific versions of ``WeakRef`` that hold an existential type (e.g. `any CoreSDK`). (This is because the compiler complains that an existential of a class-bound protocol doesn't conform to `AnyObject`.) - -internal struct WeakLiveMapObjectPoolDelegateRef: Sendable { - internal weak var referenced: LiveMapObjectPoolDelegate? -} diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 6b89d54b..d33ca22d 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,7 +20,7 @@ struct AblyLiveObjectsTests { // Then // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is DefaultRealtimeObjects) + #expect(channel.objects is PublicDefaultRealtimeObjects) } /// A basic test of the core interactions between this plugin and ably-cocoa. @@ -79,10 +79,10 @@ struct AblyLiveObjectsTests { try await channel.attachAsync() // 3. Check that ably-cocoa called our onChannelAttached and passed the HAS_OBJECTS flag. - #expect(channel.testsOnly_internallyTypedObjects.testsOnly_onChannelAttachedHasObjects == true) + #expect(channel.testsOnly_nonTypeErasedObjects.testsOnly_onChannelAttachedHasObjects == true) // 4. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT_SYNC, and then called our handleObjectSyncProtocolMessage with these ObjectMessages; we expect the OBJECT_SYNC to contain the root object and the map that we created in the REST call above. - let objectSyncObjectMessages = try #require(await channel.testsOnly_internallyTypedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) + let objectSyncObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) #expect(Set(objectSyncObjectMessages.map(\.object?.objectId)) == ["root", restCreatedMapObjectID]) // 5. Now, send an OBJECT ProtocolMessage that creates a new Map. This confirms that Ably is using us to encode this ProtocolMessage's contained ObjectMessages. @@ -90,7 +90,7 @@ struct AblyLiveObjectsTests { // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" - try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ OutboundObjectMessage( operation: .init( action: .known(.mapCreate), @@ -101,7 +101,7 @@ struct AblyLiveObjectsTests { ]) // 6. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT triggered by this map creation, and then called our handleObjectProtocolMessage with these ObjectMessages; we expect the OBJECT to contain the map create operation that we just performed. - let objectObjectMessages = try #require(await channel.testsOnly_internallyTypedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) + let objectObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) try #require(objectObjectMessages.count == 1) let receivedMapCreateObjectMessage = objectObjectMessages[0] #expect(receivedMapCreateObjectMessage.operation?.objectId == realtimeCreatedMapObjectID) @@ -110,7 +110,7 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { do throws(InternalError) { - try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ .init(), ]) } catch { diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift index 8c6f9a0a..ff3dcb71 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -19,6 +19,9 @@ enum ClientHelper { if let useBinaryProtocol = options.useBinaryProtocol { clientOptions.useBinaryProtocol = useBinaryProtocol } + if let autoConnect = options.autoConnect { + clientOptions.autoConnect = autoConnect + } return ARTRealtime(options: clientOptions) } @@ -32,5 +35,6 @@ enum ClientHelper { struct PartialClientOptions: Encodable, Hashable { var useBinaryProtocol: Bool? + var autoConnect: Bool? } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index cf94ccfc..c82df9e9 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -172,14 +172,12 @@ import Foundation /// /// - Parameters: /// /// - objectId: The object ID for the map (default: "map:test@123") /// /// - entries: Dictionary of key-value pairs to populate the map -/// /// - delegate: The delegate for the map (default: MockLiveMapObjectPoolDelegate()) -/// /// - Returns: A configured DefaultLiveMap instance +/// /// - Returns: A configured InternalDefaultLiveMap instance /// static func liveMap( /// objectId: String = "map:test@123", /// entries: [String: String] = [:], -/// delegate: LiveMapObjectPoolDelegate = MockLiveMapObjectPoolDelegate() -/// ) -> DefaultLiveMap { -/// let map = DefaultLiveMap.createZeroValued(delegate: delegate) +/// ) -> InternalDefaultLiveMap { +/// let map = InternalDefaultLiveMap.createZeroValued() /// // Configure map with entries... /// return map /// } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift similarity index 75% rename from Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 4b8ce9c3..d4cd7b75 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -3,17 +3,18 @@ import AblyPlugin import Foundation import Testing -struct DefaultLiveCounterTests { +struct InternalDefaultLiveCounterTests { /// Tests for the `value` property, covering RTLC5 specification points struct ValueTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: channelState) #expect { - _ = try counter.value + _ = try counter.value(coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -27,12 +28,13 @@ struct DefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) - #expect(try counter.value == 42) + #expect(try counter.value(coreSDK: coreSDK) == 42) } } @@ -42,7 +44,7 @@ struct DefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -58,7 +60,7 @@ struct DefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( @@ -85,23 +87,25 @@ struct DefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value ) counter.replaceData(using: state) - #expect(try counter.value == 42) + #expect(try counter.value(coreSDK: coreSDK) == 42) } // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) - #expect(try counter.value == 0) + #expect(try counter.value(coreSDK: coreSDK) == 0) } } @@ -111,13 +115,14 @@ struct DefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) counter.replaceData(using: state) - #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) + #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } } @@ -129,28 +134,30 @@ struct DefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist counter.testsOnly_mergeInitialValue(from: operation) - #expect(try counter.value == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 } // @specOneOf(2/2) RTLC10a - no count @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count let operation = TestFactories.objectOperation( @@ -159,14 +166,14 @@ struct DefaultLiveCounterTests { ) counter.testsOnly_mergeInitialValue(from: operation) - #expect(try counter.value == 5) // Unchanged + #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged } // @spec RTLC10b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist @@ -182,7 +189,8 @@ struct DefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged counter.replaceData(using: TestFactories.counterObjectState(count: 5)) @@ -194,14 +202,15 @@ struct DefaultLiveCounterTests { counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was discarded - data unchanged - #expect(try counter.value == 15) // 5 + 10, not 5 + 10 + 20 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10, not 5 + 10 + 20 } // @spec RTLC8c @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged counter.replaceData(using: TestFactories.counterObjectState(count: 5)) @@ -212,7 +221,7 @@ struct DefaultLiveCounterTests { counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was applied - initial value merged. (The full logic of RTLC10 is tested elsewhere; we just check for some of its side effects here.) - #expect(try counter.value == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 #expect(counter.testsOnly_createOperationIsMerged) } } @@ -226,17 +235,18 @@ struct DefaultLiveCounterTests { ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation counter.testsOnly_applyCounterIncOperation(operation) // Verify the operation was applied correctly - #expect(try counter.value == expectedValue) + #expect(try counter.value(coreSDK: coreSDK) == expectedValue) } } @@ -246,7 +256,8 @@ struct DefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded counter.replaceData(using: TestFactories.counterObjectState( @@ -258,7 +269,7 @@ struct DefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( @@ -270,7 +281,7 @@ struct DefaultLiveCounterTests { // Check that the COUNTER_INC side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be 5 from creation) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Verify site timeserials unchanged #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) } @@ -280,10 +291,11 @@ struct DefaultLiveCounterTests { @Test func appliesCounterCreateOperation() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply COUNTER_CREATE operation counter.apply( @@ -294,7 +306,7 @@ struct DefaultLiveCounterTests { ) // Verify the operation was applied - initial value merged (the full logic of RTLC8 is tested elsewhere; we just check for some of its side effects here) - #expect(try counter.value == 15) + #expect(try counter.value(coreSDK: coreSDK) == 15) #expect(counter.testsOnly_createOperationIsMerged) // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) @@ -305,17 +317,18 @@ struct DefaultLiveCounterTests { @Test func appliesCounterIncOperation() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply COUNTER_INC operation counter.apply( @@ -326,7 +339,7 @@ struct DefaultLiveCounterTests { ) // Verify the operation was applied - amount added to data (the full logic of RTLC9 is tested elsewhere; we just check for some of its side effects here) - #expect(try counter.value == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift similarity index 80% rename from Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index f9bb1838..3a2f1450 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -3,17 +3,17 @@ import AblyPlugin import Foundation import Testing -struct DefaultLiveMapTests { +struct InternalDefaultLiveMapTests { /// Tests for the `get` method, covering RTLM5 specification points struct GetTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) #expect { - _ = try map.get(key: "test") + _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -30,8 +30,8 @@ struct DefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "nonexistent") == nil) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } // @spec RTLM5d2a @@ -43,8 +43,8 @@ struct DefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } // @spec RTLM5d2b @@ -53,8 +53,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -65,8 +65,8 @@ struct DefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -76,8 +76,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -87,8 +87,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -99,8 +99,8 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool @@ -111,11 +111,11 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) } @@ -128,10 +128,10 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) } @@ -144,8 +144,8 @@ struct DefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -155,14 +155,12 @@ struct DefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -171,12 +169,10 @@ struct DefaultLiveMapTests { @Test func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) let logger = TestLogger() - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let map = { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.objectState( @@ -202,18 +198,18 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) - #expect(try map.get(key: "key1")?.stringValue == "test") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "test") } // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp @@ -223,7 +219,7 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -239,12 +235,12 @@ struct DefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) - #expect(try map.get(key: "keyFromMapEntries")?.stringValue == "valueFromMapEntries") - #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") #expect(map.testsOnly_createOperationIsMerged) } } @@ -260,14 +256,16 @@ struct DefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: channelState) + let delegate = MockLiveMapObjectPoolDelegate() // Define actions to test let actions: [(String, () throws -> Any)] = [ - ("size", { try map.size }), - ("entries", { try map.entries }), - ("keys", { try map.keys }), - ("values", { try map.values }), + ("size", { try map.size(coreSDK: coreSDK) }), + ("entries", { try map.entries(coreSDK: coreSDK, delegate: delegate) }), + ("keys", { try map.keys(coreSDK: coreSDK, delegate: delegate) }), + ("values", { try map.values(coreSDK: coreSDK, delegate: delegate) }), ] // Test each property throws the expected error @@ -294,7 +292,8 @@ struct DefaultLiveMapTests { func allPropertiesFilterOutTombstonedEntries() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let delegate = MockLiveMapObjectPoolDelegate() + let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstone is nil, so not considered tombstoned "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), @@ -304,29 +303,27 @@ struct DefaultLiveMapTests { "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], objectID: "arbitrary", - delegate: nil, - coreSDK: coreSDK, logger: logger, ) // Test size - should only count non-tombstoned entries - let size = try map.size + let size = try map.size(coreSDK: coreSDK) #expect(size == 2) // Test entries - should only return non-tombstoned entries - let entries = try map.entries + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) #expect(entries.count == 2) #expect(Set(entries.map(\.key)) == ["active1", "active2"]) #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") #expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2") // Test keys - should only return keys from non-tombstoned entries - let keys = try map.keys + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) #expect(keys.count == 2) #expect(Set(keys) == ["active1", "active2"]) // Test values - should only return values from non-tombstoned entries - let values = try map.values + let values = try map.values(coreSDK: coreSDK, delegate: delegate) #expect(values.count == 2) #expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"])) } @@ -340,22 +337,21 @@ struct DefaultLiveMapTests { func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let delegate = MockLiveMapObjectPoolDelegate() + let map = InternalDefaultLiveMap( testsOnly_data: [ "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), ], objectID: "arbitrary", - delegate: nil, - coreSDK: coreSDK, logger: logger, ) - let size = try map.size - let entries = try map.entries - let keys = try map.keys - let values = try map.values + let size = try map.size(coreSDK: coreSDK) + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + let values = try map.values(coreSDK: coreSDK, delegate: delegate) // All properties should return the same count #expect(size == 3) @@ -380,12 +376,12 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: [ "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c @@ -395,15 +391,13 @@ struct DefaultLiveMapTests { "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - let size = try map.size - let entries = try map.entries - let keys = try map.keys - let values = try map.values + let size = try map.size(coreSDK: coreSDK) + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + let values = try map.values(coreSDK: coreSDK, delegate: delegate) #expect(size == 6) #expect(entries.count == 6) @@ -438,14 +432,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -456,7 +448,7 @@ struct DefaultLiveMapTests { ) // Verify the operation was discarded - existing data unchanged - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify that RTLM7c1 didn't happen (i.e. that we didn't create a zero-value object in the pool for object ID "new") #expect(Set(pool.entries.keys) == ["root"]) } @@ -475,14 +467,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -497,7 +487,7 @@ struct DefaultLiveMapTests { } // Verify the operation was applied - let result = try map.get(key: "key1") + let result = try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) if let numberValue = operationData.number { #expect(result?.numberValue == numberValue.doubleValue) } else if expectedCreatedObjectID != nil { @@ -544,8 +534,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -561,7 +551,7 @@ struct DefaultLiveMapTests { // Verify new entry was created // RTLM7b1 - let result = try map.get(key: "newKey") + let result = try map.get(key: "newKey", coreSDK: coreSDK, delegate: delegate) if let numberValue = operationData.number { #expect(result?.numberValue == numberValue.doubleValue) } else if expectedCreatedObjectID != nil { @@ -591,20 +581,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" - let existingObject = DefaultLiveMap( + let existingObject = InternalDefaultLiveMap( testsOnly_data: [:], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) var pool = ObjectsPool( - rootDelegate: delegate, - rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) @@ -624,7 +610,7 @@ struct DefaultLiveMapTests { #expect(objectAfterMapSetValue as AnyObject === existingObject as AnyObject) // Verify the MAP_SET operation was applied correctly (creates reference in the map) - let referenceValue = try map.get(key: "referenceKey") + let referenceValue = try map.get(key: "referenceKey", coreSDK: coreSDK, delegate: delegate) #expect(referenceValue?.liveMapValue != nil) } } @@ -640,11 +626,9 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) @@ -652,7 +636,7 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") // Verify the operation was discarded - existing data unchanged - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") } // @spec RTLM8a2a @@ -663,11 +647,9 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) @@ -675,7 +657,7 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") // Verify the operation was applied - #expect(try map.get(key: "key1") == nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null let entry = map.testsOnly_data["key1"] @@ -700,9 +682,7 @@ struct DefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -721,9 +701,7 @@ struct DefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -785,14 +763,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -805,10 +781,10 @@ struct DefaultLiveMapTests { if shouldApply { // Verify operation was applied - #expect(try map.get(key: "key1")?.stringValue == "new") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") } else { // Verify operation was discarded - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") } } } @@ -821,8 +797,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -835,7 +811,7 @@ struct DefaultLiveMapTests { // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) - #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") } // @spec RTLM17a2 @@ -844,17 +820,15 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Confirm that the initial data is there - #expect(try map.get(key: "key1") != nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) // Apply merge operation with MAP_REMOVE entry let entry = TestFactories.mapEntry( @@ -869,17 +843,15 @@ struct DefaultLiveMapTests { map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied - #expect(try map.get(key: "key1") == nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) } // @spec RTLM17b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") @@ -897,8 +869,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Set initial data and mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -910,9 +882,9 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was discarded - data unchanged - #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data - #expect(try map.get(key: "key2")?.stringValue == "value2") // From first merge - #expect(try map.get(key: "key3") == nil) // Not added by second operation + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From first merge + #expect(try map.get(key: "key3", coreSDK: coreSDK, delegate: delegate) == nil) // Not added by second operation } // @spec RTLM16d @@ -921,8 +893,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Set initial data but don't mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -933,8 +905,8 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was applied - initial value merged. (The full logic of RTLM17 is tested elsewhere; we just check for some of its side effects here.) - #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data - #expect(try map.get(key: "key2")?.stringValue == "value2") // From merge + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From merge #expect(map.testsOnly_createOperationIsMerged) } } @@ -947,10 +919,10 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" @@ -972,7 +944,7 @@ struct DefaultLiveMapTests { // Check that the MAP_SET side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be "existing" from creation) - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify site timeserials unchanged #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) } @@ -984,12 +956,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Apply MAP_CREATE operation map.apply( @@ -1000,7 +972,7 @@ struct DefaultLiveMapTests { ) // Verify the operation was applied - initial value merged (the full logic of RTLM16 is tested elsewhere; we just check for some of its side effects here) - #expect(try map.get(key: "key1")?.stringValue == "value1") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value1") #expect(map.testsOnly_createOperationIsMerged) // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) @@ -1013,16 +985,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set initial data - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( action: .known(.mapSet), @@ -1038,7 +1010,7 @@ struct DefaultLiveMapTests { ) // Verify the operation was applied - value updated (the full logic of RTLM7 is tested elsewhere; we just check for some of its side effects here) - #expect(try map.get(key: "key1")?.stringValue == "new") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -1050,16 +1022,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set initial data - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( action: .known(.mapRemove), @@ -1075,7 +1047,7 @@ struct DefaultLiveMapTests { ) // Verify the operation was applied - key removed (the full logic of RTLM8 is tested elsewhere; we just check for some of its side effects here) - #expect(try map.get(key: "key1") == nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift similarity index 87% rename from Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index a7b6a5cd..017ec7b3 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -3,25 +3,24 @@ import Ably import AblyPlugin import Testing -/// Tests for `DefaultRealtimeObjects`. -struct DefaultRealtimeObjectsTests { +/// Tests for `InternalDefaultRealtimeObjects`. +struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers - /// Creates a DefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects(channelState: ARTRealtimeChannelState = .attached) -> DefaultRealtimeObjects { - let coreSDK = MockCoreSDK(channelState: channelState) + /// Creates a InternalDefaultRealtimeObjects instance for testing + static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + return InternalDefaultRealtimeObjects(logger: logger) } - /// Tests for `DefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. + /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. struct HandleObjectSyncProtocolMessageTests { // MARK: - RTO5a5: Single ProtocolMessage Sync Tests // @spec RTO5a5 @Test func handlesSingleProtocolMessageSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [ TestFactories.simpleMapMessage(objectId: "map:1@123"), TestFactories.simpleMapMessage(objectId: "map:2@456"), @@ -56,7 +55,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5c5 @Test func handlesMultiProtocolMessageSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let sequenceId = "seq123" // First message in sequence @@ -112,7 +111,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5a2b @Test func newSequenceIdDiscardsInFlightSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let firstSequenceId = "seq1" let secondSequenceId = "seq2" @@ -159,7 +158,7 @@ struct DefaultRealtimeObjectsTests { // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root @Test func removesObjectsNotInSyncButPreservesRoot() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Perform sync with only one object (RTO5a5 case) let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] @@ -183,7 +182,7 @@ struct DefaultRealtimeObjectsTests { /// Test handling of invalid channelSerial format @Test func handlesInvalidChannelSerialFormat() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Call with invalid channelSerial (missing colon) @@ -205,7 +204,7 @@ struct DefaultRealtimeObjectsTests { /// Test with empty sequence ID @Test func handlesEmptySequenceId() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Start sequence with empty sequence ID @@ -231,7 +230,7 @@ struct DefaultRealtimeObjectsTests { /// Test mixed object types in single sync @Test func handlesMixedObjectTypesInSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let mixedMessages = [ TestFactories.mapObjectMessage(objectId: "map:1@123"), @@ -255,7 +254,7 @@ struct DefaultRealtimeObjectsTests { /// Test continuation of sync after interruption by new sequence @Test func handlesSequenceInterruptionCorrectly() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Start first sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -294,7 +293,7 @@ struct DefaultRealtimeObjectsTests { } } - /// Tests for `DefaultRealtimeObjects.onChannelAttached`, covering RTO4 specification points. + /// Tests for `InternalDefaultRealtimeObjects.onChannelAttached`, covering RTO4 specification points. /// /// Note: These tests use `OBJECT_SYNC` messages to populate the initial state of objects pools /// and sync sequences. This approach is more realistic than directly manipulating internal state, @@ -305,12 +304,12 @@ struct DefaultRealtimeObjectsTests { // @spec RTO4a - Checks that when the `HAS_OBJECTS` flag is 1 (i.e. the server will shortly perform an `OBJECT_SYNC` sequence) we don't modify any internal state @Test func doesNotModifyStateWhenHasObjectsIsTrue() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Set up initial state with additional objects by using the createZeroValueObject method let originalPool = realtimeObjects.testsOnly_objectsPool let originalRootObject = originalPool.root - _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123", coreSDK: MockCoreSDK(channelState: .attaching)) + _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123") // Set up an in-progress sync sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -347,7 +346,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO4b5 @Test func handlesHasObjectsFalse() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Set up initial state with additional objects in the pool using sync realtimeObjects.handleObjectSyncProtocolMessage( @@ -399,7 +398,7 @@ struct DefaultRealtimeObjectsTests { /// Test that multiple calls to onChannelAttached work correctly @Test func handlesMultipleCallsCorrectly() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // First call with hasObjects = true (should do nothing) realtimeObjects.onChannelAttached(hasObjects: true) @@ -425,7 +424,7 @@ struct DefaultRealtimeObjectsTests { /// Test that sync sequence is properly discarded even with complex sync state @Test func discardsComplexSyncSequence() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Create a complex sync sequence using OBJECT_SYNC messages // (This simulates realistic multi-message sync scenarios) @@ -459,7 +458,7 @@ struct DefaultRealtimeObjectsTests { /// Test behavior when there's no sync sequence in progress @Test func handlesNoSyncSequenceCorrectly() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Add some objects to the pool using OBJECT_SYNC messages // (This is the realistic way objects enter the pool, not through direct manipulation) @@ -488,28 +487,29 @@ struct DefaultRealtimeObjectsTests { /// Test that the root object's delegate is correctly set after reset @Test func setsCorrectDelegateOnNewRoot() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // When: onChannelAttached is called with hasObjects = false realtimeObjects.onChannelAttached(hasObjects: false) - // Then: The new root should have the correct delegate + // Then: The new root should be properly initialized let newRoot = realtimeObjects.testsOnly_objectsPool.root - #expect(newRoot.testsOnly_delegate as AnyObject === realtimeObjects as AnyObject) + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) } } - /// Tests for `DefaultRealtimeObjects.getRoot`, covering RTO1 specification points + /// Tests for `InternalDefaultRealtimeObjects.getRoot`, covering RTO1 specification points struct GetRootTests { // MARK: - RTO1c Tests // @specOneOf(1/4) RTO1c - getRoot waits for sync completion when sync completes via ATTACHED with `HAS_OBJECTS` false (RTO4b) @Test func waitsForSyncCompletionViaAttachedHasObjectsFalse() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -524,10 +524,11 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(2/4) RTO1c - getRoot waits for sync completion when sync completes via single `OBJECT_SYNC` with no channelSerial (RTO5a5) @Test func waitsForSyncCompletionViaSingleObjectSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -550,22 +551,23 @@ struct DefaultRealtimeObjectsTests { let root = try await getRootTask // Verify the root object contains the expected entries from the sync - let testValue = try root.get(key: "testKey")?.stringValue + let testValue = try root.get(key: "testKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue #expect(testValue == "testValue") // Verify the root object contains a reference to the other LiveObject - let referencedObject = try root.get(key: "referencedObject") + let referencedObject = try root.get(key: "referencedObject", coreSDK: coreSDK, delegate: realtimeObjects) #expect(referencedObject != nil) } // @specOneOf(3/4) RTO1c - getRoot waits for sync completion when sync completes via multiple `OBJECT_SYNC` messages (RTO5a4) @Test func waitsForSyncCompletionViaMultipleObjectSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) let sequenceId = "seq123" // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -608,10 +610,10 @@ struct DefaultRealtimeObjectsTests { let root = try await getRootTask // Verify the root object contains the expected entries from the sync sequence - let firstValue = try root.get(key: "firstKey")?.stringValue - let firstObject = try root.get(key: "firstObject") - let secondObject = try root.get(key: "secondObject") - let finalObject = try root.get(key: "finalObject") + let firstValue = try root.get(key: "firstKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue + let firstObject = try root.get(key: "firstObject", coreSDK: coreSDK, delegate: realtimeObjects) + let secondObject = try root.get(key: "secondObject", coreSDK: coreSDK, delegate: realtimeObjects) + let finalObject = try root.get(key: "finalObject", coreSDK: coreSDK, delegate: realtimeObjects) #expect(firstValue == "firstValue") #expect(firstObject != nil) #expect(secondObject != nil) @@ -621,13 +623,14 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(4/4) RTO1c - getRoot returns immediately when sync is already complete @Test func returnsImmediatelyWhenSyncAlreadyComplete() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Complete sync first realtimeObjects.onChannelAttached(hasObjects: false) // getRoot should return - _ = try await realtimeObjects.getRoot() + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) // Verify no waiting events were emitted realtimeObjects.testsOnly_finishAllTestHelperStreams() @@ -642,13 +645,14 @@ struct DefaultRealtimeObjectsTests { // @spec RTO1d @Test func returnsRootObjectFromObjectsPool() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Complete sync first realtimeObjects.onChannelAttached(hasObjects: false) // Call getRoot - let root = try await realtimeObjects.getRoot() + let root = try await realtimeObjects.getRoot(coreSDK: coreSDK) // Verify it's the same object as the one in the pool with key "root" let poolRoot = realtimeObjects.testsOnly_objectsPool.entries["root"]?.mapValue @@ -660,10 +664,11 @@ struct DefaultRealtimeObjectsTests { // @spec RTO1b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getRootThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects(channelState: channelState) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) await #expect { - _ = try await realtimeObjects.getRoot() + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -674,7 +679,7 @@ struct DefaultRealtimeObjectsTests { } } - /// Tests for `DefaultRealtimeObjects.handleObjectProtocolMessage`, covering RTO8 specification points. + /// Tests for `InternalDefaultRealtimeObjects.handleObjectProtocolMessage`, covering RTO8 specification points. struct HandleObjectProtocolMessageTests { // Tests that when an OBJECT ProtocolMessage is received and there isn't a sync in progress, its operations are handled per RTO8b. struct ApplyOperationTests { @@ -686,7 +691,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool @Test func createsObjectInObjectsPoolWhenNecessary() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:new@123" // Verify the object doesn't exist in the pool initially @@ -715,7 +720,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(1/5) RTO9a2a3 - Tests MAP_CREATE operation application @Test func appliesMapCreateOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -733,7 +738,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_CREATE operation message @@ -750,7 +756,7 @@ struct DefaultRealtimeObjectsTests { // Verify the operation was applied by checking for side effects // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here - let finalValue = try #require(map.get(key: "createKey")?.stringValue) + let finalValue = try #require(map.get(key: "createKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(finalValue == "createValue") #expect(map.testsOnly_createOperationIsMerged) #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") @@ -761,7 +767,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application @Test func appliesMapSetOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -779,7 +785,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_SET operation message @@ -796,7 +803,7 @@ struct DefaultRealtimeObjectsTests { // Verify the operation was applied by checking for side effects // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here - let finalValue = try #require(map.get(key: "existingKey")?.stringValue) + let finalValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(finalValue == "newValue") #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -806,7 +813,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application @Test func appliesMapRemoveOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -824,7 +831,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_REMOVE operation message @@ -840,7 +848,7 @@ struct DefaultRealtimeObjectsTests { // Verify the operation was applied by checking for side effects // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here - let finalValue = try map.get(key: "existingKey") + let finalValue = try map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects) #expect(finalValue == nil) // Key should be removed/tombstoned #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -850,7 +858,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application @Test func appliesCounterCreateOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "counter:test@123" // Create a counter object in the pool first @@ -867,7 +875,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) - let initialValue = try counter.value + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) // Create a COUNTER_CREATE operation message @@ -883,7 +892,7 @@ struct DefaultRealtimeObjectsTests { // Verify the operation was applied by checking for side effects // The full logic of applying the operation is tested in RTLC7; we just check for some of its side effects here - let finalValue = try counter.value + let finalValue = try counter.value(coreSDK: coreSDK) #expect(finalValue == 15) // 5 + 10 (initial value merged) #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -893,7 +902,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application @Test func appliesCounterIncOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "counter:test@123" // Create a counter object in the pool first @@ -910,7 +919,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) - let initialValue = try counter.value + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) // Create a COUNTER_INC operation message @@ -926,7 +936,7 @@ struct DefaultRealtimeObjectsTests { // Verify the operation was applied by checking for side effects // The full logic of applying the operation is tested in RTLC7; we just check for some of its side effects here - let finalValue = try counter.value + let finalValue = try counter.value(coreSDK: coreSDK) #expect(finalValue == 15) // 5 + 10 #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -938,7 +948,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5c6 @Test func buffersObjectOperationsDuringSyncAndAppliesAfterCompletion() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let sequenceId = "seq123" // Start sync sequence with first OBJECT_SYNC message @@ -1008,12 +1018,13 @@ struct DefaultRealtimeObjectsTests { // Verify the buffered operations were applied after sync completion (RTO5c6) // Check that MAP_SET operation was applied to the map - let mapValue = try #require(map.get(key: "key1")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let mapValue = try #require(map.get(key: "key1", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(mapValue == "value1") #expect(map.testsOnly_siteTimeserials["site1"] == "ts3") // Check that COUNTER_INC operation was applied to the counter - let counterValue = try counter.value + let counterValue = try counter.value(coreSDK: coreSDK) #expect(counterValue == 15) // 5 (from sync) + 10 (from buffered operation) #expect(counter.testsOnly_siteTimeserials["site1"] == "ts4") } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 9b14499a..ec84e3f8 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -299,9 +299,6 @@ private struct ObjectsIntegrationTests { for key in valueMapKeys { #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") } - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -371,9 +368,6 @@ private struct ObjectsIntegrationTests { #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel2) {} } }, ), @@ -447,9 +441,6 @@ private struct ObjectsIntegrationTests { let counterObj = try #require(root.get(key: counter.key)?.liveCounterValue) #expect(try counterObj.value == Double(counter.value), "Check counter at key=\"\(counter.key)\" in root has correct value") } - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -490,9 +481,6 @@ private struct ObjectsIntegrationTests { let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -521,9 +509,6 @@ private struct ObjectsIntegrationTests { let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -751,9 +736,6 @@ private struct ObjectsIntegrationTests { clientOptions: testCase.options, ), ) - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} } } diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift new file mode 100644 index 00000000..067f2f19 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -0,0 +1,237 @@ +import Ably.Private +@testable import AblyLiveObjects +import Testing + +struct ObjectLifetimesTests { + @Test("LiveObjects functionality works with only a strong reference to channel's public objects property") + func withStrongReferenceToPublicObjectsProperty() async throws { + // The objects that we'll create. + struct CreatedObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + weak var weakPublicRealtime: ARTRealtime? + weak var weakInternalRealtime: ARTRealtimeInternal? + weak var weakPublicChannel: ARTRealtimeChannel? + weak var weakInternalChannel: ARTRealtimeChannelInternal? + var strongPublicRealtimeObjects: PublicDefaultRealtimeObjects + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + } + + // What we're left with after discarding a CreatedObjects. + struct RemainingObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + // weakPublicRealtime is gone now + weak var weakInternalRealtime: ARTRealtimeInternal? + // weakPublicChannel is gone now + weak var weakInternalChannel: ARTRealtimeChannelInternal? + weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + } + + func createAndDiscardObjects() async throws -> RemainingObjects { + func createObjects() async throws -> CreatedObjects { + // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. + let realtime = try await ClientHelper.realtimeWithObjects(options: .init(autoConnect: false)) + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + let anyObjects = channel.objects + // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + + return .init( + realtimeDeallocQueue: realtime.internal.queue, + weakPublicRealtime: realtime, + weakInternalRealtime: realtime.internal, + weakPublicChannel: channel, + weakInternalChannel: channel.internal, + strongPublicRealtimeObjects: objects, + weakInternalRealtimeObjects: objects.testsOnly_proxied, + ) + } + + let createdObjects = try await createObjects() + + // The only public object we have a strong reference to is strongPublicRealtimeObjects, so the other public objects should have already been deallocated + #expect(createdObjects.weakPublicRealtime == nil) + #expect(createdObjects.weakPublicChannel == nil) + + // Now we check that, since we still have a strong reference to strongPublicRealtimeObjects, none of the dependencies that it needs in order to function have been deallocated. + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + createdObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + #expect(createdObjects.weakInternalRealtime != nil) + #expect(createdObjects.weakInternalChannel != nil) + #expect(createdObjects.weakInternalRealtimeObjects != nil) + + // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/30) + + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObjects instance + return .init( + realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, + weakInternalRealtime: createdObjects.weakInternalRealtime, + weakInternalChannel: createdObjects.weakInternalChannel, + weakPublicRealtimeObjects: createdObjects.strongPublicRealtimeObjects, + weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, + ) + } + + let remainingObjects = try await createAndDiscardObjects() + + // Check that the public RealtimeObjects has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicRealtimeObjects == nil) + + // Check that the internal objects that the public RealtimeObjects needed in order to function have now been deallocated + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + remainingObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + + #expect(remainingObjects.weakInternalRealtime == nil) + #expect(remainingObjects.weakInternalChannel == nil) + #expect(remainingObjects.weakInternalRealtimeObjects == nil) + } + + @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") + func withStrongReferenceToPublicLiveObject() async throws { + // Note: This test is very similar to withStrongReferenceToPublicObjectsProperty but "one layer down" — i.e. it checks that instead of a RealtimeObjects reference keeping everything working, a LiveObject reference keeps everything working. Keep these two tests in sync. + + // The objects that we'll create. + struct CreatedObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + weak var weakPublicRealtime: ARTRealtime? + weak var weakInternalRealtime: ARTRealtimeInternal? + weak var weakPublicChannel: ARTRealtimeChannel? + weak var weakInternalChannel: ARTRealtimeChannelInternal? + weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + var strongPublicLiveObject: PublicDefaultLiveMap + weak var weakInternalLiveObject: InternalDefaultLiveMap? + } + + // What we're left with after discarding a CreatedObjects. + struct RemainingObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + // weakPublicRealtime is gone now + weak var weakInternalRealtime: ARTRealtimeInternal? + // weakPublicChannel is gone now + weak var weakInternalChannel: ARTRealtimeChannelInternal? + // weakPublicRealtimeObjects is gone now + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + weak var weakPublicLiveObject: PublicDefaultLiveMap? + weak var weakInternalLiveObject: InternalDefaultLiveMap? + } + + func createAndDiscardObjects() async throws -> RemainingObjects { + func createObjects() async throws -> CreatedObjects { + // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. + let realtime = try await ClientHelper.realtimeWithObjects() + // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that getRoot() returns. We'll instead manually close the connection before proceeding with the test + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + let anyObjects = channel.objects + // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + let root = try #require(try await anyObjects.getRoot() as? PublicDefaultLiveMap) + + // Wait for the connection to close, as mentioned above + async let connectionClosedPromise: Void = withCheckedContinuation { continuation in + realtime.connection.on(.closed) { _ in + continuation.resume() + } + } + realtime.connection.close() + _ = await connectionClosedPromise + + return .init( + realtimeDeallocQueue: realtime.internal.queue, + weakPublicRealtime: realtime, + weakInternalRealtime: realtime.internal, + weakPublicChannel: channel, + weakInternalChannel: channel.internal, + weakPublicRealtimeObjects: objects, + weakInternalRealtimeObjects: objects.testsOnly_proxied, + strongPublicLiveObject: root, + weakInternalLiveObject: root.testsOnly_proxied, + ) + } + + let createdObjects = try await createObjects() + + // The only public object we have a strong reference to is strongPublicLiveObject, so the other public objects should have already been deallocated + #expect(createdObjects.weakPublicRealtime == nil) + #expect(createdObjects.weakPublicChannel == nil) + #expect(createdObjects.weakPublicRealtimeObjects == nil) + + // Now we check that, since we still have a strong reference to strongPublicLiveObject, none of the dependencies that it needs in order to function have been deallocated. + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + createdObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + #expect(createdObjects.weakInternalRealtime != nil) + #expect(createdObjects.weakInternalChannel != nil) + #expect(createdObjects.weakInternalRealtimeObjects != nil) + #expect(createdObjects.weakInternalLiveObject != nil) + + // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/30) + + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public LiveObject instance + return .init( + realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, + weakInternalRealtime: createdObjects.weakInternalRealtime, + weakInternalChannel: createdObjects.weakInternalChannel, + weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, + weakPublicLiveObject: createdObjects.strongPublicLiveObject, + weakInternalLiveObject: createdObjects.weakInternalLiveObject, + ) + } + + let remainingObjects = try await createAndDiscardObjects() + + // Check that the public LiveObject has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicLiveObject == nil) + + // Check that the internal objects that the public LiveObject needed in order to function have now been deallocated + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + remainingObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + + #expect(remainingObjects.weakInternalRealtime == nil) + #expect(remainingObjects.weakInternalChannel == nil) + #expect(remainingObjects.weakInternalRealtimeObjects == nil) + #expect(remainingObjects.weakInternalLiveObject == nil) + } + + @Test("Public objects have a stable identity") + func publicObjectIdentity() async throws { + let realtime = try await ClientHelper.realtimeWithObjects() + defer { realtime.close() } + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + + let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) + let root = try #require(try await objects.getRoot() as? PublicDefaultLiveMap) + + let objectsAgain = try #require(channel.objects as? PublicDefaultRealtimeObjects) + let rootAgain = try #require(try await objectsAgain.getRoot() as? PublicDefaultLiveMap) + + #expect(objects as AnyObject === objectsAgain as AnyObject) + #expect(root === rootAgain) + // TODO: when we have an easy way of populating the ObjectsPool (i.e. once we have a write API) then also test with a non-root LiveMap and a counter (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/30) + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 985354a1..dd3db831 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,12 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -23,18 +21,14 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) let map = try #require(result?.mapValue) // Verify it was added to the pool #expect(pool.entries["map:123@456"]?.mapValue != nil) - // Verify the map has the delegate set - #expect(map.testsOnly_delegate as AnyObject === delegate as AnyObject) // Verify the objectID is correctly set #expect(map.testsOnly_objectID == "map:123@456") } @@ -43,13 +37,12 @@ struct ObjectsPoolTests { @Test func createsZeroValueCounter() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger) let counter = try #require(result?.counterValue) - #expect(try counter.value == 0) + #expect(try counter.value(coreSDK: coreSDK) == 0) // Verify it was added to the pool #expect(pool.entries["counter:123@456"]?.counterValue != nil) @@ -61,11 +54,9 @@ struct ObjectsPoolTests { @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger) #expect(result == nil) } @@ -73,11 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -93,8 +82,8 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -103,13 +92,13 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify the existing map was updated by checking side effects of DefaultLiveMap.replaceData(using:) + // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) #expect(updatedMap === existingMap) // Checking map data to verify replaceData was called successfully - #expect(try updatedMap.get(key: "key1")?.stringValue == "updated_value") + #expect(try updatedMap.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated_value") // Checking site timeserials to verify they were updated by replaceData #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -118,10 +107,9 @@ struct ObjectsPoolTests { @Test func updatesExistingCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -129,13 +117,13 @@ struct ObjectsPoolTests { count: 42, ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify the existing counter was updated by checking side effects of DefaultLiveCounter.replaceData(using:) + // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) #expect(updatedCounter === existingCounter) // Checking counter value to verify replaceData was called successfully - #expect(try updatedCounter.value == 42) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 42) // Checking site timeserials to verify they were updated by replaceData #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -144,9 +132,8 @@ struct ObjectsPoolTests { @Test func createsNewCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -154,12 +141,12 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify a new counter was created and data was set by checking side effects of DefaultLiveCounter.replaceData(using:) + // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) // Checking counter value to verify the new counter was created and replaceData was called - #expect(try newCounter.value == 100) + #expect(try newCounter.value(coreSDK: coreSDK) == 100) // Checking site timeserials to verify they were set by replaceData #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) // Verify the objectID is correctly set per RTO5c1b1a @@ -173,7 +160,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -182,16 +169,14 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify a new map was created and data was set by checking side effects of DefaultLiveMap.replaceData(using:) + // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) // Checking map data to verify the new map was created and replaceData was called - #expect(try newMap.get(key: "key2")?.stringValue == "new_value") + #expect(try newMap.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new_value") // Checking site timeserials to verify they were set by replaceData #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) - // Verify delegate was set on the new map - #expect(newMap.testsOnly_delegate as AnyObject === delegate as AnyObject) // Verify the objectID and semantics are correctly set per RTO5c1b1b #expect(newMap.testsOnly_objectID == "map:hash@789") #expect(newMap.testsOnly_semantics == .known(.lww)) @@ -201,9 +186,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -213,7 +196,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -225,13 +208,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -240,7 +221,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -254,13 +235,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([], logger: logger) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -275,11 +254,11 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -313,7 +292,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool(syncObjects, logger: logger) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects @@ -324,23 +303,23 @@ struct ObjectsPoolTests { // Updated existing objects - verify by checking side effects of replaceData calls let updatedMap = try #require(pool.entries["map:existing@1"]?.mapValue) // Checking map data to verify replaceData was called successfully - #expect(try updatedMap.get(key: "updated")?.stringValue == "updated") + #expect(try updatedMap.get(key: "updated", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated") let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) // Checking counter value to verify replaceData was called successfully - #expect(try updatedCounter.value == 100) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 100) // New objects - verify by checking side effects of replaceData calls let newMap = try #require(pool.entries["map:new@1"]?.mapValue) // Checking map data to verify the new map was created and replaceData was called - #expect(try newMap.get(key: "new")?.stringValue == "new") + #expect(try newMap.get(key: "new", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify the objectID and semantics are correctly set per RTO5c1b1b #expect(newMap.testsOnly_objectID == "map:new@1") #expect(newMap.testsOnly_semantics == .known(.lww)) let newCounter = try #require(pool.entries["counter:new@1"]?.counterValue) // Checking counter value to verify the new counter was created and replaceData was called - #expect(try newCounter.value == 50) + #expect(try newCounter.value(coreSDK: coreSDK) == 50) // Verify the objectID is correctly set per RTO5c1b1a #expect(newCounter.testsOnly_objectID == "counter:new@1")