diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 7186321..37795ab 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -95,6 +95,13 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + /// Test-only: sets the counter's data directly, mirroring the spec's `pool["id"].data = …`. + internal func testsOnly_setData(_ data: Double) { + mutableStateMutex.withSync { mutableState in + mutableState.data = data + } + } + // MARK: - Internal methods that back LiveCounter conformance internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e3f2253..7282c68 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -17,6 +17,13 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// Test-only: sets the map's data directly, mirroring the spec's `pool["id"].data = …`. + internal func testsOnly_setData(_ data: [String: InternalObjectsMapEntry]) { + mutableStateMutex.withSync { mutableState in + mutableState.data = data + } + } + internal var testsOnly_semantics: WireEnum? { mutableStateMutex.withSync { mutableState in mutableState.semantics diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 59f2c09..4311197 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -73,6 +73,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + /// The current object sync state (RTO4/RTO5 state machine): `.initialized`, `.syncing`, or `.synced`. + internal var testsOnly_objectsSyncState: ObjectsSyncState { + mutableStateMutex.withSync { mutableState in + mutableState.state.toObjectsSyncState + } + } + /// If this returns false, it means that there is currently no stored sync sequence ID, SyncObjectsPool, or BufferedObjectOperations. internal var testsOnly_hasSyncSequence: Bool { mutableStateMutex.withSync { mutableState in @@ -439,6 +446,28 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + /// Seeds a pre-built `InternalLiveMap` into the object pool, keyed by its own objectId. + /// + /// Intended as a way for tests to populate the object pool (like `testsOnly_createZeroValueLiveObject`, + /// but with an already-configured object). + internal func testsOnly_setLiveMap(_ map: InternalDefaultLiveMap) { + let objectID = map.testsOnly_objectID + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.testsOnly_setLiveMap(map, forObjectID: objectID) + } + } + + /// Seeds a pre-built `InternalLiveCounter` into the object pool, keyed by its own objectId. + /// + /// Intended as a way for tests to populate the object pool (like `testsOnly_createZeroValueLiveObject`, + /// but with an already-configured object). + internal func testsOnly_setLiveCounter(_ counter: InternalDefaultLiveCounter) { + let objectID = counter.testsOnly_objectID + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.testsOnly_setLiveCounter(counter, forObjectID: objectID) + } + } + // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObjectParentReferences.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObjectParentReferences.swift new file mode 100644 index 0000000..e77251c --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObjectParentReferences.swift @@ -0,0 +1,58 @@ +/// The parent-reference tracking that a `LiveObject` maintains so that its full key-paths from root +/// can be resolved (`RTLO3f`, `RTLO4f`, `RTLO4g`, `RTLO4h`). +/// +/// > Note: This is the API shape only; the behaviour is not yet implemented in this target, so every +/// > member traps via ``notImplemented()``. The shape is defined here so that the path-based +/// > dispatch and the LiveObjects graph traversal can be built against it. +internal protocol ParentReferencing: AnyObject { + /// Tracks which `InternalLiveMap`s currently reference this `LiveObject`, and at which keys, keyed + /// by the parent's `objectId`. Set to an empty map when the `LiveObject` is initialized (RTLO3f2). + /// Spec: `RTLO3f`. + var parentReferences: [String: Set] { get set } + + /// Records that the `InternalLiveMap` `parent` references this `LiveObject` at `key`: adds `key` to + /// the existing entry for `parent.objectId` (RTLO4g1), or inserts a new entry `{parent.objectId: + /// {key}}` (RTLO4g2). + /// Spec: `RTLO4g`. + func addParentReference(_ parent: InternalDefaultLiveMap, key: String) + + /// Removes the recorded reference from `parent` at `key`: no-op if there is no entry for + /// `parent.objectId` (RTLO4h1); otherwise removes `key` from the entry's set (RTLO4h2), and drops + /// the entry entirely if its set becomes empty (RTLO4h3). + /// Spec: `RTLO4h`. + func removeParentReference(_ parent: InternalDefaultLiveMap, key: String) + + /// Returns every key-path from the root `InternalLiveMap` to this `LiveObject` — one per simple + /// path through the parent-reference graph (RTLO4f2), each appearing once, order unspecified + /// (RTLO4f3). Root itself yields the single empty key-path `[]`; an unreachable object yields `[]`. + /// Spec: `RTLO4f`. + func getFullPaths() -> [[String]] +} + +internal extension ParentReferencing { + var parentReferences: [String: Set] { + get { notImplemented() } + set { + _ = newValue + notImplemented() + } + } + + func addParentReference(_ parent: InternalDefaultLiveMap, key: String) { + _ = (parent, key) + notImplemented() + } + + func removeParentReference(_ parent: InternalDefaultLiveMap, key: String) { + _ = (parent, key) + notImplemented() + } + + func getFullPaths() -> [[String]] { + notImplemented() + } +} + +extension InternalDefaultLiveCounter: ParentReferencing {} + +extension InternalDefaultLiveMap: ParentReferencing {} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index b7be883..4bed441 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -214,6 +214,16 @@ internal struct ObjectsPool { } } + /// Test-only: seeds `map` into the pool under `objectID`, mirroring the spec's `pool[id] = obj`. + internal mutating func testsOnly_setLiveMap(_ map: InternalDefaultLiveMap, forObjectID objectID: String) { + entries[objectID] = .map(map) + } + + /// Test-only: seeds `counter` into the pool under `objectID`, mirroring the spec's `pool[id] = obj`. + internal mutating func testsOnly_setLiveCounter(_ counter: InternalDefaultLiveCounter, forObjectID objectID: String) { + entries[objectID] = .counter(counter) + } + // MARK: - Data manipulation /// Creates a zero-value object if it does not exist in the pool, per RTO6. This is used when applying a `MAP_SET` operation that contains a reference to another object. diff --git a/Tests/UTS/Harness/UTSTestCase.swift b/Tests/UTS/Harness/UTSTestCase.swift index 5d413e6..8f22f42 100644 --- a/Tests/UTS/Harness/UTSTestCase.swift +++ b/Tests/UTS/Harness/UTSTestCase.swift @@ -30,6 +30,14 @@ class UTSTestCase { private var installedMockHTTPClient: MockHTTPClient? private var clients: [ARTRealtime] = [] + /// Shared serial queues for the tests that drive `ObjectsPool` / + /// `InternalDefaultRealtimeObjects` / the live-object classes directly (see `UTSTestCase+LiveObjects`). + /// Lazily created, so they exist only for the tests that actually use those helpers. Every + /// object a test builds via those helpers shares ``objectsInternalQueue`` (so they can be mixed in + /// one pool); ``flushCallbacks()`` drains subscription deliveries on ``objectsUserCallbackQueue``. + lazy var objectsInternalQueue = DispatchQueue(label: "uts.objects.internal.\(UUID().uuidString)", qos: .userInitiated) + lazy var objectsUserCallbackQueue = DispatchQueue(label: "uts.objects.user.\(UUID().uuidString)") + // MARK: Enable fake timers /// UTS `enable_fake_timers()`. Clients built *after* this call use the deterministic diff --git a/Tests/UTS/Helpers/StandardTestPool.swift b/Tests/UTS/Helpers/StandardTestPool.swift index be66f8b..4f5a217 100644 --- a/Tests/UTS/Helpers/StandardTestPool.swift +++ b/Tests/UTS/Helpers/StandardTestPool.swift @@ -116,6 +116,14 @@ enum StandardTestPool { } } + /// Builds an `InternalLiveMap`'s data (`[String: InternalObjectsMapEntry]`) from key -> data, all + /// seeded with ``poolSerial`` and not tombstoned. For seeding via `InternalDefaultLiveMap.testsOnly_setData`. + static func internalMapEntries(_ entries: [String: ProtocolTypes.ObjectData]) -> [String: InternalObjectsMapEntry] { + entries.mapValues { data in + InternalObjectsMapEntry(tombstonedAt: nil, timeserial: poolSerial, data: data) + } + } + /// Builds an ``ProtocolTypes/ObjectsMap`` — the `{ semantics, entries }` object of the spec's /// `build_object_state`. `clearTimeserial` is omitted unless the spec shows one. static func objectsMap( diff --git a/Tests/UTS/Helpers/UTSMockCoreSDK.swift b/Tests/UTS/Helpers/UTSMockCoreSDK.swift new file mode 100644 index 0000000..3be872b --- /dev/null +++ b/Tests/UTS/Helpers/UTSMockCoreSDK.swift @@ -0,0 +1,39 @@ +import _AblyPluginSupportPrivate +import Ably +import Foundation +@testable import AblyLiveObjects + +/// A minimal ``CoreSDK`` for the tests that drive the internal live-object classes directly. Reports +/// a fixed channel state (so value/size reads pass the RTO25 precondition) and resolves publishes +/// synchronously via `publishHandler` (used to feed a known ACK serial into the LOCAL-source apply +/// path). +final class UTSMockCoreSDK: CoreSDK { + private let channelState: _AblyPluginSupportPrivate.RealtimeChannelState + private let internalQueue: DispatchQueue + private let publishHandler: @Sendable ([ProtocolTypes.OutboundObjectMessage]) -> PublishResult + + init( + channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached, + internalQueue: DispatchQueue, + publishHandler: @escaping @Sendable ([ProtocolTypes.OutboundObjectMessage]) -> PublishResult = { _ in PublishResult(serials: []) }, + ) { + self.channelState = channelState + self.internalQueue = internalQueue + self.publishHandler = publishHandler + } + + func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + let result = publishHandler(objectMessages) + internalQueue.async { callback(.success(result)) } + } + + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) { + callback(.success(Date())) + } + + func testsOnly_overridePublish(with _: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) {} + + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + channelState + } +} diff --git a/Tests/UTS/Helpers/UTSNoOpLogger.swift b/Tests/UTS/Helpers/UTSNoOpLogger.swift new file mode 100644 index 0000000..aab6f71 --- /dev/null +++ b/Tests/UTS/Helpers/UTSNoOpLogger.swift @@ -0,0 +1,8 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects + +/// A no-op ``Logger`` for the tests that drive `ObjectsPool` / `InternalDefaultRealtimeObjects` / +/// the live-object classes directly (without the mock WebSocket). +final class UTSNoOpLogger: AblyLiveObjects.Logger { + func log(_: String, level _: _AblyPluginSupportPrivate.LogLevel, codeLocation _: CodeLocation) {} +} diff --git a/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift index 3b60ac7..e5a4a44 100644 --- a/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift +++ b/Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift @@ -115,6 +115,47 @@ extension UTSTestCase { func sendToClient(_ ws: MockWebSocketProvider, channel: String = "test", _ state: [ProtocolTypes.InboundObjectMessage]) { ws.activeConnection?.sendToClient(.object(channel: channel, state: state)) } + + // MARK: - Direct internal live-object helpers + + // Factories for the tests that drive `ObjectsPool` / `InternalDefaultRealtimeObjects` / the + // live-object classes directly, without the mock WebSocket. Every object is bound to the test's + // shared ``objectsInternalQueue`` so they can be mixed in one pool, and the `nosync_*` handlers are + // invoked on that queue via ``onQueue(_:)``. ``flushCallbacks()`` drains subscription deliveries + // on ``objectsUserCallbackQueue``. + + private var objectsLogger: AblyLiveObjects.Logger { UTSNoOpLogger() } + private var objectsClock: SimpleClock { DefaultSimpleClock() } + + func makeRealtimeObjects() -> InternalDefaultRealtimeObjects { + InternalDefaultRealtimeObjects(logger: objectsLogger, internalQueue: objectsInternalQueue, userCallbackQueue: objectsUserCallbackQueue, clock: objectsClock) + } + + func makePool(otherEntries: [String: ObjectsPool.Entry]? = nil) -> ObjectsPool { + ObjectsPool(logger: objectsLogger, internalQueue: objectsInternalQueue, userCallbackQueue: objectsUserCallbackQueue, clock: objectsClock, testsOnly_otherEntries: otherEntries) + } + + func makeCounter(objectID: String) -> InternalDefaultLiveCounter { + .createZeroValued(objectID: objectID, logger: objectsLogger, internalQueue: objectsInternalQueue, userCallbackQueue: objectsUserCallbackQueue, clock: objectsClock) + } + + func makeMap(objectID: String) -> InternalDefaultLiveMap { + .createZeroValued(objectID: objectID, logger: objectsLogger, internalQueue: objectsInternalQueue, userCallbackQueue: objectsUserCallbackQueue, clock: objectsClock) + } + + func makeCoreSDK(publishHandler: @escaping @Sendable ([ProtocolTypes.OutboundObjectMessage]) -> PublishResult = { _ in PublishResult(serials: []) }) -> UTSMockCoreSDK { + UTSMockCoreSDK(internalQueue: objectsInternalQueue, publishHandler: publishHandler) + } + + /// Runs `body` on the internal queue (the `nosync_*` handlers require this). + func onQueue(_ body: () throws -> T) rethrows -> T { + try objectsInternalQueue.ably_syncNoDeadlock(execute: body) + } + + /// Drains any pending user-callback-queue work (subscription deliveries) synchronously. + func flushCallbacks() { + objectsUserCallbackQueue.sync {} + } } /// Wire-level `ProtocolMessage.action` codes (`ARTProtocolMessageAction`). diff --git a/Tests/UTS/Tests/Internal/ObjectIdTests.swift b/Tests/UTS/Tests/Internal/ObjectIdTests.swift new file mode 100644 index 0000000..ecc91fc --- /dev/null +++ b/Tests/UTS/Tests/Internal/ObjectIdTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +@testable import AblyLiveObjects + +/// ObjectId generation (`RTO14`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/object_id.md +/// +/// Pure function, no mocks. The spec's `generateObjectId(type:initialValue:nonce:timestamp:)` maps to +/// the internal `ObjectCreationHelpers.testsOnly_createObjectID` (timestamp is a `Date`; the spec's +/// millisecond value is `Date(timeIntervalSince1970: ms / 1000)`). +@Suite +struct ObjectIdTests { + private static let timestamp = Date(timeIntervalSince1970: 1_700_000_000) // 1700000000000 ms + + // UTS: objects/unit/RTO14/objectid-format-counter-0 + @Test + func test_RTO14_objectId_format_for_counter_type() throws { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":42}}"#, + nonce: "test-nonce-12345678", + timestamp: Self.timestamp, + ) + + #expect(objectId.hasPrefix("counter:")) + #expect(objectId.contains("@1700000000000")) + + let (typePart, hashPart, timestampPart) = try parseObjectId(objectId) + #expect(typePart == "counter") + #expect(timestampPart == "1700000000000") + // RTO14b2: base64url — no standard-base64 characters. + #expect(!hashPart.isEmpty) + #expect(!hashPart.contains("+")) + #expect(!hashPart.contains("/")) + #expect(!hashPart.contains("=")) + } + + // UTS: objects/unit/RTO14/objectid-format-map-0 + @Test + func test_RTO14_objectId_format_for_map_type() { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "map", + initialValue: #"{"map":{"semantics":"LWW","entries":{}}}"#, + nonce: "test-nonce-12345678", + timestamp: Self.timestamp, + ) + + #expect(objectId.hasPrefix("map:")) + #expect(objectId.contains("@1700000000000")) + } + + // UTS: objects/unit/RTO14/deterministic-0 + @Test + func test_RTO14_deterministic_output_for_same_inputs() { + let make = { + ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "same-nonce-1234567", + timestamp: Self.timestamp, + ) + } + #expect(make() == make()) + } + + // UTS: objects/unit/RTO14/different-nonce-0 + @Test + func test_RTO14_different_nonce_produces_different_objectId() { + let id1 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", initialValue: #"{"counter":{"count":0}}"#, nonce: "nonce-aaaaaaaaaaaaa", timestamp: Self.timestamp, + ) + let id2 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", initialValue: #"{"counter":{"count":0}}"#, nonce: "nonce-bbbbbbbbbbbbb", timestamp: Self.timestamp, + ) + #expect(id1 != id2) + } + + // UTS: objects/unit/RTO14b/base64url-encoding-0 + @Test + func test_RTO14b_hash_is_base64url_encoded_not_standard_base64() throws { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", initialValue: #"{"counter":{"count":0}}"#, nonce: "test-nonce-12345678", timestamp: Self.timestamp, + ) + let (_, hashPart, _) = try parseObjectId(objectId) + #expect(!hashPart.contains("+")) + #expect(!hashPart.contains("/")) + #expect(!hashPart.hasSuffix("=")) + } +} + +private extension ObjectIdTests { + /// Splits an objectId `{type}:{hash}@{timestamp}` into its parts. + private func parseObjectId(_ objectId: String) throws -> (type: String, hash: String, timestamp: String) { + let typeSplit = objectId.split(separator: ":", maxSplits: 1) + let typePart = try #require(typeSplit.first.map(String.init)) + let rest = try #require(typeSplit.count > 1 ? String(typeSplit[1]) : nil) + let hashAndTimestamp = rest.split(separator: "@", maxSplits: 1) + let hashPart = try #require(hashAndTimestamp.first.map(String.init)) + let timestampPart = try #require(hashAndTimestamp.count > 1 ? String(hashAndTimestamp[1]) : nil) + return (typePart, hashPart, timestampPart) + } +} diff --git a/Tests/UTS/Tests/Internal/ObjectsPoolTests.swift b/Tests/UTS/Tests/Internal/ObjectsPoolTests.swift new file mode 100644 index 0000000..03ac725 --- /dev/null +++ b/Tests/UTS/Tests/Internal/ObjectsPoolTests.swift @@ -0,0 +1,910 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// The `ObjectsPool` data structure and the RTO4/RTO5 sync state machine (`RTO3`–`RTO9`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/objects_pool.md +/// +/// The spec drives everything through a bare `pool` (`pool.processAttached` / `processObjectSync` / +/// `processObjectMessage`, `pool.syncState`, `RealtimeObject(pool:)`). In this SDK the pool is owned +/// by ``InternalDefaultRealtimeObjects``, which is where the sync state, buffered operations and +/// `appliedOnAckSerials` live; the spec verbs map to its `nosync_*` handlers (run on the internal +/// queue via `onQueue(_:)`) and the state is read via `testsOnly_*` accessors. The spec's direct pool +/// pre-seeding maps to `testsOnly_setLiveMap` / `testsOnly_setLiveCounter` (`pool[id] = obj`) and +/// `testsOnly_setData` (`pool[id].data = …`). +/// +/// Each `objectStateMessage(…)` composition mirrors the spec's `build_object_state(objectId, +/// siteTimeserials, { map | counter, createOp })` — the same parameters, in the same shape, with the +/// map's `{ semantics, entries }` built via ``StandardTestPool/objectsMap(semantics:entries:clearTimeserial:)``. +/// The `channel` argument of the spec's `build_object_sync_message(channel, channelSerial, …)` (e.g. +/// `"test"`) has no counterpart here: ``InternalDefaultRealtimeObjects`` is already scoped to a single +/// channel, so `nosync_handleObjectSyncProtocolMessage` takes only the messages and the channelSerial. +@Suite(.serialized) +final class ObjectsPoolTests: UTSTestCase { + + // MARK: - RTO3 — initialization + + // UTS: objects/unit/RTO3/pool-init-root-0 + @Test + func test_RTO3_pool_initialized_with_root_map() { + let pool = makePool() + + #expect(pool.entries["root"]?.mapValue != nil) + #expect(pool.root.testsOnly_data.isEmpty) + #expect(pool.root.testsOnly_objectID == "root") + } + + // MARK: - RTO4 — ATTACHED handling + + // UTS: objects/unit/RTO4/attached-has-objects-syncing-0 + @Test + func test_RTO4_attached_with_has_objects_starts_syncing() { + let realtimeObjects = makeRealtimeObjects() + onQueue { realtimeObjects.nosync_onChannelAttached(hasObjects: true) } + #expect(realtimeObjects.testsOnly_objectsSyncState == .syncing) + } + + // UTS: objects/unit/RTO4b/attached-no-objects-synced-0 + @Test + func test_RTO4b_attached_without_has_objects_clears_pool_and_syncs() { + // DEVIATION (RTO4b2a): the emitted `DefaultLiveMapUpdate` carries no `objectMessage` field, so + // "objectMessage IS null" is not expressible; we assert the removed-entry update instead. + let realtimeObjects = makeRealtimeObjects() + // Seed the pre-state directly (`pool["counter:abc@1000"] = …` / `pool["root"].data = …`). + // These `testsOnly_` methods self-synchronize, so they run off the internal queue; both mutate + // the RealtimeObjects' own pool (the copy from `testsOnly_objectsPool` shares the `root` class + // instance, and `testsOnly_setLiveCounter` inserts into the live pool). + realtimeObjects.testsOnly_setLiveCounter(makeCounter(objectID: "counter:abc@1000")) + let pool = realtimeObjects.testsOnly_objectsPool + pool.root.testsOnly_setData(StandardTestPool.internalMapEntries(["name": StandardTestPool.data(string: "Alice")])) + + let updates = Captured() + let coreSDK = makeCoreSDK() + _ = try? pool.root.subscribe(listener: { update, _ in updates.append(update) }, coreSDK: coreSDK) + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + flushCallbacks() + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries["counter:abc@1000"] == nil) + #expect(finalPool.entries["root"] != nil) + #expect(finalPool.root.testsOnly_data.isEmpty) + #expect(updates.count >= 1) + #expect(updates.first?.update["name"] == .removed) + } + + // UTS: objects/unit/RTO4d/attached-clears-buffer-0 + @Test + func test_RTO4d_attached_clears_buffered_operations() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + } + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 1) + + onQueue { realtimeObjects.nosync_onChannelAttached(hasObjects: true) } + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 0) + } + + // UTS: objects/unit/RTO4-RTO5/attached-during-syncing-resets-0 + @Test + func test_RTO4_RTO5_attached_during_syncing_resets_sync() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "counter:old@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 10)), + ), + ], + protocolMessageChannelSerial: "sync1:more", + ) + } + #expect(realtimeObjects.testsOnly_objectsSyncState == .syncing) + + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:new@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 99)), + ), + ], + protocolMessageChannelSerial: "sync2:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:old@1000"] == nil) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:new@1000"] != nil) + } + + // MARK: - RTO5 — OBJECT_SYNC handling + + // UTS: objects/unit/RTO5/sync-complete-sequence-0 + @Test + func test_RTO5_object_sync_complete_sequence() throws { + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["name": StandardTestPool.data(string: "Alice")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 42), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["root"] != nil) + #expect(pool.root.testsOnly_data["name"]?.data?.string == "Alice") + let counter = try #require(pool.entries["counter:abc@1000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 42) + } + + // UTS: objects/unit/RTO5a2/new-sequence-discards-old-0 + @Test + func test_RTO5a2_new_sequence_discards_previous() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "counter:old@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 10)), + ), + ], + protocolMessageChannelSerial: "seq1:more", + ) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:new@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 99)), + ), + ], + protocolMessageChannelSerial: "seq2:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:old@1000"] == nil) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:new@1000"] != nil) + } + + // UTS: objects/unit/RTO5f2a/partial-map-merge-0 + @Test + func test_RTO5f2a_partial_object_state_merge_for_maps() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["name": StandardTestPool.data(string: "Alice")]), + ), + ), + ], + protocolMessageChannelSerial: "sync1:more", + ) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["age": StandardTestPool.data(number: 30)]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + let root = realtimeObjects.testsOnly_objectsPool.root + #expect(root.testsOnly_data["name"]?.data?.string == "Alice") + #expect(root.testsOnly_data["age"]?.data?.number == 30) + } + + // UTS: objects/unit/RTO5c2/remove-absent-objects-0 + @Test + func test_RTO5c2_sync_completion_removes_objects_not_in_sync() { + let realtimeObjects = makeRealtimeObjects() + // Seed the object to be removed directly (`pool["counter:old@1000"] = …`; the spec's data of + // 99 isn't material — the test only asserts the object is removed). + realtimeObjects.testsOnly_setLiveCounter(makeCounter(objectID: "counter:old@1000")) + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:old@1000"] == nil) + #expect(realtimeObjects.testsOnly_objectsPool.entries["root"] != nil) + } + + // UTS: objects/unit/RTO5d/null-object-skipped-0 + @Test + func test_RTO5d_object_sync_with_null_object_field_is_skipped() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + ProtocolTypes.InboundObjectMessage(), + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + } + + // UTS: objects/unit/RTO5f3/unsupported-type-skipped-0 + @Test + func test_RTO5f3_object_sync_with_unsupported_object_type_is_skipped() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "unknown:xyz@1000", + siteTimeserials: [:], + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + #expect(realtimeObjects.testsOnly_objectsPool.entries["unknown:xyz@1000"] == nil) + } + + // UTS: objects/unit/RTO5e/object-sync-transitions-syncing-0 + @Test + func test_RTO5e_object_sync_transitions_to_syncing() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + ), + ], + protocolMessageChannelSerial: "sync1:more", + ) + } + #expect(realtimeObjects.testsOnly_objectsSyncState == .syncing) + } + + // UTS: objects/unit/RTO5c7/sync-emits-updates-0 + @Test + func test_RTO5c7_sync_completion_emits_updates_for_existing_objects() { + let realtimeObjects = makeRealtimeObjects() + // Seed the previous root value directly (`pool["root"].data = { name: "Old" }`); a sync then + // replaces it with "New", emitting a LiveMapUpdate that marks "name" as updated. + var pool = realtimeObjects.testsOnly_objectsPool + _ = pool.root.testsOnly_applyMapSetOperation( + key: "name", + operationTimeserial: "01", + operationData: StandardTestPool.data(string: "Old"), + objectsPool: &pool, + ) + + let updates = Captured() + let coreSDK = makeCoreSDK() + _ = try? pool.root.subscribe(listener: { update, _ in updates.append(update) }, coreSDK: coreSDK) + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:1"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["name": StandardTestPool.data(string: "New")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + flushCallbacks() + + #expect(updates.count >= 1) + #expect(updates.first?.update["name"] == .updated) + } + + // UTS: objects/unit/RTO5f2b/partial-counter-error-0 + @Test + func test_RTO5f2b_partial_counter_state_is_rejected() throws { + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 10)), + ), + ], + protocolMessageChannelSerial: "sync1:more", + ) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 5)), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 10) + } + + // UTS: objects/unit/RTO5c-RTLM23/sync-clear-timeserial-hides-create-entries-0 + @Test + func test_RTO5c_RTLM23_sync_clear_timeserial_hides_create_entries() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: [:], + clearTimeserial: "05", + ), + createOp: ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: "root", + mapCreate: ProtocolTypes.MapCreate( + semantics: .known(.lww), + entries: [ + "old_key": ProtocolTypes.ObjectsMapEntry(timeserial: "03", data: StandardTestPool.data(string: "old")), + "new_key": ProtocolTypes.ObjectsMapEntry(timeserial: "07", data: StandardTestPool.data(string: "new")), + ], + ), + ), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let root = realtimeObjects.testsOnly_objectsPool.root + #expect(root.testsOnly_data["old_key"] == nil) + #expect(root.testsOnly_data["new_key"]?.data?.string == "new") + } + + // MARK: - RTO7 / RTO8 — buffering + + // UTS: objects/unit/RTO8a/buffer-during-syncing-0 + @Test + func test_RTO8a_object_messages_buffered_during_syncing() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .syncing) + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 1) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"] == nil) + } + + // UTS: objects/unit/RTO5c6/apply-buffered-on-sync-0 + @Test + func test_RTO5c6_buffered_operations_applied_on_sync_completion() throws { + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:abc@1000", number: 10, serial: "02", siteCode: "site1"), + ]) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 100), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 110) + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == nil) // no longer syncing + } + + // UTS: objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0 + @Test + func test_RTO5_RTO7_new_object_sync_sequence_keeps_buffer() throws { + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + } + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 1) + + onQueue { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 100), + ), + ], + protocolMessageChannelSerial: "seq2:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 105) + } + + // UTS: objects/unit/RTO7-RTO8/buffer-without-attached-0 + @Test + func test_RTO7_RTO8_object_message_in_initialized_state() { + // DEVIATION (RTO8a): the spec expects buffering while INITIALIZED. This SDK only buffers while + // SYNCING — it relies on the invariant that OBJECT messages only arrive after ATTACHED (which + // moves it to SYNCING), so in INITIALIZED it applies immediately (see the RTO8b comment in + // `InternalDefaultRealtimeObjects`). We assert the SDK's actual behaviour: nothing is buffered + // and the object is created directly. See deviations.md. + let realtimeObjects = makeRealtimeObjects() + #expect(realtimeObjects.testsOnly_objectsSyncState == .initialized) + onQueue { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + } + + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == nil) + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"] != nil) + } + + // MARK: - RTO9 — OBJECT application + + // UTS: objects/unit/RTO9a1/null-operation-warning-0 + @Test + func test_RTO9a1_null_operation_is_discarded() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) // → synced + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + ProtocolTypes.InboundObjectMessage(serial: "01", siteCode: "site1"), + ]) + } + #expect(realtimeObjects.testsOnly_objectsPool.entries.count == 1) // only root + } + + // UTS: objects/unit/RTO9a2b/unsupported-action-warning-0 + @Test + func test_RTO9a2b_unsupported_action_is_discarded() throws { + // DEVIATION (RTO9a2b): the spec asserts the pool still has only root (the unsupported-action + // message is discarded without creating an object). This SDK creates the zero-value object + // (RTO9a2a2) *before* the action check (RTO9a2b), so the object exists but the operation is + // not applied (the counter stays zero-valued). We assert the observable "not applied" + // behaviour instead of the pool size. See deviations.md. + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) // → synced + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + ProtocolTypes.InboundObjectMessage( + operation: ProtocolTypes.ObjectOperation(action: .unknown(999), objectId: "counter:abc@1000"), + serial: "01", + siteCode: "site1", + ), + ]) + } + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 0) // operation not applied + } + + // UTS: objects/unit/RTO6/zero-value-from-prefix-0 + @Test + func test_RTO6_zero_value_object_created_from_objectId_prefix() throws { + let coreSDK = makeCoreSDK() + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) // → synced + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: "counter:new@2000", number: 5, serial: "01", siteCode: "site1"), + ]) + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.mapSet(objectId: "map:new@2000", key: "key", value: StandardTestPool.data(string: "val"), serial: "02", siteCode: "site1"), + ]) + } + + let pool = realtimeObjects.testsOnly_objectsPool + let counter = try #require(pool.entries["counter:new@2000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 5) + let map = try #require(pool.entries["map:new@2000"]?.mapValue) + #expect(map.testsOnly_data["key"]?.data?.string == "val") + } + + // UTS: objects/unit/RTO5c9/clear-applied-on-ack-serials-0 + @Test + func test_RTO5c9_sync_completion_clears_appliedOnAckSerials() async throws { + let realtimeObjects = makeRealtimeObjects() + let coreSDK = makeCoreSDK { _ in PublishResult(serials: ["serial-1"]) } + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + // Populate appliedOnAckSerials via a LOCAL operation (RTO9a2a4). + _ = try await realtimeObjects.createCounter(count: 42, coreSDK: coreSDK) + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) + + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([:]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) + } + + // UTS: objects/unit/RTO9a2a4/local-source-adds-serial-0 + @Test + func test_RTO9a2a4_local_source_adds_serial_to_appliedOnAckSerials() async throws { + // ADAPTATION: the spec calls the internal `applyObjectMessages(source: LOCAL)` primitive + // directly. Here a LOCAL apply is produced through the public path — a local `createCounter`, + // whose COUNTER_CREATE is published (ACK serial "local-serial-1") and applied on ACK. + let realtimeObjects = makeRealtimeObjects() + let coreSDK = makeCoreSDK { _ in PublishResult(serials: ["local-serial-1"]) } + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("test-site") + } + + let counter = try await realtimeObjects.createCounter(count: 5, coreSDK: coreSDK) + + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.contains("local-serial-1")) + #expect(try counter.value(coreSDK: coreSDK) == 5) + } + + // UTS: objects/unit/RTO9a3/dedup-applied-on-ack-0 + @Test + func test_RTO9a3_appliedOnAckSerials_deduplication() async throws { + let realtimeObjects = makeRealtimeObjects() + let serial = "echo-serial-1" + let coreSDK = makeCoreSDK { _ in PublishResult(serials: [serial]) } + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + // A LOCAL createCounter records `serial` in appliedOnAckSerials. + let counter = try await realtimeObjects.createCounter(count: 10, coreSDK: coreSDK) + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.contains(serial)) + let objectId = counter.testsOnly_objectID // read off the internal queue + + // The echoed channel-sourced message with the same serial is discarded and the serial removed. + onQueue { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + StandardTestPool.counterInc(objectId: objectId, number: 5, serial: serial, siteCode: "site1"), + ]) + } + + #expect(try counter.value(coreSDK: coreSDK) == 10) // unchanged + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.contains(serial)) + } + + // MARK: - RTO5c10 — post-sync parentReferences rebuild + + // These assert `parentReferences`, which is a `notImplemented()` skeleton (see `ParentReferencing`), + // so they compile and trap at runtime like the `ParentReferencesTests` cases. + + // UTS: objects/unit/RTO5c10/sync-rebuilds-parent-refs-0 + @Test + func test_RTO5c10_sync_rebuilds_parentReferences() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([ + "score": StandardTestPool.data(objectId: "counter:score@1000"), + "profile": StandardTestPool.data(objectId: "map:profile@1000"), + "name": StandardTestPool.data(string: "Alice"), + ]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:score@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:score@1000", count: 100), + ), + StandardTestPool.objectStateMessage( + objectId: "map:profile@1000", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["nested_counter": StandardTestPool.data(objectId: "counter:nested@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "map:profile@1000"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:nested@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:nested@1000", count: 5), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.root.parentReferences == [:]) + #expect(pool.entries["counter:score@1000"]?.counterValue?.parentReferences == ["root": ["score"]]) + #expect(pool.entries["map:profile@1000"]?.mapValue?.parentReferences == ["root": ["profile"]]) + #expect(pool.entries["counter:nested@1000"]?.counterValue?.parentReferences == ["map:profile@1000": ["nested_counter"]]) + } + + // UTS: objects/unit/RTO5c10/resync-rebuilds-parent-refs-0 + @Test + func test_RTO5c10_resync_rebuilds_parentReferences_with_new_tree() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["counter_key": StandardTestPool.data(objectId: "counter:abc@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 10), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue?.parentReferences == ["root": ["counter_key"]]) + + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:1"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["wrapper": StandardTestPool.data(objectId: "map:wrapper@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "map:wrapper@1000", + siteTimeserials: ["aaa": "t:1"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["moved_counter": StandardTestPool.data(objectId: "counter:abc@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "map:wrapper@1000"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:1"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 20), + ), + ], + protocolMessageChannelSerial: "sync2:", + ) + } + + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.root.parentReferences == [:]) + #expect(pool.entries["map:wrapper@1000"]?.mapValue?.parentReferences == ["root": ["wrapper"]]) + #expect(pool.entries["counter:abc@1000"]?.counterValue?.parentReferences == ["map:wrapper@1000": ["moved_counter"]]) + } + + // UTS: objects/unit/RTO5c10/empty-sync-parent-refs-0 + @Test + func test_RTO5c10_empty_sync_leaves_root_with_empty_parentReferences() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["child": StandardTestPool.data(objectId: "counter:child@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:child@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:child@1000", count: 1), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:child@1000"]?.counterValue?.parentReferences == ["root": ["child"]]) + + onQueue { realtimeObjects.nosync_onChannelAttached(hasObjects: false) } + + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["counter:child@1000"] == nil) + #expect(pool.entries["root"] != nil) + #expect(pool.root.testsOnly_data.isEmpty) + #expect(pool.root.parentReferences == [:]) + } +} diff --git a/Tests/UTS/Tests/Internal/ParentReferencesTests.swift b/Tests/UTS/Tests/Internal/ParentReferencesTests.swift new file mode 100644 index 0000000..a65b017 --- /dev/null +++ b/Tests/UTS/Tests/Internal/ParentReferencesTests.swift @@ -0,0 +1,444 @@ +import Ably +import Foundation +import Testing +@testable import AblyLiveObjects + +/// Parent-reference tracking on `LiveObject` (`RTLO3f`, `RTLO4f`, `RTLO4g`, `RTLO4h`, `RTO5c10`). +/// Derived from https://github.com/ably/specification/blob/0a531c79adfc072c6d1441591f2dd838913dfe73/uts/objects/unit/parent_references.md +/// +/// The spec's `InternalLiveCounter(objectId:)` / `InternalLiveMap(objectId:, semantics:)` map to +/// `makeCounter` / `makeMap` (zero-valued), and `pool[id] = obj` to +/// `pool.testsOnly_setLiveMap(_:forObjectID:)` / `testsOnly_setLiveCounter(_:forObjectID:)`. The parent-reference API (`parentReferences`, +/// `addParentReference`, `removeParentReference`, `getFullPaths`) is a skeleton in this target — see +/// ``ParentReferencing`` — so every case traps via `notImplemented()` at runtime; the goal here is a +/// faithful translation that compiles and that will pass once the API is implemented. +/// +/// The `RTO5c10` post-sync cases drive `nosync_handleObjectSyncProtocolMessage`; the spec's +/// `build_object_sync_message(channel, channelSerial, …)` channel argument (e.g. `"test"`) has no +/// counterpart because ``InternalDefaultRealtimeObjects`` is already scoped to a single channel. +@Suite(.serialized) +final class ParentReferencesTests: UTSTestCase { + + // MARK: - RTLO3f2 — initialized empty + + // UTS: objects/unit/RTLO3f2/init-empty-counter-0 + @Test + func test_RTLO3f2_parentReferences_empty_on_counter() { + let counter = makeCounter(objectID: "counter:abc@1000") + #expect(counter.parentReferences == [:]) + } + + // UTS: objects/unit/RTLO3f2/init-empty-map-0 + @Test + func test_RTLO3f2_parentReferences_empty_on_map() { + let map = makeMap(objectID: "map:abc@1000") + #expect(map.parentReferences == [:]) + } + + // MARK: - RTLO4g — addParentReference + + // UTS: objects/unit/RTLO4g2/first-reference-new-entry-0 + @Test + func test_RTLO4g2_first_reference_creates_new_entry() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + + child.addParentReference(parent, key: "score") + + #expect(child.parentReferences["map:parent@1000"] == ["score"]) + } + + // UTS: objects/unit/RTLO4g1/second-key-same-parent-0 + @Test + func test_RTLO4g1_second_key_added_to_existing_entry() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + child.parentReferences = ["map:parent@1000": ["score"]] + + child.addParentReference(parent, key: "points") + + #expect(child.parentReferences["map:parent@1000"] == ["score", "points"]) + } + + // UTS: objects/unit/RTLO4g/different-parent-separate-entry-0 + @Test + func test_RTLO4g_different_parent_creates_separate_entry() { + let child = makeCounter(objectID: "counter:child@1000") + let parentA = makeMap(objectID: "map:a@1000") + let parentB = makeMap(objectID: "map:b@1000") + + child.addParentReference(parentA, key: "x") + child.addParentReference(parentB, key: "y") + + #expect(child.parentReferences["map:a@1000"] == ["x"]) + #expect(child.parentReferences["map:b@1000"] == ["y"]) + } + + // UTS: objects/unit/RTLO4g/multiple-parents-multiple-keys-0 + @Test + func test_RTLO4g_multiple_parents_multiple_keys() { + let child = makeCounter(objectID: "counter:child@1000") + let parentA = makeMap(objectID: "map:a@1000") + let parentB = makeMap(objectID: "map:b@1000") + + child.addParentReference(parentA, key: "x") + child.addParentReference(parentA, key: "y") + child.addParentReference(parentB, key: "p") + child.addParentReference(parentB, key: "q") + + #expect(child.parentReferences["map:a@1000"] == ["x", "y"]) + #expect(child.parentReferences["map:b@1000"] == ["p", "q"]) + } + + // MARK: - RTLO4h — removeParentReference + + // UTS: objects/unit/RTLO4h1/nonexistent-parent-noop-0 + @Test + func test_RTLO4h1_remove_nonexistent_parent_is_noop() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + + child.removeParentReference(parent, key: "score") + + #expect(child.parentReferences == [:]) + } + + // UTS: objects/unit/RTLO4h2/remove-key-leaves-others-0 + @Test + func test_RTLO4h2_remove_key_leaves_other_keys() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + child.parentReferences = ["map:parent@1000": ["score", "points"]] + + child.removeParentReference(parent, key: "score") + + #expect(child.parentReferences["map:parent@1000"] == ["points"]) + } + + // UTS: objects/unit/RTLO4h3/remove-last-key-removes-entry-0 + @Test + func test_RTLO4h3_remove_last_key_removes_entry() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + child.parentReferences = ["map:parent@1000": ["score"]] + + child.removeParentReference(parent, key: "score") + + #expect(child.parentReferences["map:parent@1000"] == nil) + #expect(child.parentReferences == [:]) + } + + // UTS: objects/unit/RTLO4h/remove-nonexistent-key-0 + @Test + func test_RTLO4h_remove_nonexistent_key_leaves_existing() { + let child = makeCounter(objectID: "counter:child@1000") + let parent = makeMap(objectID: "map:parent@1000") + child.parentReferences = ["map:parent@1000": ["score"]] + + child.removeParentReference(parent, key: "nonexistent") + + #expect(child.parentReferences["map:parent@1000"] == ["score"]) + } + + // MARK: - RTLO4f — getFullPaths + + // UTS: objects/unit/RTLO4f2/root-returns-empty-path-0 + @Test + func test_RTLO4f2_root_returns_empty_key_path() { + let pool = makePool() + let root = pool.root + + let paths = root.getFullPaths() + #expect(paths.count == 1) + #expect(paths.contains([])) + } + + // UTS: objects/unit/RTLO4f/direct-child-single-path-0 + @Test + func test_RTLO4f_direct_child_of_root_single_path() { + var pool = makePool() + let counter = makeCounter(objectID: "counter:score@1000") + pool.testsOnly_setLiveCounter(counter, forObjectID: "counter:score@1000") + + counter.addParentReference(pool.root, key: "score") + + let paths = counter.getFullPaths() + #expect(paths.count == 1) + #expect(paths.contains(["score"])) + } + + // UTS: objects/unit/RTLO4f/deep-nesting-0 + @Test + func test_RTLO4f_deeply_nested_object() { + var pool = makePool() + let profile = makeMap(objectID: "map:profile@1000") + pool.testsOnly_setLiveMap(profile, forObjectID: "map:profile@1000") + profile.addParentReference(pool.root, key: "profile") + + let prefs = makeMap(objectID: "map:prefs@1000") + pool.testsOnly_setLiveMap(prefs, forObjectID: "map:prefs@1000") + prefs.addParentReference(profile, key: "prefs") + + let themeCounter = makeCounter(objectID: "counter:theme@1000") + pool.testsOnly_setLiveCounter(themeCounter, forObjectID: "counter:theme@1000") + themeCounter.addParentReference(prefs, key: "theme_counter") + + let paths = themeCounter.getFullPaths() + #expect(paths.count == 1) + #expect(paths.contains(["profile", "prefs", "theme_counter"])) + } + + // UTS: objects/unit/RTLO4f/diamond-graph-0 + @Test + func test_RTLO4f_diamond_graph_multiple_parents() { + var pool = makePool() + let mapA = makeMap(objectID: "map:a@1000") + pool.testsOnly_setLiveMap(mapA, forObjectID: "map:a@1000") + mapA.addParentReference(pool.root, key: "a") + + let mapB = makeMap(objectID: "map:b@1000") + pool.testsOnly_setLiveMap(mapB, forObjectID: "map:b@1000") + mapB.addParentReference(pool.root, key: "b") + + let leaf = makeCounter(objectID: "counter:leaf@1000") + pool.testsOnly_setLiveCounter(leaf, forObjectID: "counter:leaf@1000") + leaf.addParentReference(mapA, key: "x") + leaf.addParentReference(mapB, key: "y") + + let paths = leaf.getFullPaths() + #expect(paths.count == 2) + #expect(paths.contains(["a", "x"])) + #expect(paths.contains(["b", "y"])) + } + + // UTS: objects/unit/RTLO4f/single-parent-multiple-keys-0 + @Test + func test_RTLO4f_single_parent_multiple_keys() { + var pool = makePool() + let child = makeCounter(objectID: "counter:child@1000") + pool.testsOnly_setLiveCounter(child, forObjectID: "counter:child@1000") + child.addParentReference(pool.root, key: "primary") + child.addParentReference(pool.root, key: "alias") + + let paths = child.getFullPaths() + #expect(paths.count == 2) + #expect(paths.contains(["primary"])) + #expect(paths.contains(["alias"])) + } + + // UTS: objects/unit/RTLO4f/orphan-returns-empty-0 + @Test + func test_RTLO4f_orphan_returns_empty_list() { + var pool = makePool() + let orphan = makeCounter(objectID: "counter:orphan@1000") + pool.testsOnly_setLiveCounter(orphan, forObjectID: "counter:orphan@1000") + + #expect(orphan.getFullPaths().isEmpty) + } + + // UTS: objects/unit/RTLO4f/cycle-suppression-0 + @Test + func test_RTLO4f_suppresses_cycles() { + var pool = makePool() + let mapA = makeMap(objectID: "map:a@1000") + pool.testsOnly_setLiveMap(mapA, forObjectID: "map:a@1000") + mapA.addParentReference(pool.root, key: "a") + + let mapB = makeMap(objectID: "map:b@1000") + pool.testsOnly_setLiveMap(mapB, forObjectID: "map:b@1000") + mapB.addParentReference(mapA, key: "b") + + // Introduce a cycle: map:A also has map:B as a parent. + mapA.addParentReference(mapB, key: "a") + + let pathsB = mapB.getFullPaths() + #expect(pathsB.count == 1) + #expect(pathsB.contains(["a", "b"])) + + let pathsA = mapA.getFullPaths() + #expect(pathsA.count == 1) + #expect(pathsA.contains(["a"])) + } + + // UTS: objects/unit/RTLO4f/complex-diamond-deep-0 + @Test + func test_RTLO4f_complex_diamond_with_deep_nesting() { + var pool = makePool() + let mapL = makeMap(objectID: "map:l@1000") + pool.testsOnly_setLiveMap(mapL, forObjectID: "map:l@1000") + mapL.addParentReference(pool.root, key: "left") + + let mapR = makeMap(objectID: "map:r@1000") + pool.testsOnly_setLiveMap(mapR, forObjectID: "map:r@1000") + mapR.addParentReference(pool.root, key: "right") + + let mapM = makeMap(objectID: "map:m@1000") + pool.testsOnly_setLiveMap(mapM, forObjectID: "map:m@1000") + mapM.addParentReference(mapL, key: "mid") + + let target = makeCounter(objectID: "counter:t@1000") + pool.testsOnly_setLiveCounter(target, forObjectID: "counter:t@1000") + target.addParentReference(mapM, key: "target") + target.addParentReference(mapR, key: "target") + + let paths = target.getFullPaths() + #expect(paths.count == 2) + #expect(paths.contains(["left", "mid", "target"])) + #expect(paths.contains(["right", "target"])) + } + + // MARK: - RTO5c10 — post-sync rebuild + + // UTS: objects/unit/RTO5c10/rebuild-from-sync-0 + @Test + func test_RTO5c10_post_sync_rebuild_populates_parentReferences() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([ + "score": StandardTestPool.data(objectId: "counter:score@1000"), + "profile": StandardTestPool.data(objectId: "map:profile@1000"), + ]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:score@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:score@1000", count: 100), + ), + StandardTestPool.objectStateMessage( + objectId: "map:profile@1000", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries([ + "nested": StandardTestPool.data(objectId: "counter:nested@1000"), + ]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "map:profile@1000"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:nested@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:nested@1000", count: 5), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["counter:score@1000"]?.counterValue?.parentReferences["root"] == ["score"]) + #expect(pool.entries["map:profile@1000"]?.mapValue?.parentReferences["root"] == ["profile"]) + #expect(pool.entries["counter:nested@1000"]?.counterValue?.parentReferences["map:profile@1000"] == ["nested"]) + #expect(pool.root.parentReferences == [:]) + #expect(pool.entries["counter:score@1000"]?.counterValue?.getFullPaths().contains(["score"]) == true) + #expect(pool.entries["counter:nested@1000"]?.counterValue?.getFullPaths().contains(["profile", "nested"]) == true) + } + + // UTS: objects/unit/RTO5c10a/rebuild-clears-stale-refs-0 + @Test + func test_RTO5c10a_post_sync_rebuild_clears_stale_parentReferences() { + let realtimeObjects = makeRealtimeObjects() + // First sync: root --"score"--> counter:abc@1000. + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["score": StandardTestPool.data(objectId: "counter:abc@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 10), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue?.parentReferences["root"] == ["score"]) + + // Second sync: root --"points"--> counter:abc@1000 (key changed). + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:1"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["points": StandardTestPool.data(objectId: "counter:abc@1000")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:abc@1000", + siteTimeserials: ["aaa": "t:1"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:abc@1000", count: 20), + ), + ], + protocolMessageChannelSerial: "sync2:", + ) + } + + let counter = realtimeObjects.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue + #expect(counter?.parentReferences["root"] == ["points"]) + #expect(counter?.getFullPaths().contains(["points"]) == true) + #expect(counter?.getFullPaths().count == 1) + } + + // UTS: objects/unit/RTO5c10/unreferenced-empty-refs-0 + @Test + func test_RTO5c10_unreferenced_objects_have_empty_parentReferences() { + let realtimeObjects = makeRealtimeObjects() + onQueue { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + StandardTestPool.objectStateMessage( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + map: StandardTestPool.objectsMap( + semantics: .lww, + entries: StandardTestPool.mapEntries(["name": StandardTestPool.data(string: "Alice")]), + ), + createOp: StandardTestPool.mapCreateOp(objectId: "root"), + ), + StandardTestPool.objectStateMessage( + objectId: "counter:orphan@1000", + siteTimeserials: ["aaa": "t:0"], + counter: WireObjectsCounter(count: NSNumber(value: 0)), + createOp: StandardTestPool.counterCreateOp(objectId: "counter:orphan@1000", count: 42), + ), + ], + protocolMessageChannelSerial: "sync1:", + ) + } + + #expect(realtimeObjects.testsOnly_objectsSyncState == .synced) + let orphan = realtimeObjects.testsOnly_objectsPool.entries["counter:orphan@1000"]?.counterValue + #expect(orphan?.parentReferences == [:]) + #expect(orphan?.getFullPaths().isEmpty == true) + } +} diff --git a/Tests/UTS/deviations.md b/Tests/UTS/deviations.md index 74dd319..f6819b1 100644 --- a/Tests/UTS/deviations.md +++ b/Tests/UTS/deviations.md @@ -31,6 +31,30 @@ construction only, with an inline `// DEVIATION` note. (Same resolution as ably- | `RTINS10` | `Instance.compact()` recursive compaction | only `compactJson()` is exposed publicly; asserted on the JSON form | `InstanceTests.test_RTINS10_compact_recursively_compacts` | | `RTINS4d` / `RTINS9c` | `value()`/`size()` return null for the wrong wrapped type | Swift exposes `value`/`size` only on the relevant payload; "returns null" is represented as "not that payload type" | `InstanceTests.test_RTINS4_*` / `test_RTINS9_*` | | `RTO15` | `channel.object.publish([...])` sends an `OBJECT` PM and returns a `PublishResult` | `publish` / `PublishResult` are internal RealtimeObject members, not on the public `RealtimeObject` protocol (which exposes only `get()` / `on(...)`); the publish path is covered indirectly via the path-object mutation tests (RTO20) | `RealtimeObjectTests.test_RTO15_publish_sends_object_protocol_message` (empty, documented) | +| `RTLO4b4c1` | noop `COUNTER_INC` (a `counterInc` with no `number`) must not trigger the listener | `WireCounterInc.number` is a non-optional `NSNumber`, so an empty `counterInc: {}` isn't constructible; the test asserts two real increments produce exactly two updates | `LiveObjectSubscribeTests.test_RTLO4b4c1_noop_update_does_not_trigger_listener` | +| `RTO4b2a` | the reset LiveMapUpdate for root must have `objectMessage == null` | the internal `DefaultLiveMapUpdate` carries no `objectMessage` field, so this cannot be asserted; the removed-entry update itself is asserted instead | `ObjectsPoolTests.test_RTO4b_attached_without_has_objects_clears_pool_and_syncs` | +| `RTO7`/`RTO8a` | an OBJECT message received while INITIALIZED is buffered | this SDK only buffers while SYNCING (it relies on the invariant that OBJECT messages only arrive after ATTACHED → SYNCING); in INITIALIZED it applies immediately. The test asserts the SDK's actual behaviour (object created, nothing buffered) | `ObjectsPoolTests.test_RTO7_RTO8_object_message_in_initialized_state` | +| `RTO9a2b` | an unsupported-action OBJECT message is discarded and no object is created (pool keeps only root) | this SDK creates the zero-value object (RTO9a2a2) *before* the action check (RTO9a2b), so the object exists but the operation is not applied. The test asserts the operation had no effect (counter stays zero-valued) rather than the pool size | `ObjectsPoolTests.test_RTO9a2b_unsupported_action_is_discarded` | + +## Skeleton API added to host these tests + +Two files exercised functionality that had **no symbol at all** in this branch (not even a trapping +skeleton). Rather than defer them, the API shapes were added as `notImplemented()` skeletons (like +the rest of the path-based target), so the tests bind to real symbols, compile, and trap at runtime +until the behaviour is implemented: + +- **`parent_references.md`** (`RTLO3f` / `RTLO4f` / `RTLO4g` / `RTLO4h`, `RTO5c10`): added the + ``ParentReferencing`` protocol (`parentReferences`, `addParentReference`, `removeParentReference`, + `getFullPaths`) with `notImplemented()` defaults, conformed by `InternalDefaultLiveCounter` / + `InternalDefaultLiveMap`. → `ParentReferencesTests`. +- **`public_object_message.md`** (`PAOM3` / `PAOOP3`): added `ObjectMessage.fromObjectMessage(_:channelName:)` + and `ObjectOperation.fromObjectOperation(_:)` as `notImplemented()` skeletons (the spec's + `PublicObjectMessage` / `PublicObjectOperation` map to the SDK's `ObjectMessage` / `ObjectOperation`; + the `channel` object is represented by its name). → `PublicObjectMessageTests`. + +A minimal `testsOnly_objectsSyncState` accessor and an `ObjectsPool.testsOnly_setEntry(_:forObjectID:)` +seed helper were also added to `Sources` to let the internal-engine tests assert sync state and +pre-seed the pool. ## Mock Infrastructure Limitations