From 200a9ca3be50ac64b3148cfe9dc7dc5671c0924c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 14:40:20 -0300 Subject: [PATCH 1/3] Mark remaining LiveMap accessors as throwing Motivation as in 3f6de86; the new spec points in [1] tell us these can throw. [1] https://github.com/ably/specification/pull/341 --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 8 ++++---- .../ObjectsIntegrationTests.swift | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 425953b7..88d3bd1e 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -211,16 +211,16 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? /// Returns the number of key-value pairs in the map. - var size: Int { get } + var size: Int { get throws(ARTErrorInfo) } /// Returns an array of key-value pairs for every entry in the map. - var entries: [(key: String, value: LiveMapValue)] { get } + var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } /// Returns an array of keys in the map. - var keys: [String] { get } + var keys: [String] { get throws(ARTErrorInfo) } /// Returns an iterable of values in the map. - var values: [LiveMapValue] { get } + var values: [LiveMapValue] { get throws(ARTErrorInfo) } /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. /// diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 9931a54e..502d0b91 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -295,7 +295,7 @@ private struct ObjectsIntegrationTests { let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] let rootKeysCount = counterKeys.count + mapKeys.count - #expect(root.size == rootKeysCount, "Check root has correct number of keys") + #expect(try root.size == rootKeysCount, "Check root has correct number of keys") for key in counterKeys { let counter = try #require(try root.get(key: key)) @@ -319,7 +319,7 @@ private struct ObjectsIntegrationTests { "falseKey", "mapKey", ] - #expect(valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") + #expect(try valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") for key in valueMapKeys { #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") } @@ -391,7 +391,7 @@ private struct ObjectsIntegrationTests { #expect(try counter2.value == 11, "Check counter has correct value") let map2 = try #require(root2.get(key: "map")?.liveMapValue) - #expect(map2.size == 2, "Check map has correct number of keys") + #expect(try map2.size == 2, "Check map has correct number of keys") #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") @@ -492,16 +492,16 @@ private struct ObjectsIntegrationTests { let root = try await objects.getRoot() let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) - #expect(emptyMap.size == 0, "Check empty map in root has no keys") + #expect(try emptyMap.size == 0, "Check empty map in root has no keys") let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - #expect(referencedMap.size == 1, "Check referenced map in root has correct number of keys") + #expect(try referencedMap.size == 1, "Check referenced map in root has correct number of keys") let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue) #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - #expect(valuesMap.size == 9, "Check values map in root has correct number of keys") + #expect(try valuesMap.size == 9, "Check values map in root has correct number of keys") #expect(try #require(valuesMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check values map has correct string value key") #expect(try #require(valuesMap.get(key: "emptyStringKey")?.stringValue).isEmpty, "Check values map has correct empty string value key") @@ -513,7 +513,7 @@ private struct ObjectsIntegrationTests { #expect(try #require(valuesMap.get(key: "falseKey")?.boolValue as Bool?) == false, "Check values map has correct 'false' value key") let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) - #expect(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 withExtendedLifetime(channel) {} @@ -543,7 +543,7 @@ private struct ObjectsIntegrationTests { #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") - #expect(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 From d82d90dfad6dd7a1e9686014c8c867698ab58482 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 16:16:55 -0300 Subject: [PATCH 2/3] Introduce status code into invalid channel state error I didn't do this in cb427d8 because the specification hadn't yet specified the status code (it was an outstanding question on the PR at time of implementing), but the newly-written spec [1] for other LiveMap getter methods _does_ specify the status code as being 400. So DRY up the creation of these errors, and supply a status code (assuming that the spec will be updated to specify 400 for these existing ones too). [1] https://github.com/ably/specification/pull/341 --- .../DefaultRealtimeObjects.swift | 6 ++- .../Internal/DefaultLiveCounter.swift | 6 ++- .../Internal/DefaultLiveMap.swift | 6 ++- Sources/AblyLiveObjects/Utility/Errors.swift | 41 +++++++++++++++++++ .../DefaultLiveCounterTests.swift | 2 +- .../DefaultLiveMapTests.swift | 2 +- .../DefaultRealtimeObjectsTests.swift | 2 +- 7 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/Errors.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index fa12ed9b..2e6ea38e 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -90,7 +90,11 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "getRoot operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "getRoot", + channelState: currentChannelState, + ) + .toARTErrorInfo() } let syncStatus = mutex.withLock { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 1246d1b2..020bd535 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -69,7 +69,11 @@ internal final class DefaultLiveCounter: LiveCounter { // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveCounter.value operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveCounter.value", + channelState: currentChannelState, + ) + .toARTErrorInfo() } return mutex.withLock { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 2711663a..3d781f1c 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -108,7 +108,11 @@ internal final class DefaultLiveMap: LiveMap { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.get operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.get", + channelState: currentChannelState, + ) + .toARTErrorInfo() } let entry = mutex.withLock { diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift new file mode 100644 index 00000000..e44d47f9 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -0,0 +1,41 @@ +import Ably + +/** + Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. + */ +internal enum LiveObjectsError { + // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message + case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: ARTRealtimeChannelState) + + /// The ``ARTErrorInfo/code`` that should be returned for this error. + internal var code: ARTErrorCode { + switch self { + case .objectsOperationFailedInvalidChannelState: + .channelOperationFailedInvalidState + } + } + + /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. + internal var statusCode: Int { + switch self { + case .objectsOperationFailedInvalidChannelState: + 400 + } + } + + /// The ``ARTErrorInfo/localizedDescription`` that should be returned for this error. + internal var localizedDescription: String { + switch self { + case let .objectsOperationFailedInvalidChannelState(operationDescription: operationDescription, channelState: channelState): + "\(operationDescription) operation failed (invalid channel state: \(channelState))" + } + } + + internal func toARTErrorInfo() -> ARTErrorInfo { + ARTErrorInfo.create( + withCode: Int(code.rawValue), + status: statusCode, + message: localizedDescription, + ) + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 973e1e6e..17839c95 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -18,7 +18,7 @@ struct DefaultLiveCounterTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index c0d3725b..05c493fe 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -18,7 +18,7 @@ struct DefaultLiveMapTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift index b70dffd1..f6e9d69f 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -660,7 +660,7 @@ struct DefaultRealtimeObjectsTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } } From 8d881e23eaceb7fa9d3b18c11f4a5532a256e4b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 14:42:19 -0300 Subject: [PATCH 3/3] Implement remaining LiveMap access API properties Based on [1] at 7d4c215. A few outstanding questions on the PR; have implemented based on my current understanding of what's there. Development approach similar to that described in cb427d8. Also, have not implemented the specification points related to RTO2's channel mode checking for same reasons as mentioned there. [1] https://github.com/ably/specification/pull/341 --- .../Internal/DefaultLiveMap.swift | 167 +++++++++++------ .../DefaultLiveMapTests.swift | 168 ++++++++++++++++++ 2 files changed, 279 insertions(+), 56 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 3d781f1c..a8ed390f 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -124,78 +124,74 @@ internal final class DefaultLiveMap: LiveMap { return nil } - // RTLM5d2: If a ObjectsMapEntry exists at the key - - // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null - if entry.tombstone == true { - return nil - } - - // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e - - // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it - if let boolean = entry.data.boolean { - return .primitive(.bool(boolean)) - } - - // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it - if let bytes = entry.data.bytes { - return .primitive(.data(bytes)) - } + // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic + return convertEntryToLiveMapValue(entry) + } - // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it - if let number = entry.data.number { - return .primitive(.number(number.doubleValue)) - } + internal var size: Int { + get throws(ARTErrorInfo) { + // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.size", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it - if let string = entry.data.string { - switch string { - case let .string(string): - return .primitive(.string(string)) - case .json: - // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) - notYetImplemented() + return mutex.withLock { + // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map + mutableState.data.values.count { entry in + // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true + // RTLM14b: Otherwise, it returns false + entry.tombstone != true + } } } + } - // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool - if let objectId = entry.data.objectId { - // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null - guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else { - return nil + internal var entries: [(key: String, value: LiveMapValue)] { + get throws(ARTErrorInfo) { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.entries", + channelState: currentChannelState, + ) + .toARTErrorInfo() } - // RTLM5d2f2: If an object with id objectId exists, return it - switch poolEntry { - case let .map(map): - return .liveMap(map) - case let .counter(counter): - return .liveCounter(counter) - } - } + return mutex.withLock { + // RTLM11d: Returns key-value pairs from the internal data map + // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned + var result: [(key: String, value: LiveMapValue)] = [] - // RTLM5d2g: Otherwise, return undefined/null - return nil - } + for (key, entry) in mutableState.data { + // Convert entry to LiveMapValue using the same logic as get(key:) + if let value = convertEntryToLiveMapValue(entry) { + result.append((key: key, value: value)) + } + } - internal var size: Int { - mutex.withLock { - // TODO: this is not yet specified, but it seems like the obvious right thing and it unlocks some integration tests; add spec point once specified - mutableState.data.count + return result + } } } - internal var entries: [(key: String, value: LiveMapValue)] { - notYetImplemented() - } - internal var keys: [String] { - notYetImplemented() + get throws(ARTErrorInfo) { + // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map + try entries.map(\.key) + } } internal var values: [LiveMapValue] { - notYetImplemented() + get throws(ARTErrorInfo) { + // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map + try entries.map(\.value) + } } internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { @@ -445,4 +441,63 @@ internal final class DefaultLiveMap: LiveMap { } } } + + // MARK: - Helper Methods + + /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// This is used by entries to ensure consistent value conversion + private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry) -> LiveMapValue? { + // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null + // This is also equivalent to the RTLM14 check + if entry.tombstone == true { + return nil + } + + // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e + + // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it + if let boolean = entry.data.boolean { + return .primitive(.bool(boolean)) + } + + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data.bytes { + return .primitive(.data(bytes)) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data.number { + return .primitive(.number(number.doubleValue)) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data.string { + switch string { + case let .string(string): + return .primitive(.string(string)) + case .json: + // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) + notYetImplemented() + } + } + + // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool + if let objectId = entry.data.objectId { + // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null + guard let poolEntry = delegate.referenced?.getObjectFromPool(id: objectId) else { + return nil + } + + // RTLM5d2f2: If an object with id objectId exists, return it + switch poolEntry { + case let .map(map): + return .liveMap(map) + case let .counter(counter): + return .liveCounter(counter) + } + } + + // RTLM5d2g: Otherwise, return undefined/null + return nil + } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 05c493fe..97a8c10c 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -263,6 +263,174 @@ struct DefaultLiveMapTests { } } + /// Tests for the `size`, `entries`, `keys`, and `values` properties, covering RTLM10, RTLM11, RTLM12, and RTLM13 specification points + struct AccessPropertiesTests { + // MARK: - Error Throwing Tests (RTLM10c, RTLM11c, RTLM12b, RTLM13b) + + // @spec RTLM10c + // @spec RTLM11c + // @spec RTLM12b + // @spec RTLM13b + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + + // Define actions to test + let actions: [(String, () throws -> Any)] = [ + ("size", { try map.size }), + ("entries", { try map.entries }), + ("keys", { try map.keys }), + ("values", { try map.values }), + ] + + // Test each property throws the expected error + for (propertyName, action) in actions { + #expect("\(propertyName) should throw") { + _ = try action() + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + } + + // MARK: - Tombstone Filtering Tests (RTLM10d, RTLM11d1, RTLM12b, RTLM13b) + + // @specOneOf(1/2) RTLM10d - Tests the "non-tombstoned" part of spec point + // @spec RTLM11d1 + // @specOneOf(1/2) RTLM12b - Tests the "non-tombstoned" part of RTLM10d + // @specOneOf(1/2) RTLM13b - Tests the "non-tombstoned" part of RTLM10d + // @spec RTLM14 + @Test + func allPropertiesFilterOutTombstonedEntries() throws { + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: [ + // tombstone is nil, so not considered tombstoned + "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + // tombstone is false, so not considered tombstoned[ + "active2": TestFactories.mapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), + "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), + "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), + ], + delegate: nil, + coreSDK: coreSDK, + ) + + // Test size - should only count non-tombstoned entries + let size = try map.size + #expect(size == 2) + + // Test entries - should only return non-tombstoned entries + let entries = try map.entries + #expect(entries.count == 2) + #expect(Set(entries.map(\.key)) == ["active1", "active2"]) + #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") + #expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2") + + // Test keys - should only return keys from non-tombstoned entries + let keys = try map.keys + #expect(keys.count == 2) + #expect(Set(keys) == ["active1", "active2"]) + + // Test values - should only return values from non-tombstoned entries + let values = try map.values + #expect(values.count == 2) + #expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"])) + } + + // MARK: - Consistency Tests + + // @specOneOf(2/2) RTLM10d + // @specOneOf(2/2) RTLM12b + // @specOneOf(2/2) RTLM13b + @Test + func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: [ + "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), + "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), + ], + delegate: nil, + coreSDK: coreSDK, + ) + + let size = try map.size + let entries = try map.entries + let keys = try map.keys + let values = try map.values + + // All properties should return the same count + #expect(size == 3) + #expect(entries.count == 3) + #expect(keys.count == 3) + #expect(values.count == 3) + + // Keys should match the keys from entries + #expect(Set(keys) == Set(entries.map(\.key))) + + // Values should match the values from entries + #expect(Set(values.compactMap(\.stringValue)) == Set(entries.compactMap(\.value.stringValue))) + } + + // MARK: - `entries` handling of different value types, per RTLM5d2 + + // @spec RTLM11d + @Test + func entriesHandlesAllValueTypes() throws { + 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) + delegate.objects["map:ref@123"] = .map(referencedMap) + delegate.objects["counter:ref@456"] = .counter(referencedCounter) + + let map = DefaultLiveMap( + testsOnly_data: [ + "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.mapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e + "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + ], + delegate: delegate, + coreSDK: coreSDK, + ) + + let size = try map.size + let entries = try map.entries + let keys = try map.keys + let values = try map.values + + #expect(size == 6) + #expect(entries.count == 6) + #expect(keys.count == 6) + #expect(values.count == 6) + + // Verify the correct values are returned by `entries` + let booleanEntry = entries.first { $0.key == "boolean" } // RTLM5d2b + let bytesEntry = entries.first { $0.key == "bytes" } // RTLM5d2c + let numberEntry = entries.first { $0.key == "number" } // RTLM5d2d + let stringEntry = entries.first { $0.key == "string" } // RTLM5d2e + let mapRefEntry = entries.first { $0.key == "mapRef" } // RTLM5d2f2 + let counterRefEntry = entries.first { $0.key == "counterRef" } // RTLM5d2f2 + + #expect(booleanEntry?.value.boolValue == true) // RTLM5d2b + #expect(bytesEntry?.value.dataValue == Data([0x01, 0x02, 0x03])) // RTLM5d2c + #expect(numberEntry?.value.numberValue == 42) // RTLM5d2d + #expect(stringEntry?.value.stringValue == "hello") // RTLM5d2e + #expect(mapRefEntry?.value.liveMapValue as AnyObject === referencedMap as AnyObject) // RTLM5d2f2 + #expect(counterRefEntry?.value.liveCounterValue as AnyObject === referencedCounter as AnyObject) // RTLM5d2f2 + } + } + /// Tests for `MAP_SET` operations, covering RTLM7 specification points struct MapSetOperationTests { // MARK: - RTLM7a Tests (Existing Entry)