From 1185b08f5496dfa3ae02326a1cb9d24506cf78a9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 16 Jan 2026 11:08:47 +0000 Subject: [PATCH] Fix events emitted upon sync This implements the spec changes from [1] at 3f86319. That is, the update events emitted by a sync are now calculated from the before/after diff of the object state, as opposed to just being calculated from the createOp. Written by Claude based on the spec. [1] https://github.com/ably/specification/pull/414 --- .../Internal/InternalDefaultLiveCounter.swift | 14 +- .../Internal/InternalDefaultLiveMap.swift | 14 +- .../Internal/ObjectDiffHelpers.swift | 57 ++++++ .../InternalDefaultLiveCounterTests.swift | 58 ++++++ .../InternalDefaultLiveMapTests.swift | 101 ++++++++++ .../ObjectDiffHelpersTests.swift | 181 ++++++++++++++++++ .../ObjectsPoolTests.swift | 16 +- 7 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift create mode 100644 Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 15f12a10..d8713063 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -331,6 +331,9 @@ 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 @@ -338,12 +341,13 @@ internal final class InternalDefaultLiveCounter: Sendable { 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. diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index d1ec8922..788eaff5 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -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 @@ -493,8 +496,9 @@ 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, @@ -502,10 +506,10 @@ internal final class InternalDefaultLiveMap: Sendable { 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. diff --git a/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift new file mode 100644 index 00000000..dbd93091 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -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 { + // 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 { + // 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)) + } +} diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 520ad03b..f749be69 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -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 diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 3fd05e22..72ee524d 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -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 diff --git a/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift new file mode 100644 index 00000000..640d4d83 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift @@ -0,0 +1,181 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectDiffHelpersTests { + /// Tests for the `calculateCounterDiff` method, covering RTLC14 specification points + struct CalculateCounterDiffTests { + // @spec RTLC14b + @Test + func calculatesDifference() { + let update = ObjectDiffHelpers.calculateCounterDiff( + previousData: 10.0, + newData: 15.0, + ) + #expect(update.update?.amount == 5.0) + } + } + + /// Tests for the `calculateMapDiff` method, covering RTLM22 specification points + struct CalculateMapDiffTests { + // @spec RTLM22b1 + @Test + func detectsRemovedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key2"] == .removed) + #expect(update.update?.update["key1"] == nil) // key1 unchanged + } + + // @spec RTLM22b2 + @Test + func detectsAddedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key2"] == .updated) + #expect(update.update?.update["key1"] == nil) // key1 unchanged + } + + // @specOneOf(1/2) RTLM22b3 + @Test + func detectsUpdatedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key1"] == .updated) + } + + // @specOneOf(2/2) RTLM22b3 + @Test + func ignoresUnchangedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update.isEmpty == true) + } + + // @specOneOf(1/3) RTLM22b - Ignores tombstoned entries in previousData + @Test + func ignoresTombstonedEntriesInPreviousData() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + // key1 was tombstoned in previousData, so it's not considered "removed" + #expect(update.update?.update["key1"] == nil) + #expect(update.update?.update.isEmpty == true) + } + + // @specOneOf(2/3) RTLM22b - Ignores tombstoned entries in newData + @Test + func ignoresTombstonedEntriesInNewData() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + // key1 became tombstoned in newData, so it's considered "removed" + #expect(update.update?.update["key1"] == .removed) + } + + // @specOneOf(3/3) RTLM22b - Tombstoned to tombstoned is not a change + @Test + func ignoresTombstonedToTombstonedTransition() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + // Both tombstoned, so no change + #expect(update.update?.update.isEmpty == true) + } + + // Test combined changes + @Test + func detectsMultipleChanges() { + let previousData: [String: InternalObjectsMapEntry] = [ + "removed": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "updated": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "added": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "updated": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["removed"] == .removed) + #expect(update.update?.update["added"] == .updated) + #expect(update.update?.update["updated"] == .updated) + #expect(update.update?.update["unchanged"] == nil) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 866a6cff..3b075733 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -119,8 +119,11 @@ struct ObjectsPoolTests { #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + // Per RTLM6h, the update should reflect the diff from previous state (empty) to new state (key1 + createOpKey) let subscriberInvocations = await existingMapSubscriber.getInvocations() - #expect(subscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) + let emittedUpdate = try #require(subscriberInvocations.first?.0.update) + #expect(emittedUpdate["key1"] == .updated) + #expect(emittedUpdate["createOpKey"] == .updated) } // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects @@ -157,8 +160,9 @@ struct ObjectsPoolTests { #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + // Per RTLC6h, the update should reflect the diff from previous state (0) to new state (15) let subscriberInvocations = await existingCounterSubscriber.getInvocations() - #expect(subscriberInvocations.map(\.0) == [.init(amount: 5)]) // From createOp + #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) // Diff from 0 to 15 } // @spec RTO5c1b1a @@ -367,16 +371,20 @@ struct ObjectsPoolTests { #expect(try updatedMap.get(key: "updated", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated") // Check update emitted by existing map per RTO5c7 + // Per RTLM6h, the update should reflect the diff from previous state (empty) to new state (updated + createOpKey) let existingMapSubscriberInvocations = await existingMapSubscriber.getInvocations() - #expect(existingMapSubscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) + let existingMapUpdate = try #require(existingMapSubscriberInvocations.first?.0.update) + #expect(existingMapUpdate["createOpKey"] == .updated) + #expect(existingMapUpdate["updated"] == .updated) let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) // Checking counter value to verify replaceData was called successfully #expect(try updatedCounter.value(coreSDK: coreSDK) == 105) // Check update emitted by existing counter per RTO5c7 + // Per RTLC6h, the update should reflect the diff from previous state (0) to new state (105) let existingCounterInvocations = await existingCounterSubscriber.getInvocations() - #expect(existingCounterInvocations.map(\.0) == [.init(amount: 5)]) + #expect(existingCounterInvocations.map(\.0) == [.init(amount: 105)]) // Diff from 0 to 105 // New objects - verify by checking side effects of replaceData calls let newMap = try #require(pool.entries["map:new@1"]?.mapValue)