diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 2e6ea38e..b96c82b0 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 @@ -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]> { @@ -193,7 +202,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 +251,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. @@ -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 020bd535..b4c9603a 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``. @@ -8,43 +9,47 @@ 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 + mutableState.liveObject.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization internal convenience init( testsOnly_data data: Double, - objectID: String?, - coreSDK: CoreSDK + objectID: String, + 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 + objectID: String, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { - mutableState = .init(data: data, objectID: objectID) + mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.coreSDK = coreSDK + self.logger = logger } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -52,13 +57,15 @@ 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, + logger: AblyPlugin.Logger, ) -> Self { .init( data: 0, objectID: objectID, coreSDK: coreSDK, + logger: logger, ) } @@ -116,41 +123,137 @@ 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) + } + } + + /// 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 { + /// 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: Bool? - - /// 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 - // 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 - 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 + } + + /// 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 a8ed390f..d9a5af6b 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 { @@ -19,9 +20,9 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } @@ -31,15 +32,15 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_siteTimeserials: [String: String]? { + internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.siteTimeserials + mutableState.liveObject.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } @@ -50,15 +51,17 @@ internal final class DefaultLiveMap: LiveMap { } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization internal convenience init( testsOnly_data data: [String: ObjectsMapEntry], - objectID: String? = nil, + objectID: String, testsOnly_semantics semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { self.init( data: data, @@ -66,31 +69,35 @@ internal final class DefaultLiveMap: LiveMap { semantics: semantics, delegate: delegate, coreSDK: coreSDK, + logger: logger, ) } private init( data: [String: ObjectsMapEntry], - objectID: String?, + objectID: String, semantics: WireEnum?, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { - 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 + self.logger = logger } /// 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? = nil, + objectID: String, 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,53 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, + ) + } + } + + /// 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, + ) + } + } + + /// 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, ) } } @@ -252,6 +307,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, ) } } @@ -271,21 +327,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: Bool? - /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// /// - Parameters: @@ -295,44 +345,128 @@ 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 - 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 ?? [:] - // 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, - ) - } + 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 - createOperationIsMerged = true + } + // RTLM17b: Set the private flag createOperationIsMerged to true + 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) } } @@ -344,6 +478,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] { @@ -370,7 +505,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) } } @@ -440,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/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift new file mode 100644 index 00000000..c81bec58 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -0,0 +1,50 @@ +internal import AblyPlugin + +/// 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 + + /// 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/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 11d8d001..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`. @@ -44,11 +69,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 +83,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(delegate: rootDelegate, coreSDK: rootCoreSDK)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK, logger: logger)) } // MARK: - Typed root @@ -87,8 +115,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 +135,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 +188,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 17839c95..4b8ce9c3 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(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(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(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 ) @@ -49,21 +52,40 @@ 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() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + // Given: A counter whose createOperationIsMerged is true + let logger = TestLogger() + let counter = { + 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( + 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 logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( count: 42, // Test value ) @@ -74,7 +96,8 @@ struct DefaultLiveCounterTests { // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(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 )) @@ -82,49 +105,232 @@ 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 { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + func mergesInitialValueWhenCreateOpPresent() throws { + 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 ) 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 counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) - 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 counter = DefaultLiveCounter.createZeroValued(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) - } + // 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) + } + } + + /// 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 97a8c10c..f9bb1838 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(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(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], 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], 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], 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], 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], 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], 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(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], 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(coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) 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, 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], 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(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"]) } @@ -158,27 +170,45 @@ 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 logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = { + 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( + 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) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) map.replaceData(using: state, objectsPool: &pool) - #expect(map.testsOnly_createOperationIsMerged == false) + + // Then: + #expect(!map.testsOnly_createOperationIsMerged) } // @specOneOf(1/2) RTLM6c @Test func setsDataToMapEntries() throws { + let logger = TestLogger() 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, 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) @@ -187,12 +217,13 @@ 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) - let map = DefaultLiveMap.createZeroValued(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( @@ -208,58 +239,13 @@ 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) + // 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 delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( - testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], - delegate: delegate, - coreSDK: coreSDK, - ) - // 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) - 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 delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(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 +259,8 @@ 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 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)] = [ @@ -305,6 +292,7 @@ struct DefaultLiveMapTests { // @spec RTLM14 @Test func allPropertiesFilterOutTombstonedEntries() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -315,8 +303,10 @@ 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, + logger: logger, ) // Test size - should only count non-tombstoned entries @@ -348,6 +338,7 @@ struct DefaultLiveMapTests { // @specOneOf(2/2) RTLM13b @Test func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -355,8 +346,10 @@ struct DefaultLiveMapTests { "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), ], + objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, + logger: logger, ) let size = try map.size @@ -382,12 +375,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(delegate: delegate, coreSDK: coreSDK) - let referencedCounter = DefaultLiveCounter.createZeroValued(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) @@ -400,8 +394,10 @@ 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, + logger: logger, ) let size = try map.size @@ -439,14 +435,17 @@ struct DefaultLiveMapTests { // @spec RTLM7a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() 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, + 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( @@ -473,14 +472,17 @@ 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( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -539,10 +541,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(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", @@ -585,20 +588,24 @@ 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(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" let existingObject = DefaultLiveMap( testsOnly_data: [:], + 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 @@ -630,12 +637,15 @@ struct DefaultLiveMapTests { // @spec RTLM8a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() 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, + logger: logger, ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -650,12 +660,15 @@ struct DefaultLiveMapTests { // @spec RTLM8a2c @Test func appliesOperationWhenCanBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() 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, + logger: logger, ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -686,9 +699,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(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -706,9 +720,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(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -767,14 +782,17 @@ 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( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -794,4 +812,274 @@ 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) + } + } + + /// 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 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)") + } + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 2495cf3d..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(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(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(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(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, 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(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(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, 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