Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProtocolTypes.ObjectsMapSemantics>? {
mutableStateMutex.withSync { mutableState in
mutableState.semantics
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>] { 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<String>] {
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 {}
10 changes: 10 additions & 0 deletions Sources/AblyLiveObjects/Internal/ObjectsPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions Tests/UTS/Harness/UTSTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Tests/UTS/Helpers/StandardTestPool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
39 changes: 39 additions & 0 deletions Tests/UTS/Helpers/UTSMockCoreSDK.swift
Original file line number Diff line number Diff line change
@@ -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<PublishResult, ARTErrorInfo>) -> Void) {
let result = publishHandler(objectMessages)
internalQueue.async { callback(.success(result)) }
}

func nosync_fetchServerTime(callback: @escaping @Sendable (Result<Date, ARTErrorInfo>) -> Void) {
callback(.success(Date()))
}

func testsOnly_overridePublish(with _: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) {}

var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState {
channelState
}
}
8 changes: 8 additions & 0 deletions Tests/UTS/Helpers/UTSNoOpLogger.swift
Original file line number Diff line number Diff line change
@@ -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) {}
}
41 changes: 41 additions & 0 deletions Tests/UTS/Helpers/UTSTestCase+LiveObjects.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(_ 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`).
Expand Down
102 changes: 102 additions & 0 deletions Tests/UTS/Tests/Internal/ObjectIdTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading