Skip to content
Merged
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 @@ -331,19 +331,23 @@ internal final class InternalDefaultLiveCounter: Sendable {
return .update(.init(amount: -dataBeforeTombstoning))
}

// RTLC6g: Store the current data value as previousData for use in RTLC6h
let previousData = data

// RTLC6b: Set the private flag createOperationIsMerged to false
liveObjectMutableState.createOperationIsMerged = false

// RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist
data = state.counter?.count?.doubleValue ?? 0

// RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10
return if let createOp = state.createOp {
mergeInitialValue(from: createOp)
} else {
// TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446
.noop
// Discard the LiveCounterUpdate object returned by the merge operation
if let createOp = state.createOp {
_ = mergeInitialValue(from: createOp)
}

// RTLC6h: Calculate the diff between previousData and the current data per RTLC14
return ObjectDiffHelpers.calculateCounterDiff(previousData: previousData, newData: data)
}

/// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10.
Expand Down
14 changes: 9 additions & 5 deletions Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,9 @@ internal final class InternalDefaultLiveMap: Sendable {
return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed }))
}

// RTLM6g: Store the current data value as previousData for use in RTLM6h
let previousData = data

// RTLM6b: Set the private flag createOperationIsMerged to false
liveObjectMutableState.createOperationIsMerged = false

Expand All @@ -493,19 +496,20 @@ internal final class InternalDefaultLiveMap: Sendable {
} ?? [:]

// RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17
return if let createOp = state.createOp {
mergeInitialValue(
// Discard the LiveMapUpdate object returned by the merge operation
if let createOp = state.createOp {
_ = mergeInitialValue(
from: createOp,
objectsPool: &objectsPool,
logger: logger,
internalQueue: internalQueue,
userCallbackQueue: userCallbackQueue,
clock: clock,
)
} else {
// TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446
.noop
}

// RTLM6h: Calculate the diff between previousData and the current data per RTLM22
return ObjectDiffHelpers.calculateMapDiff(previousData: previousData, newData: data)
}

/// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17.
Expand Down
57 changes: 57 additions & 0 deletions Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import Foundation

/// Helper methods for calculating diffs between LiveObject data values.
internal enum ObjectDiffHelpers {
/// Calculates the diff between two LiveCounter data values, per RTLC14.
///
/// - Parameters:
/// - previousData: The previous `data` value (RTLC14a1).
/// - newData: The new `data` value (RTLC14a2).
/// - Returns: Per RTLC14b.
internal static func calculateCounterDiff(
previousData: Double,
newData: Double,
) -> LiveObjectUpdate<DefaultLiveCounterUpdate> {
// RTLC14b
.update(DefaultLiveCounterUpdate(amount: newData - previousData))
}

/// Calculates the diff between two LiveMap data values, per RTLM22.
///
/// - Parameters:
/// - previousData: The previous `data` value (RTLM22a1).
/// - newData: The new `data` value (RTLM22a2).
/// - Returns: Per RTLM22b.
internal static func calculateMapDiff(
previousData: [String: InternalObjectsMapEntry],
newData: [String: InternalObjectsMapEntry],
) -> LiveObjectUpdate<DefaultLiveMapUpdate> {
// RTLM22b
let previousNonTombstonedKeys = Set(previousData.filter { !$0.value.tombstone }.keys)
let newNonTombstonedKeys = Set(newData.filter { !$0.value.tombstone }.keys)

var update: [String: LiveMapUpdateAction] = [:]

// RTLM22b1
for key in previousNonTombstonedKeys.subtracting(newNonTombstonedKeys) {
update[key] = .removed
}

// RTLM22b2
for key in newNonTombstonedKeys.subtracting(previousNonTombstonedKeys) {
update[key] = .updated
}

// RTLM22b3
for key in previousNonTombstonedKeys.intersection(newNonTombstonedKeys) {
let previousEntry = previousData[key]!
let newEntry = newData[key]!

if previousEntry.data != newEntry.data {
update[key] = .updated
}
}

return .update(DefaultLiveMapUpdate(update: update))
}
}
58 changes: 58 additions & 0 deletions Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,64 @@ struct InternalDefaultLiveCounterTests {
#expect(counter.testsOnly_createOperationIsMerged)
}
}

/// Tests for RTLC6h (diff calculation on replaceData)
struct DiffCalculationTests {
// @specOneOf(1/2) RTLC6h - Tests that replaceData returns the diff calculated via RTLC14
@Test
func returnsCorrectDiffWithoutCreateOp() throws {
let logger = TestLogger()
let internalQueue = TestFactories.createInternalQueue()
let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)

// Set initial data to 10
internalQueue.ably_syncNoDeadlock {
_ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 10), objectMessageSerialTimestamp: nil)
}
#expect(try counter.value(coreSDK: coreSDK) == 10)

