From fd0db2301bf24a116763516c2961ae87640be001 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 14:50:58 -0300 Subject: [PATCH 1/6] Fix nullability and default values of common LiveObject properties This is based on the values stated in RTOL3 of [1] at 29276a5. (We'll formalise the fact that these are common LiveObject properties in an upcoming commit.) [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 18 ++-- .../Internal/DefaultLiveMap.swift | 18 ++-- .../Internal/ObjectsPool.swift | 2 +- .../DefaultLiveCounterTests.swift | 39 ++++++--- .../DefaultLiveMapTests.swift | 82 ++++++++++++------- .../ObjectsPoolTests.swift | 20 ++--- 6 files changed, 111 insertions(+), 68 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 020bd535..4f1453ba 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -8,19 +8,19 @@ internal final class DefaultLiveCounter: LiveCounter { private nonisolated(unsafe) var mutableState: MutableState - internal var testsOnly_siteTimeserials: [String: String]? { + internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { mutableState.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { mutableState.createOperationIsMerged } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { mutableState.objectID } @@ -32,7 +32,7 @@ internal final class DefaultLiveCounter: LiveCounter { internal convenience init( testsOnly_data data: Double, - objectID: String?, + objectID: String, coreSDK: CoreSDK ) { self.init(data: data, objectID: objectID, coreSDK: coreSDK) @@ -40,7 +40,7 @@ internal final class DefaultLiveCounter: LiveCounter { private init( data: Double, - objectID: String?, + objectID: String, coreSDK: CoreSDK ) { mutableState = .init(data: data, objectID: objectID) @@ -52,7 +52,7 @@ internal final class DefaultLiveCounter: LiveCounter { /// - Parameters: /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( - objectID: String? = nil, + objectID: String, coreSDK: CoreSDK, ) -> Self { .init( @@ -123,13 +123,13 @@ internal final class DefaultLiveCounter: LiveCounter { internal var data: Double /// The site timeserials for this counter, per RTLC6a. - internal var siteTimeserials: [String: String]? + internal var siteTimeserials: [String: String] = [:] /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. - internal var createOperationIsMerged: Bool? + internal var createOperationIsMerged = false /// The "private `objectId` field" of RTO5c1b1a. - internal var objectID: String? + internal var objectID: String /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index a8ed390f..c432b5ec 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -19,7 +19,7 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { mutableState.objectID } @@ -31,13 +31,13 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_siteTimeserials: [String: String]? { + internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { mutableState.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { mutableState.createOperationIsMerged } @@ -55,7 +55,7 @@ internal final class DefaultLiveMap: LiveMap { internal convenience init( testsOnly_data data: [String: ObjectsMapEntry], - objectID: String? = nil, + objectID: String, testsOnly_semantics semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK @@ -71,7 +71,7 @@ internal final class DefaultLiveMap: LiveMap { private init( data: [String: ObjectsMapEntry], - objectID: String?, + objectID: String, semantics: WireEnum?, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK @@ -87,7 +87,7 @@ internal final class DefaultLiveMap: LiveMap { /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. internal static func createZeroValued( - objectID: String? = nil, + objectID: String, semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, @@ -275,16 +275,16 @@ internal final class DefaultLiveMap: LiveMap { internal var data: [String: ObjectsMapEntry] /// The "private `objectId` field" of RTO5c1b1b. - internal var objectID: String? + internal var objectID: String /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? /// The site timeserials for this map, per RTLM6a. - internal var siteTimeserials: [String: String]? + internal var siteTimeserials: [String: String] = [:] /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. - internal var createOperationIsMerged: Bool? + internal var createOperationIsMerged = false /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 11d8d001..7fb7ce0f 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -60,7 +60,7 @@ internal struct ObjectsPool { ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(delegate: rootDelegate, coreSDK: rootCoreSDK)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK)) } // MARK: - Typed root diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 17839c95..2c8df459 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -9,7 +9,7 @@ struct DefaultLiveCounterTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: channelState)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState)) #expect { _ = try counter.value @@ -25,7 +25,7 @@ struct DefaultLiveCounterTests { // @spec RTLC5c @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attached)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached)) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) @@ -39,7 +39,7 @@ struct DefaultLiveCounterTests { // @spec RTLC6a @Test func replacesSiteTimeserials() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -52,18 +52,35 @@ struct DefaultLiveCounterTests { // @spec RTLC6b - Tests the case without createOp, as RTLC6d2 takes precedence when createOp exists @Test func setsCreateOperationIsMergedToFalse() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + // Given: A counter whose createOperationIsMerged is true + let counter = { + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + // 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( + action: .known(.counterCreate), + ), + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged) + + return counter + }() + + // When: let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged == false) + + // Then: + #expect(!counter.testsOnly_createOperationIsMerged) } // @specOneOf(1/4) RTLC6c - count but no createOp @Test func setsDataToCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( count: 42, // Test value ) @@ -74,7 +91,7 @@ struct DefaultLiveCounterTests { // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) @@ -88,7 +105,7 @@ struct DefaultLiveCounterTests { // @specOneOf(3/4) RTLC6c - count and createOp @Test func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist @@ -101,7 +118,7 @@ struct DefaultLiveCounterTests { // @specOneOf(4/4) RTLC6c - no count but createOp @Test func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( action: .known(.counterCreate), @@ -116,14 +133,14 @@ struct DefaultLiveCounterTests { // @spec RTLC6d2 @Test func setsCreateOperationIsMergedToTrue() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( // Test value - must be non-nil action: .known(.counterCreate), ), ) counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged == true) + #expect(counter.testsOnly_createOperationIsMerged) } } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 97a8c10c..9e0ab150 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -9,7 +9,7 @@ struct DefaultLiveMapTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) #expect { _ = try map.get(key: "test") @@ -28,7 +28,7 @@ struct DefaultLiveMapTests { @Test func returnsNilWhenNoEntryExists() throws { let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) #expect(try map.get(key: "nonexistent") == nil) } @@ -40,7 +40,7 @@ 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], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } @@ -49,7 +49,7 @@ struct DefaultLiveMapTests { func returnsBooleanValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.boolValue == true) } @@ -60,7 +60,7 @@ 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], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.dataValue == bytes) } @@ -70,7 +70,7 @@ struct DefaultLiveMapTests { func returnsNumberValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.numberValue == 123.456) } @@ -80,7 +80,7 @@ struct DefaultLiveMapTests { func returnsStringValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.stringValue == "test") } @@ -91,7 +91,7 @@ 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], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } @@ -102,10 +102,10 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let result = try map.get(key: "key") let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -118,9 +118,9 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let result = try map.get(key: "key") let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -133,7 +133,7 @@ struct DefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } } @@ -145,7 +145,7 @@ struct DefaultLiveMapTests { func replacesSiteTimeserials() { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], @@ -158,13 +158,29 @@ struct DefaultLiveMapTests { // @spec RTLM6b @Test func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { + // Given: let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let map = { + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + + // 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( + createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), + ) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged) + + return map + }() + + // When: + let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) map.replaceData(using: state, objectsPool: &pool) - #expect(map.testsOnly_createOperationIsMerged == false) + + // Then: + #expect(!map.testsOnly_createOperationIsMerged) } // @specOneOf(1/2) RTLM6c @@ -172,7 +188,7 @@ struct DefaultLiveMapTests { func setsDataToMapEntries() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", @@ -192,7 +208,7 @@ struct DefaultLiveMapTests { func appliesMapSetOperationFromCreateOp() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -223,6 +239,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -252,14 +269,14 @@ struct DefaultLiveMapTests { func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) map.replaceData(using: state, objectsPool: &pool) - #expect(map.testsOnly_createOperationIsMerged == true) + #expect(map.testsOnly_createOperationIsMerged) } } @@ -273,7 +290,7 @@ struct DefaultLiveMapTests { // @spec RTLM13b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -315,6 +332,7 @@ struct DefaultLiveMapTests { "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], + objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, ) @@ -355,6 +373,7 @@ struct DefaultLiveMapTests { "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), ], + objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, ) @@ -386,8 +405,8 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -400,6 +419,7 @@ struct DefaultLiveMapTests { "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -443,6 +463,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -477,6 +498,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -541,7 +563,7 @@ struct DefaultLiveMapTests { func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) map.testsOnly_applyMapSetOperation( @@ -587,12 +609,13 @@ struct DefaultLiveMapTests { func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" let existingObject = DefaultLiveMap( testsOnly_data: [:], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -634,6 +657,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -654,6 +678,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -688,7 +713,7 @@ struct DefaultLiveMapTests { func createsNewEntryWhenNoExistingEntry() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -708,7 +733,7 @@ struct DefaultLiveMapTests { func setsNewEntryTombstoneToTrue() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -771,6 +796,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 2495cf3d..40752e8e 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -10,7 +10,7 @@ struct ObjectsPoolTests { func returnsExistingObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) @@ -87,7 +87,7 @@ struct ObjectsPoolTests { func updatesExistingMapObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let logger = TestLogger() @@ -114,7 +114,7 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let logger = TestLogger() @@ -221,9 +221,9 @@ struct ObjectsPoolTests { func removesObjectsNotInSync() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap1 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingMap2 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), @@ -250,7 +250,7 @@ struct ObjectsPoolTests { func doesNotRemoveRootObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) let logger = TestLogger() @@ -269,9 +269,9 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), From 337c415b3632d235e8871c1df4e097e4f31fc04f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 15:33:32 -0300 Subject: [PATCH 2/6] Extract LiveObject common mutable state into a new type Based on [1] at 29276a5. [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 26 +++++++---------- .../Internal/DefaultLiveMap.swift | 28 ++++++++----------- .../Internal/LiveObjectMutableState.swift | 11 ++++++++ 3 files changed, 32 insertions(+), 33 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 4f1453ba..708eef20 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -10,19 +10,19 @@ internal final class DefaultLiveCounter: LiveCounter { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.siteTimeserials + mutableState.liveObject.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } @@ -43,7 +43,7 @@ internal final class DefaultLiveCounter: LiveCounter { objectID: String, coreSDK: CoreSDK ) { - mutableState = .init(data: data, objectID: objectID) + mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.coreSDK = coreSDK } @@ -119,25 +119,19 @@ internal final class DefaultLiveCounter: LiveCounter { // MARK: - Mutable state and the operations that affect it private struct MutableState { + /// The mutable state common to all LiveObjects. + internal var liveObject: LiveObjectMutableState + /// The internal data that this map holds, per RTLC3. internal var data: Double - /// The site timeserials for this counter, per RTLC6a. - internal var siteTimeserials: [String: String] = [:] - - /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. - internal var createOperationIsMerged = false - - /// The "private `objectId` field" of RTO5c1b1a. - internal var objectID: String - /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - siteTimeserials = state.siteTimeserials + liveObject.siteTimeserials = state.siteTimeserials // RTLC6b: Set the private flag createOperationIsMerged to false - createOperationIsMerged = false + liveObject.createOperationIsMerged = false // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist data = state.counter?.count?.doubleValue ?? 0 @@ -149,7 +143,7 @@ internal final class DefaultLiveCounter: LiveCounter { data += createOpCount } // RTLC6d2: Set the private flag createOperationIsMerged to true - createOperationIsMerged = true + liveObject.createOperationIsMerged = true } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index c432b5ec..1a553504 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -21,7 +21,7 @@ internal final class DefaultLiveMap: LiveMap { internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } @@ -33,13 +33,13 @@ internal final class DefaultLiveMap: LiveMap { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.siteTimeserials + mutableState.liveObject.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } @@ -76,7 +76,7 @@ internal final class DefaultLiveMap: LiveMap { delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK ) { - mutableState = .init(data: data, objectID: objectID, semantics: semantics) + mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.delegate = .init(referenced: delegate) self.coreSDK = coreSDK } @@ -84,7 +84,7 @@ internal final class DefaultLiveMap: LiveMap { /// Creates a "zero-value LiveMap", per RTLM4. /// /// - Parameters: - /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. + /// - objectID: The value to use for the RTLO3a `objectID` property. /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. internal static func createZeroValued( objectID: String, @@ -271,21 +271,15 @@ internal final class DefaultLiveMap: LiveMap { // MARK: - Mutable state and the operations that affect it private struct MutableState { + /// The mutable state common to all LiveObjects. + internal var liveObject: LiveObjectMutableState + /// The internal data that this map holds, per RTLM3. internal var data: [String: ObjectsMapEntry] - /// The "private `objectId` field" of RTO5c1b1b. - internal var objectID: String - /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? - /// The site timeserials for this map, per RTLM6a. - internal var siteTimeserials: [String: String] = [:] - - /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. - internal var createOperationIsMerged = false - /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// /// - Parameters: @@ -297,10 +291,10 @@ internal final class DefaultLiveMap: LiveMap { coreSDK: CoreSDK, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - siteTimeserials = state.siteTimeserials + liveObject.siteTimeserials = state.siteTimeserials // RTLM6b: Set the private flag createOperationIsMerged to false - createOperationIsMerged = false + liveObject.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist data = state.map?.entries ?? [:] @@ -332,7 +326,7 @@ internal final class DefaultLiveMap: LiveMap { } } // RTLM6d2: Set the private flag createOperationIsMerged to true - createOperationIsMerged = true + liveObject.createOperationIsMerged = true } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift new file mode 100644 index 00000000..d0043c8a --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -0,0 +1,11 @@ +/// This is the equivalent of the `LiveObject` abstract class described in RTLO. +/// +/// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. +internal struct LiveObjectMutableState { + // RTLO3a + internal var objectID: String + // RTLO3b + internal var siteTimeserials: [String: String] = [:] + // RTLO3c + internal var createOperationIsMerged = false +} From 0a2e72a5a8fc2c51b04c7e4624174d25a70d8169 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 17:03:32 -0300 Subject: [PATCH 3/6] Add logger to DefaultLiveCounter and DefaultLiveMap All done by Cursor at my instruction. --- .../DefaultRealtimeObjects.swift | 6 +- .../Internal/DefaultLiveCounter.swift | 13 +- .../Internal/DefaultLiveMap.swift | 19 ++- .../Internal/ObjectsPool.swift | 16 ++- .../DefaultLiveCounterTests.swift | 27 +++-- .../DefaultLiveMapTests.swift | 111 ++++++++++++------ .../ObjectsPoolTests.swift | 77 ++++++------ 7 files changed, 174 insertions(+), 95 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 2e6ea38e..0e01a02d 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -73,7 +73,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK)) + mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK, logger: logger)) } // MARK: - LiveMapObjectPoolDelegate @@ -193,7 +193,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String, coreSDK: CoreSDK) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK, logger: logger) } } @@ -242,7 +242,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) + objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK, 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. diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 708eef20..a0708831 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin import Foundation /// Our default implementation of ``LiveCounter``. @@ -27,24 +28,28 @@ internal final class DefaultLiveCounter: LiveCounter { } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization internal convenience init( testsOnly_data data: Double, objectID: String, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { - self.init(data: data, objectID: objectID, coreSDK: coreSDK) + self.init(data: data, objectID: objectID, coreSDK: coreSDK, logger: logger) } private init( data: Double, objectID: String, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.coreSDK = coreSDK + self.logger = logger } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -54,11 +59,13 @@ internal final class DefaultLiveCounter: LiveCounter { internal static func createZeroValued( objectID: String, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) -> Self { .init( data: 0, objectID: objectID, coreSDK: coreSDK, + logger: logger, ) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 1a553504..83bdf160 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { @@ -50,6 +51,7 @@ internal final class DefaultLiveMap: LiveMap { } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -58,7 +60,8 @@ internal final class DefaultLiveMap: LiveMap { objectID: String, testsOnly_semantics semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { self.init( data: data, @@ -66,6 +69,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: semantics, delegate: delegate, coreSDK: coreSDK, + logger: logger, ) } @@ -74,11 +78,13 @@ internal final class DefaultLiveMap: LiveMap { objectID: String, semantics: WireEnum?, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + 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 } /// Creates a "zero-value LiveMap", per RTLM4. @@ -91,6 +97,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) -> Self { .init( data: [:], @@ -98,6 +105,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: semantics, delegate: delegate, coreSDK: coreSDK, + logger: logger, ) } @@ -231,6 +239,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, ) } } @@ -252,6 +261,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, ) } } @@ -289,6 +299,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: inout ObjectsPool, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -321,6 +332,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: mapDelegate, coreSDK: coreSDK, + logger: logger, ) } } @@ -338,6 +350,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: inout ObjectsPool, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -364,7 +377,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) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK, logger: logger) } } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 7fb7ce0f..41b6ab38 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -44,11 +44,13 @@ internal struct ObjectsPool { 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, ) } @@ -56,11 +58,12 @@ internal struct ObjectsPool { 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)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK, logger: logger)) } // MARK: - Typed root @@ -87,8 +90,9 @@ internal struct ObjectsPool { /// - 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) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, 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 @@ -106,9 +110,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK)) + entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK, logger: logger)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK)) + entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK, logger: logger)) default: return nil } @@ -159,14 +163,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) + let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK, 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) + let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK, logger: logger) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 2c8df459..746bde7f 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -9,7 +9,8 @@ struct DefaultLiveCounterTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState), logger: logger) #expect { _ = try counter.value @@ -25,7 +26,8 @@ struct DefaultLiveCounterTests { // @spec RTLC5c @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached), logger: logger) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) @@ -39,7 +41,8 @@ struct DefaultLiveCounterTests { // @spec RTLC6a @Test func replacesSiteTimeserials() { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -53,8 +56,9 @@ struct DefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToFalse() { // Given: A counter whose createOperationIsMerged is true + let logger = TestLogger() let counter = { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), 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( @@ -80,7 +84,8 @@ struct DefaultLiveCounterTests { // @specOneOf(1/4) RTLC6c - count but no createOp @Test func setsDataToCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( count: 42, // Test value ) @@ -91,7 +96,8 @@ struct DefaultLiveCounterTests { // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) @@ -105,7 +111,8 @@ struct DefaultLiveCounterTests { // @specOneOf(3/4) RTLC6c - count and createOp @Test func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist @@ -118,7 +125,8 @@ struct DefaultLiveCounterTests { // @specOneOf(4/4) RTLC6c - no count but createOp @Test func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( action: .known(.counterCreate), @@ -133,7 +141,8 @@ struct DefaultLiveCounterTests { // @spec RTLC6d2 @Test func setsCreateOperationIsMergedToTrue() { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( // Test value - must be non-nil action: .known(.counterCreate), diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 9e0ab150..ce991d27 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -9,7 +9,8 @@ struct DefaultLiveMapTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) #expect { _ = try map.get(key: "test") @@ -27,29 +28,32 @@ struct DefaultLiveMapTests { // @spec RTLM5d1 @Test func returnsNilWhenNoEntryExists() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "nonexistent") == nil) } // @spec RTLM5d2a @Test func returnsNilWhenEntryIsTombstoned() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry( tombstone: true, 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } // @spec RTLM5d2b @Test func returnsBooleanValue() throws { + 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.boolValue == true) } @@ -57,10 +61,11 @@ struct DefaultLiveMapTests { // @spec RTLM5d2c @Test func returnsBytesValue() throws { + let logger = TestLogger() 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.dataValue == bytes) } @@ -68,9 +73,10 @@ struct DefaultLiveMapTests { // @spec RTLM5d2d @Test func returnsNumberValue() throws { + 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.numberValue == 123.456) } @@ -78,9 +84,10 @@ struct DefaultLiveMapTests { // @spec RTLM5d2e @Test func returnsStringValue() throws { + 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.stringValue == "test") } @@ -88,24 +95,26 @@ struct DefaultLiveMapTests { // @spec RTLM5d2f1 @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { + let logger = TestLogger() 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) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool @Test func returnsReferencedMap() throws { + let logger = TestLogger() let objectId = "map1" 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) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -114,13 +123,14 @@ struct DefaultLiveMapTests { // @specOneOf(2/2) RTLM5d2f2 - Returns referenced counter when it exists in pool @Test func returnsReferencedCounter() throws { + let logger = TestLogger() let objectId = "counter1" let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -129,11 +139,12 @@ struct DefaultLiveMapTests { // @spec RTLM5d2g @Test func returnsNullOtherwise() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } } @@ -143,14 +154,15 @@ struct DefaultLiveMapTests { // @spec RTLM6a @Test func replacesSiteTimeserials() { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -161,9 +173,10 @@ struct DefaultLiveMapTests { // Given: let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let map = { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, 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( @@ -186,15 +199,16 @@ struct DefaultLiveMapTests { // @specOneOf(1/2) RTLM6c @Test func setsDataToMapEntries() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, 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) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -206,9 +220,10 @@ struct DefaultLiveMapTests { // @spec RTLM6d1a @Test func appliesMapSetOperationFromCreateOp() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -224,7 +239,7 @@ struct DefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) // 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 entries (per RTLM6c) and also the createOp (per RTLM6d1a) @@ -235,6 +250,7 @@ struct DefaultLiveMapTests { // @spec RTLM6d1b @Test func appliesMapRemoveOperationFromCreateOp() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -242,6 +258,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Confirm that the initial data is there #expect(try map.get(key: "key1") != nil) @@ -257,7 +274,7 @@ struct DefaultLiveMapTests { entries: ["key1": entry], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere // Check that MAP_REMOVE removed the initial data @@ -267,14 +284,15 @@ struct DefaultLiveMapTests { // @spec RTLM6d2 @Test func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -290,7 +308,8 @@ struct DefaultLiveMapTests { // @spec RTLM13b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -322,6 +341,7 @@ struct DefaultLiveMapTests { // @spec RTLM14 @Test func allPropertiesFilterOutTombstonedEntries() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -335,6 +355,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, + logger: logger, ) // Test size - should only count non-tombstoned entries @@ -366,6 +387,7 @@ struct DefaultLiveMapTests { // @specOneOf(2/2) RTLM13b @Test func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -376,6 +398,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, + logger: logger, ) let size = try map.size @@ -401,12 +424,13 @@ struct DefaultLiveMapTests { // @spec RTLM11d @Test func entriesHandlesAllValueTypes() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -422,6 +446,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) let size = try map.size @@ -459,6 +484,7 @@ struct DefaultLiveMapTests { // @spec RTLM7a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -466,8 +492,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -494,6 +521,7 @@ struct DefaultLiveMapTests { (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -501,8 +529,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -561,10 +590,11 @@ struct DefaultLiveMapTests { (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -607,9 +637,10 @@ struct DefaultLiveMapTests { // This is a sense check to convince ourselves that when applying a MAP_SET operation that references an object, then, because of RTO6a, if the referenced object already exists in the pool it is not replaced when RTLM7c1 is applied. @Test func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -618,10 +649,12 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) var pool = ObjectsPool( rootDelegate: delegate, rootCoreSDK: coreSDK, + logger: logger, testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object @@ -653,6 +686,7 @@ struct DefaultLiveMapTests { // @spec RTLM8a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -660,6 +694,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -674,6 +709,7 @@ struct DefaultLiveMapTests { // @spec RTLM8a2c @Test func appliesOperationWhenCanBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -681,6 +717,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -711,9 +748,10 @@ struct DefaultLiveMapTests { // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial @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) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -731,9 +769,10 @@ struct DefaultLiveMapTests { // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true @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) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -792,6 +831,7 @@ struct DefaultLiveMapTests { (entrySerial: "", operationSerial: "ts1", shouldApply: true), ] as [(entrySerial: String?, operationSerial: String?, shouldApply: Bool)]) func mapOperationApplicability(entrySerial: String?, operationSerial: String?, shouldApply: Bool) throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -799,8 +839,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 40752e8e..985354a1 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -8,12 +8,13 @@ struct ObjectsPoolTests { // @spec RTO6a @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) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + 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 result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,11 +22,12 @@ struct ObjectsPoolTests { // @spec RTO6b2 @Test func createsZeroValueMap() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let map = try #require(result?.mapValue) // Verify it was added to the pool @@ -40,11 +42,12 @@ struct ObjectsPoolTests { // @spec RTO6b3 @Test func createsZeroValueCounter() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let counter = try #require(result?.counterValue) #expect(try counter.value == 0) @@ -57,22 +60,24 @@ struct ObjectsPoolTests { // Sense check to see how it behaves when given an object ID not in the format of RTO6b1 (spec isn't prescriptive here) @Test func returnsNilForInvalidObjectId() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) #expect(result == nil) } // Sense check to see how it behaves when given an object ID not covered by RTO6b2 or RTO6b3 @Test func returnsNilForUnknownType() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -85,11 +90,11 @@ struct ObjectsPoolTests { // @specOneOf(1/2) RTO5c1a1 - Override the internal data for existing map objects @Test func updatesExistingMapObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) - let logger = TestLogger() + 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 (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -112,11 +117,11 @@ struct ObjectsPoolTests { // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects @Test func updatesExistingCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) - let logger = TestLogger() + 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 objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -138,10 +143,10 @@ struct ObjectsPoolTests { // @spec RTO5c1b1a @Test func createsNewCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -164,11 +169,11 @@ struct ObjectsPoolTests { // @spec RTO5c1b1b @Test func createsNewMapObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -195,10 +200,10 @@ struct ObjectsPoolTests { // @spec RTO5c1b1c @Test func ignoresNonMapOrCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -219,18 +224,18 @@ struct ObjectsPoolTests { // @spec(RTO5c2) Remove objects not received during sync @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) - let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + 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) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), ]) - let logger = TestLogger() // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") @@ -248,11 +253,11 @@ struct ObjectsPoolTests { // @spec(RTO5c2a) Root object must not be removed @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) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) - let logger = TestLogger() + 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)]) // Sync with empty list (no objects) pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) @@ -266,19 +271,19 @@ struct ObjectsPoolTests { // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations @Test func handlesComplexSyncScenario() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + 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) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), ]) - let logger = TestLogger() let syncObjects = [ // Update existing map From d795f9e2948e054a5430d875bb91777f09b6409f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 15:35:38 -0300 Subject: [PATCH 4/6] Implement the LiveObject.canApplyOperation method Based on [1] at 29276a5. Development approach as described in cb427d8. [1] https://github.com/ably/specification/pull/343 --- .../Internal/LiveObjectMutableState.swift | 39 ++++++ .../LiveObjectMutableStateTests.swift | 116 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index d0043c8a..c81bec58 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -1,3 +1,5 @@ +internal import AblyPlugin + /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. @@ -8,4 +10,41 @@ internal struct LiveObjectMutableState { internal var siteTimeserials: [String: String] = [:] // RTLO3c internal var createOperationIsMerged = false + + /// Represents parameters of an operation that `canApplyOperation` has decided can be applied to a `LiveObject`. + /// + /// The key thing is that it offers a non-nil `serial` and `siteCode`, which will be needed when subsequently performing the operation. + internal struct ApplicableOperation: Equatable { + internal let objectMessageSerial: String + internal let objectMessageSiteCode: String + } + + /// Indicates whether an operation described by an `ObjectMessage` should be applied or discarded, per RTLO4a. + /// + /// Instead of returning a `Bool`, in the case where the operation can be applied it returns a non-nil `ApplicableOperation` (whose non-nil `serial` and `siteCode` will be needed as part of subsequently performing this operation). + internal func canApplyOperation(objectMessageSerial: String?, objectMessageSiteCode: String?, logger: Logger) -> ApplicableOperation? { + // RTLO4a3: Both ObjectMessage.serial and ObjectMessage.siteCode must be non-empty strings + guard let serial = objectMessageSerial, !serial.isEmpty, + let siteCode = objectMessageSiteCode, !siteCode.isEmpty + else { + // RTLO4a3: Otherwise, log a warning that the object operation message has invalid serial values + logger.log("Object operation message has invalid serial values: serial=\(objectMessageSerial ?? "nil"), siteCode=\(objectMessageSiteCode ?? "nil")", level: .warn) + return nil + } + + // RTLO4a4: Get the siteSerial value stored for this LiveObject in the siteTimeserials map using the key ObjectMessage.siteCode + let siteSerial = siteTimeserials[siteCode] + + // RTLO4a5: If the siteSerial for this LiveObject is null or an empty string, return true + guard let siteSerial, !siteSerial.isEmpty else { + return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) + } + + // RTLO4a6: If the siteSerial for this LiveObject is not an empty string, return true if ObjectMessage.serial is greater than siteSerial when compared lexicographically + if serial > siteSerial { + return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) + } + + return nil + } } diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift new file mode 100644 index 00000000..47c03cbb --- /dev/null +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -0,0 +1,116 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +import Testing + +/// Tests for `LiveObjectMutableState`. +struct LiveObjectMutableStateTests { + /// Tests for `LiveObjectMutableState.canApplyOperation`, covering RTLO4 specification points. + struct CanApplyOperationTests { + /// Test case data for canApplyOperation tests + struct TestCase { + let description: String + let objectMessageSerial: String? + let objectMessageSiteCode: String? + let siteTimeserials: [String: String] + let expectedResult: LiveObjectMutableState.ApplicableOperation? + } + + // @spec RTLO4a3 + // @spec RTLO4a4 + // @spec RTLO4a5 + // @spec RTLO4a6 + @Test(arguments: [ + // RTLO4a3: Both ObjectMessage.serial and ObjectMessage.siteCode must be non-empty strings + TestCase( + description: "serial is nil, siteCode is valid - should return nil", + objectMessageSerial: nil, + objectMessageSiteCode: "site1", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is empty string, siteCode is valid - should return nil", + objectMessageSerial: "", + objectMessageSiteCode: "site1", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is valid, siteCode is nil - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: nil, + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is valid, siteCode is empty string - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "both serial and siteCode are invalid - should return nil", + objectMessageSerial: nil, + objectMessageSiteCode: "", + siteTimeserials: [:], + expectedResult: nil, + ), + + // RTLO4a5: If the siteSerial for this LiveObject is null or an empty string, return ApplicableOperation + TestCase( + description: "siteSerial is nil (siteCode doesn't exist) - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site2": "serial1"], // i.e. only has an entry for a different siteCode + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + TestCase( + description: "siteSerial is empty string - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "", "site2": "serial1"], + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + + // RTLO4a6: If the siteSerial for this LiveObject is not an empty string, return ApplicableOperation if ObjectMessage.serial is greater than siteSerial when compared lexicographically + TestCase( + description: "serial is greater than siteSerial lexicographically - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial1"], + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + TestCase( + description: "serial is less than siteSerial lexicographically - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial2"], + expectedResult: nil, + ), + TestCase( + description: "serial equals siteSerial - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial1"], + expectedResult: nil, + ), + ]) + func canApplyOperation(testCase: TestCase) { + let state = LiveObjectMutableState( + objectID: "test:object@123", + siteTimeserials: testCase.siteTimeserials, + ) + let logger = TestLogger() + + let result = state.canApplyOperation( + objectMessageSerial: testCase.objectMessageSerial, + objectMessageSiteCode: testCase.objectMessageSiteCode, + logger: logger, + ) + + #expect(result == testCase.expectedResult, "Expected \(String(describing: testCase.expectedResult)) for case: \(testCase.description)") + } + } +} From c315352683dbb461bbd5055e25616c3e1de6aee7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 8 Jul 2025 10:54:33 -0300 Subject: [PATCH 5/6] Pull "apply initial value" into a separate operation, per the spec This applies the changes from [1] at 29276a5. Development approach as described in cb427d8. [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 26 +++- .../Internal/DefaultLiveMap.swift | 82 +++++++---- .../DefaultLiveCounterTests.swift | 92 +++++++----- .../DefaultLiveMapTests.swift | 133 +++++++++++------- 4 files changed, 212 insertions(+), 121 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index a0708831..98e4bbfe 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -123,6 +123,13 @@ internal final class DefaultLiveCounter: LiveCounter { } } + /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) { + mutex.withLock { + mutableState.mergeInitialValue(from: operation) + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -143,15 +150,20 @@ internal final class DefaultLiveCounter: LiveCounter { // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist data = state.counter?.count?.doubleValue ?? 0 - // RTLC6d: If ObjectState.createOp is present + // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 if let createOp = state.createOp { - // RTLC6d1: Add ObjectState.createOp.counter.count to data, if it exists - if let createOpCount = createOp.counter?.count?.doubleValue { - data += createOpCount - } - // RTLC6d2: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + mergeInitialValue(from: createOp) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + internal mutating func mergeInitialValue(from operation: ObjectOperation) { + // RTLC10a: Add ObjectOperation.counter.count to data, if it exists + if let operationCount = operation.counter?.count?.doubleValue { + data += operationCount } + // RTLC10b: Set the private flag createOperationIsMerged to true + liveObject.createOperationIsMerged = true } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 83bdf160..80641735 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -244,6 +244,19 @@ internal final class DefaultLiveMap: LiveMap { } } + /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + /// Applies a `MAP_SET` operation to a key, per RTLM7. /// /// This is currently exposed just so that the tests can test RTLM7 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. @@ -310,36 +323,53 @@ internal final class DefaultLiveMap: LiveMap { // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist data = state.map?.entries ?? [:] - // RTLM6d: If ObjectState.createOp is present + // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 if let createOp = state.createOp { - // RTLM6d1: For each key–ObjectsMapEntry pair in ObjectState.createOp.map.entries - if let entries = createOp.map?.entries { - for (key, entry) in entries { - if entry.tombstone == true { - // RTLM6d1b: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation - // to the specified key using ObjectsMapEntry.timeserial per RTLM8 - applyMapRemoveOperation( - key: key, - operationTimeserial: entry.timeserial, - ) - } else { - // RTLM6d1a: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation - // to the specified key using ObjectsMapEntry.timeserial and ObjectsMapEntry.data per RTLM7 - applyMapSetOperation( - key: key, - operationTimeserial: entry.timeserial, - operationData: entry.data, - objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, - logger: logger, - ) - } + mergeInitialValue( + from: createOp, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + 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 + if let entries = operation.map?.entries { + for (key, entry) in entries { + if entry.tombstone == true { + // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation + // as described in RTLM8, passing in the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + applyMapRemoveOperation( + key: key, + operationTimeserial: entry.timeserial, + ) + } else { + // RTLM17a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation + // as described in RTLM7, passing in ObjectsMapEntry.data and the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + applyMapSetOperation( + key: key, + operationTimeserial: entry.timeserial, + operationData: entry.data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) } } - // RTLM6d2: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true } + // RTLM17b: Set the private flag createOperationIsMerged to true + liveObject.createOperationIsMerged = true } /// Applies a `MAP_SET` operation to a key, per RTLM7. diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 746bde7f..49434506 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -52,7 +52,7 @@ struct DefaultLiveCounterTests { /// Tests for the case where createOp is not present struct WithoutCreateOpTests { - // @spec RTLC6b - Tests the case without createOp, as RTLC6d2 takes precedence when createOp exists + // @spec RTLC6b - Tests the case without createOp, as RTLC10b takes precedence when createOp exists @Test func setsCreateOperationIsMergedToFalse() { // Given: A counter whose createOperationIsMerged is true @@ -105,12 +105,11 @@ struct DefaultLiveCounterTests { } } - /// Tests for RTLC6d (with createOp present) + /// Tests for RTLC10 (merge initial value from createOp) struct WithCreateOpTests { - // @specOneOf(1/2) RTLC6d1 - with count - // @specOneOf(3/4) RTLC6c - count and createOp + // @spec RTLC10 - Tests that replaceData merges initial value when createOp is present @Test - func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { + func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( @@ -118,39 +117,62 @@ struct DefaultLiveCounterTests { count: 5, // Test value - must exist ) counter.replaceData(using: state) - #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC6d1) + #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) + #expect(counter.testsOnly_createOperationIsMerged) } + } + } - // @specOneOf(2/2) RTLC6d1 - no count - // @specOneOf(4/4) RTLC6c - no count but createOp - @Test - func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - let state = TestFactories.counterObjectState( - createOp: TestFactories.objectOperation( - action: .known(.counterCreate), - counter: nil, // Test value - must be nil - ), - count: 5, // Test value - ) - counter.replaceData(using: state) - #expect(try counter.value == 5) // Only the base counter.count value - } + /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points + struct MergeInitialValueTests { + // @specOneOf(1/2) RTLC10a - with count + @Test + func addsCounterCountToData() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - // @spec RTLC6d2 - @Test - func setsCreateOperationIsMergedToTrue() { - let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - let state = TestFactories.counterObjectState( - createOp: TestFactories.objectOperation( // Test value - must be non-nil - action: .known(.counterCreate), - ), - ) - counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged) - } + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 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 + } + + // @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) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 5) + + // Apply merge operation with no count + let operation = TestFactories.objectOperation( + action: .known(.counterCreate), + counter: nil, // Test value - must be nil + ) + counter.testsOnly_mergeInitialValue(from: operation) + + #expect(try counter.value == 5) // Unchanged + } + + // @spec RTLC10b + @Test + func setsCreateOperationIsMergedToTrue() { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply merge operation + let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist + counter.testsOnly_mergeInitialValue(from: operation) + + #expect(counter.testsOnly_createOperationIsMerged) } } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index ce991d27..66d7b01d 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -217,9 +217,9 @@ struct DefaultLiveMapTests { } // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp - // @spec RTLM6d1a + // @spec RTLM6d @Test - func appliesMapSetOperationFromCreateOp() throws { + func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) @@ -241,59 +241,10 @@ struct DefaultLiveMapTests { ) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) - // 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 entries (per RTLM6c) and also the createOp (per RTLM6d1a) + // 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") - } - - // @spec RTLM6d1b - @Test - func appliesMapRemoveOperationFromCreateOp() throws { - let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( - testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], - objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, - logger: logger, - ) - // Confirm that the initial data is there - #expect(try map.get(key: "key1") != nil) - - let entry = TestFactories.mapEntry( - tombstone: true, - data: ObjectData(), - ) - let state = TestFactories.objectState( - objectId: "arbitrary-id", - createOp: TestFactories.mapCreateOperation( - objectId: "arbitrary-id", - entries: ["key1": entry], - ), - ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - map.replaceData(using: state, objectsPool: &pool) - // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere - // Check that MAP_REMOVE removed the initial data - #expect(try map.get(key: "key1") == nil) - } - - // @spec RTLM6d2 - @Test - func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { - let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let state = TestFactories.objectState( - objectId: "arbitrary-id", - createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), - ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } } @@ -861,4 +812,80 @@ struct DefaultLiveMapTests { } } } + + /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLM17 specification points + struct MergeInitialValueTests { + // @spec RTLM17a1 + @Test + func appliesMapSetOperationsFromOperation() throws { + 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) + + // Apply merge operation with MAP_SET entries + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // 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") + } + + // @spec RTLM17a2 + @Test + func appliesMapRemoveOperationsFromOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + objectID: "arbitrary", + delegate: delegate, + coreSDK: coreSDK, + logger: logger, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Confirm that the initial data is there + #expect(try map.get(key: "key1") != nil) + + // Apply merge operation with MAP_REMOVE entry + let entry = TestFactories.mapEntry( + tombstone: true, + timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" + data: ObjectData(), + ) + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: ["key1": entry], + ) + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // Verify the MAP_REMOVE operation was applied + #expect(try map.get(key: "key1") == 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) + + // Apply merge operation + let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + #expect(map.testsOnly_createOperationIsMerged) + } + } } From 643035886242daaacd4b398529e8b5b285fead74 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 10:46:44 -0300 Subject: [PATCH 6/6] Apply OBJECT ProtocolMessages Based on [1] at 29276a5. I wrote the implementation, and for the tests followed the development approach described in cb427d8. We have a separate issue for applying RTO8a's buffering during a sync, so I haven't done that here. [1] https://github.com/ably/specification/pull/343 --- .../DefaultRealtimeObjects.swift | 86 +++++- .../Internal/DefaultLiveCounter.swift | 90 ++++++ .../Internal/DefaultLiveMap.swift | 124 +++++++++ .../Internal/ObjectsPool.swift | 25 ++ .../DefaultLiveCounterTests.swift | 158 +++++++++++ .../DefaultLiveMapTests.swift | 194 +++++++++++++ .../DefaultRealtimeObjectsTests.swift | 259 ++++++++++++++++++ .../Helpers/TestFactories.swift | 99 +++++++ 8 files changed, 1034 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 0e01a02d..b96c82b0 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -166,8 +166,17 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD receivedObjectProtocolMessages } + /// Implements the `OBJECT` handling of RTO8. internal func handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { - receivedObjectProtocolMessagesContinuation.yield(objectMessages) + mutex.withLock { + mutableState.handleObjectProtocolMessage( + objectMessages: objectMessages, + logger: logger, + receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, + mapDelegate: self, + coreSDK: coreSDK, + ) + } } internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { @@ -320,5 +329,80 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD syncStatus.signalSyncComplete() } } + + /// Implements the `OBJECT` handling of RTO8. + internal mutating func handleObjectProtocolMessage( + objectMessages: [InboundObjectMessage], + logger: Logger, + receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + receivedObjectProtocolMessagesContinuation.yield(objectMessages) + + logger.log("handleObjectProtocolMessage(objectMessages: \(objectMessages))", level: .debug) + + // TODO: RTO8a's buffering + + // RTO8b + for objectMessage in objectMessages { + applyObjectProtocolMessageObjectMessage( + objectMessage, + logger: logger, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } + } + + /// Implements the `OBJECT` application of RTO9. + private mutating func applyObjectProtocolMessageObjectMessage( + _ objectMessage: InboundObjectMessage, + logger: Logger, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + guard let operation = objectMessage.operation else { + // RTO9a1 + logger.log("Unsupported OBJECT message received (no operation); \(objectMessage)", level: .warn) + return + } + + // RTO9a2a1, RTO9a2a2 + let entry: ObjectsPool.Entry + if let existingEntry = objectsPool.entries[operation.objectId] { + entry = existingEntry + } 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) + return + } + + entry = newEntry + } + + switch operation.action { + case let .known(action): + switch action { + case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete: + // RTO9a2a3 + entry.apply( + operation, + objectMessageSerial: objectMessage.serial, + objectMessageSiteCode: objectMessage.siteCode, + objectsPool: &objectsPool, + ) + } + case let .unknown(rawValue): + // RTO9a2b + logger.log("Unsupported OBJECT operation action \(rawValue) received", level: .warn) + return + } + } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 98e4bbfe..b4c9603a 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -130,6 +130,38 @@ internal final class DefaultLiveCounter: LiveCounter { } } + /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. + internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) { + mutex.withLock { + mutableState.applyCounterCreateOperation(operation, logger: logger) + } + } + + /// Test-only method to apply a COUNTER_INC operation, per RTLC9. + internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + mutex.withLock { + mutableState.applyCounterIncOperation(operation) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + mutex.withLock { + mutableState.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + logger: logger, + ) + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -165,5 +197,63 @@ internal final class DefaultLiveCounter: LiveCounter { // RTLC10b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + internal mutating func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + logger: Logger, + ) { + guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + // RTLC7b + logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) + return + } + + // RTLC7c + liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + + switch operation.action { + case .known(.counterCreate): + // RTLC7d1 + applyCounterCreateOperation( + operation, + logger: logger, + ) + case .known(.counterInc): + // RTLC7d2 + applyCounterIncOperation(operation.counterOp) + default: + // RTLC7d3 + logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) + } + } + + /// Applies a `COUNTER_CREATE` operation, per RTLC8. + internal mutating func applyCounterCreateOperation( + _ operation: ObjectOperation, + logger: Logger, + ) { + if liveObject.createOperationIsMerged { + // RTLC8b + logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) + return + } + + // RTLC8c + mergeInitialValue(from: operation) + } + + /// Applies a `COUNTER_INC` operation, per RTLC9. + internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + guard let operation else { + return + } + + // RTLC9b + data += operation.amount.doubleValue + } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 80641735..d9a5af6b 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -257,6 +257,39 @@ internal final class DefaultLiveMap: LiveMap { } } + /// Test-only method to apply a MAP_CREATE operation, per RTLM16. + internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + mutex.withLock { + mutableState.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + /// Applies a `MAP_SET` operation to a key, per RTLM7. /// /// This is currently exposed just so that the tests can test RTLM7 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. @@ -372,6 +405,71 @@ internal final class DefaultLiveMap: LiveMap { liveObject.createOperationIsMerged = true } + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + internal mutating func apply( + _ operation: ObjectOperation, + 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 { + // RTLM15b + logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) + return + } + + // RTLM15c + liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + + switch operation.action { + case .known(.mapCreate): + // RTLM15d1 + applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + case .known(.mapSet): + guard let mapOp = operation.mapOp else { + logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) + return + } + guard let data = mapOp.data else { + logger.log("Could not apply MAP_SET since operation.data is missing", level: .warn) + return + } + + // RTLM15d2 + applyMapSetOperation( + key: mapOp.key, + operationTimeserial: applicableOperation.objectMessageSerial, + operationData: data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + case .known(.mapRemove): + guard let mapOp = operation.mapOp else { + return + } + + // RTLM15d3 + applyMapRemoveOperation( + key: mapOp.key, + operationTimeserial: applicableOperation.objectMessageSerial, + ) + default: + // RTLM15d4 + logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) + } + } + /// Applies a `MAP_SET` operation to a key, per RTLM7. internal mutating func applyMapSetOperation( key: String, @@ -477,6 +575,32 @@ internal final class DefaultLiveMap: LiveMap { false } } + + /// Applies a `MAP_CREATE` operation, per RTLM16. + internal mutating func applyMapCreateOperation( + _ operation: ObjectOperation, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger, + ) { + if liveObject.createOperationIsMerged { + // RTLM16b + logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) + return + } + + // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 + + // RTLM16d + mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 41b6ab38..4e2efc4b 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -28,6 +28,31 @@ internal struct ObjectsPool { counter } } + + /// Applies an operation to a LiveObject, per RTO9a2a3. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + switch self { + case let .map(map): + map.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + ) + case let .counter(counter): + counter.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + ) + } + } } /// Keyed by `objectId`. diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 49434506..4b8ce9c3 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -175,4 +175,162 @@ struct DefaultLiveCounterTests { #expect(counter.testsOnly_createOperationIsMerged) } } + + /// Tests for `COUNTER_CREATE` operations, covering RTLC8 specification points + struct CounterCreateOperationTests { + // @spec RTLC8b + @Test + func discardsOperationWhenCreateOperationIsMerged() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data and mark create operation as merged + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + #expect(counter.testsOnly_createOperationIsMerged) + + // Try to apply another COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 20) + counter.testsOnly_applyCounterCreateOperation(operation) + + // Verify the operation was discarded - data unchanged + #expect(try counter.value == 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) + + // Set initial data but don't mark create operation as merged + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(!counter.testsOnly_createOperationIsMerged) + + // Apply COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 10) + 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(counter.testsOnly_createOperationIsMerged) + } + } + + /// Tests for `COUNTER_INC` operations, covering RTLC9 specification points + struct CounterIncOperationTests { + // @spec RTLC9b + @Test(arguments: [ + (operation: TestFactories.counterOp(amount: 10), expectedValue: 15.0), // 5 + 10 + (operation: nil as WireObjectsCounterOp?, expectedValue: 5.0), // unchanged + ] 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) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 5) + + // Apply COUNTER_INC operation + counter.testsOnly_applyCounterIncOperation(operation) + + // Verify the operation was applied correctly + #expect(try counter.value == expectedValue) + } + } + + /// Tests for the `apply(_ operation:, …)` method, covering RTLC7 specification points + struct ApplyOperationTests { + // @spec RTLC7b - Tests that an operation does not get applied when canApplyOperation returns nil + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set up the counter with an existing site timeserial that will cause the operation to be discarded + counter.replaceData(using: TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + count: 5, + )) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterOp: TestFactories.counterOp(amount: 10), + ) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), 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( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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) + // Verify site timeserials unchanged + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/2) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d1 - Tests COUNTER_CREATE operation application + @Test + func appliesCounterCreateOperation() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + let operation = TestFactories.counterCreateOperation(count: 15) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply COUNTER_CREATE operation + counter.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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(counter.testsOnly_createOperationIsMerged) + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/2) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d2 - Tests COUNTER_INC operation application + @Test + func appliesCounterIncOperation() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + #expect(try counter.value == 5) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterOp: TestFactories.counterOp(amount: 10), + ) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply COUNTER_INC operation + counter.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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 + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specUntested RTLC7e3 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 66d7b01d..f9bb1838 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -888,4 +888,198 @@ struct DefaultLiveMapTests { #expect(map.testsOnly_createOperationIsMerged) } } + + /// Tests for `MAP_CREATE` operations, covering RTLM16 specification points + struct MapCreateOperationTests { + // @spec RTLM16b + @Test + func discardsOperationWhenCreateOperationIsMerged() throws { + 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) + + // Set initial data and mark create operation as merged + map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged) + + // Try to apply another MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry]) + 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 + } + + // @spec RTLM16d + @Test + func mergesInitialValue() throws { + 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) + + // Set initial data but don't mark create operation as merged + map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + #expect(!map.testsOnly_createOperationIsMerged) + + // Apply MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]) + 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(map.testsOnly_createOperationIsMerged) + } + } + + /// Tests for the `apply(_ operation:, …)` method, covering RTLM15 specification points + struct ApplyOperationTests { + // @spec RTLM15b - Tests that an operation does not get applied when canApplyOperation returns nil + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, 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) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + map.replaceData(using: TestFactories.mapObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + entries: [key1: entry1], + ), objectsPool: &pool) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .string("new"))), + ) + + // 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) + map.apply( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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") + // Verify site timeserials unchanged + #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d1 - Tests MAP_CREATE operation application + @Test + func appliesMapCreateOperation() 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 operation = TestFactories.mapCreateOperation( + entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Apply MAP_CREATE operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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(map.testsOnly_createOperationIsMerged) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d2 - Tests MAP_SET operation application + @Test + func appliesMapSetOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + // Set initial data + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, 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") + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .string("new"))), + ) + + // Apply MAP_SET operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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") + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(3/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d3 - Tests MAP_REMOVE operation application + @Test + func appliesMapRemoveOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + // Set initial data + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, 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") + + let operation = TestFactories.objectOperation( + action: .known(.mapRemove), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData()), + ) + + // Apply MAP_REMOVE operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specUntested RTLM15d4 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + } } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift index f6e9d69f..35804f78 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -664,4 +664,263 @@ struct DefaultRealtimeObjectsTests { } } } + + /// Tests for `DefaultRealtimeObjects.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 { + // @specUntested RTO9a1 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + // @specUntested RTO9a2b - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + + // MARK: - RTO9a2a1 Tests + + // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool + @Test + func createsObjectInObjectsPoolWhenNecessary() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:new@123" + + // Verify the object doesn't exist in the pool initially + let initialPool = realtimeObjects.testsOnly_objectsPool + #expect(initialPool.entries[objectId] == nil) + + // Create a MAP_SET operation message for a non-existent object + let operationMessage = TestFactories.mapSetOperationMessage( + objectId: objectId, + key: "testKey", + value: "testValue", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // Verify the object was created in the ObjectsPool (RTO9a2a1) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries[objectId] != nil) + } + + // MARK: - RTO9a2a3 Tests for MAP_CREATE + + // TODO: Understand what to do with OBJECT_DELETE (https://github.com/ably/specification/pull/343#discussion_r2193126548) + + // @specOneOf(1/5) RTO9a2a3 - Tests MAP_CREATE operation application + @Test + func appliesMapCreateOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + + // 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) + #expect(initialValue == "existingValue") + + // Create a MAP_CREATE operation message + let (createKey, createEntry) = TestFactories.stringMapEntry(key: "createKey", value: "createValue") + let operationMessage = TestFactories.mapCreateOperationMessage( + objectId: objectId, + entries: [createKey: createEntry], + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // 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) + #expect(finalValue == "createValue") + #expect(map.testsOnly_createOperationIsMerged) + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_SET + + // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application + @Test + func appliesMapSetOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + + // 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) + #expect(initialValue == "existingValue") + + // Create a MAP_SET operation message + let operationMessage = TestFactories.mapSetOperationMessage( + objectId: objectId, + key: "existingKey", + value: "newValue", + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // 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) + #expect(finalValue == "newValue") + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_REMOVE + + // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application + @Test + func appliesMapRemoveOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + + // 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) + #expect(initialValue == "existingValue") + + // Create a MAP_REMOVE operation message + let operationMessage = TestFactories.mapRemoveOperationMessage( + objectId: objectId, + key: "existingKey", + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // 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") + #expect(finalValue == nil) // Key should be removed/tombstoned + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_CREATE + + // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application + @Test + func appliesCounterCreateOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "counter:test@123" + + // Create a counter object in the pool first + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + count: 5, + ), + ], + protocolMessageChannelSerial: nil, + ) + + // Verify the object exists and has initial data + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) + let initialValue = try counter.value + #expect(initialValue == 5) + + // Create a COUNTER_CREATE operation message + let operationMessage = TestFactories.counterCreateOperationMessage( + objectId: objectId, + count: 10, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // 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 + #expect(finalValue == 15) // 5 + 10 (initial value merged) + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_INC + + // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application + @Test + func appliesCounterIncOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "counter:test@123" + + // Create a counter object in the pool first + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + count: 5, + ), + ], + protocolMessageChannelSerial: nil, + ) + + // Verify the object exists and has initial data + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) + let initialValue = try counter.value + #expect(initialValue == 5) + + // Create a COUNTER_INC operation message + let operationMessage = TestFactories.counterIncOperationMessage( + objectId: objectId, + amount: 10, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + + // 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 + #expect(finalValue == 15) // 5 + 10 + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index b61414c0..cf94ccfc 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -386,6 +386,11 @@ struct TestFactories { ) } + /// Creates a WireObjectsCounterOp + static func counterOp(amount: Int = 10) -> WireObjectsCounterOp { + WireObjectsCounterOp(amount: NSNumber(value: amount)) + } + // MARK: - ObjectsMapEntry Factory /// Creates an ObjectsMapEntry with sensible defaults @@ -516,6 +521,100 @@ struct TestFactories { WireObjectsCounter(count: count.map { NSNumber(value: $0) }) } + // MARK: - Operation Message Factories + + /// Creates an InboundObjectMessage with a MAP_SET operation + static func mapSetOperationMessage( + objectId: String = "map:test@123", + key: String = "testKey", + value: String = "testValue", + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapSet), + objectId: objectId, + mapOp: ObjectsMapOp( + key: key, + data: ObjectData(string: .string(value)), + ), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_REMOVE operation + static func mapRemoveOperationMessage( + objectId: String = "map:test@123", + key: String = "testKey", + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapRemove), + objectId: objectId, + mapOp: ObjectsMapOp(key: key), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_CREATE operation + static func mapCreateOperationMessage( + objectId: String = "map:test@123", + entries: [String: ObjectsMapEntry]? = nil, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: mapCreateOperation( + objectId: objectId, + entries: entries, + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a COUNTER_CREATE operation + static func counterCreateOperationMessage( + objectId: String = "counter:test@123", + count: Int? = 42, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: counterCreateOperation( + objectId: objectId, + count: count, + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a COUNTER_INC operation + static func counterIncOperationMessage( + objectId: String = "counter:test@123", + amount: Int = 10, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.counterInc), + objectId: objectId, + counterOp: counterOp(amount: amount), + ), + serial: serial, + siteCode: siteCode, + ) + } + // MARK: - Common Test Scenarios /// Creates a simple map object message with one string entry