// Replace data with count 25 (no createOp)
let update = internalQueue.ably_syncNoDeadlock {
counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 25), objectMessageSerialTimestamp: nil)
}

// RTLC6h: Should return diff from previousData (10) to newData (25) = 15
#expect(try #require(update.update).amount == 15)
#expect(try counter.value(coreSDK: coreSDK) == 25)
}

// @specOneOf(2/2) RTLC6h - Tests that replaceData returns the diff after merging createOp
@Test
func returnsCorrectDiffWithCreateOp() throws {
let logger = TestLogger()
let internalQueue = TestFactories.createInternalQueue()
let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)

// Set initial data to 10
internalQueue.ably_syncNoDeadlock {
_ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 10), objectMessageSerialTimestamp: nil)
}
#expect(try counter.value(coreSDK: coreSDK) == 10)

// Replace data with count 5 and createOp with count 8
// This should set data to 5, then add 8 (mergeInitialValue), resulting in 13
let update = internalQueue.ably_syncNoDeadlock {
counter.nosync_replaceData(
using: TestFactories.counterObjectState(
createOp: TestFactories.counterCreateOperation(count: 8),
count: 5,
),
objectMessageSerialTimestamp: nil,
)
}

// RTLC6h: Should return diff from previousData (10) to newData (13) = 3
#expect(try #require(update.update).amount == 3)
#expect(try counter.value(coreSDK: coreSDK) == 13)
}
}
}

/// Tests for the `mergeInitialValue` method, covering RTLC10 specification points
Expand Down
101 changes: 101 additions & 0 deletions Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,107 @@ struct InternalDefaultLiveMapTests {
#expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp")
#expect(map.testsOnly_createOperationIsMerged)
}

/// Tests for RTLM6h (diff calculation on replaceData)
struct DiffCalculationTests {
// @specOneOf(1/2) RTLM6h - Tests that replaceData returns the diff calculated via RTLM22
@Test
func returnsCorrectDiffWithoutCreateOp() throws {
let logger = TestLogger()
let internalQueue = TestFactories.createInternalQueue()
let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())

// Set initial data
var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
internalQueue.ably_syncNoDeadlock {
_ = map.nosync_replaceData(
using: TestFactories.mapObjectState(
objectId: "arbitrary-id",
entries: [
"key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry,
"key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry,
],
),
objectMessageSerialTimestamp: nil,
objectsPool: &pool,
)
}

// Replace data with modified entries (no createOp)
let update = internalQueue.ably_syncNoDeadlock {
map.nosync_replaceData(
using: TestFactories.mapObjectState(
objectId: "arbitrary-id",
entries: [
"key1": TestFactories.stringMapEntry(key: "key1", value: "updatedValue").entry,
"key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry,
],
),
objectMessageSerialTimestamp: nil,
objectsPool: &pool,
)
}

// RTLM6h: Should return diff per RTLM22
// key1: updated (changed value), key2: removed, key3: added
let updateDict = try #require(update.update).update
#expect(updateDict["key1"] == .updated) // value changed
#expect(updateDict["key2"] == .removed) // removed
#expect(updateDict["key3"] == .updated) // added
}

// @specOneOf(2/2) RTLM6h - Tests that replaceData returns the diff after merging createOp
@Test
func returnsCorrectDiffWithCreateOp() throws {
let logger = TestLogger()
let internalQueue = TestFactories.createInternalQueue()
let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())

// Set initial data
var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())
internalQueue.ably_syncNoDeadlock {
_ = map.nosync_replaceData(
using: TestFactories.mapObjectState(
objectId: "arbitrary-id",
entries: [
"existing": TestFactories.stringMapEntry(key: "existing", value: "value").entry,
],
),
objectMessageSerialTimestamp: nil,
objectsPool: &pool,
)
}

// Replace data with entries and createOp
let update = internalQueue.ably_syncNoDeadlock {
map.nosync_replaceData(
using: TestFactories.objectState(
objectId: "arbitrary-id",
createOp: TestFactories.mapCreateOperation(
objectId: "arbitrary-id",
entries: [
"fromCreateOp": TestFactories.stringMapEntry(key: "fromCreateOp", value: "value").entry,
],
),
map: ObjectsMap(
semantics: .known(.lww),
entries: [
"fromEntries": TestFactories.stringMapEntry(key: "fromEntries", value: "value").entry,
],
),
),
objectMessageSerialTimestamp: nil,
objectsPool: &pool,
)
}

// RTLM6h: Should return diff from previousData to final data (after createOp merge)
let updateDict = try #require(update.update).update
#expect(updateDict["existing"] == .removed) // removed
#expect(updateDict["fromEntries"] == .updated) // added
#expect(updateDict["fromCreateOp"] == .updated) // added via createOp
}
}
}

/// Tests for the `size`, `entries`, `keys`, and `values` properties, covering RTLM10, RTLM11, RTLM12, and RTLM13 specification points
Expand Down
Loading
Loading