From 69b8e5bd06b8ea4ea68db3f076fc950d93c132b6 Mon Sep 17 00:00:00 2001 From: Alex Kremer Date: Mon, 8 Dec 2025 16:44:43 +0000 Subject: [PATCH] feat: Add observable value util (#2831) This implements a simple observable value. Can be used for a more reactive approach. Will be used in ETL state and across the codebase with time. --- src/util/ObservableValue.hpp | 426 +++++++++ tests/unit/CMakeLists.txt | 2 + tests/unit/util/ObservableValueAtomicTest.cpp | 446 ++++++++++ tests/unit/util/ObservableValueTest.cpp | 838 ++++++++++++++++++ 4 files changed, 1712 insertions(+) create mode 100644 src/util/ObservableValue.hpp create mode 100644 tests/unit/util/ObservableValueAtomicTest.cpp create mode 100644 tests/unit/util/ObservableValueTest.cpp diff --git a/src/util/ObservableValue.hpp b/src/util/ObservableValue.hpp new file mode 100644 index 000000000..c44d2b768 --- /dev/null +++ b/src/util/ObservableValue.hpp @@ -0,0 +1,426 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace util { + +template +concept SomeAtomic = std::same_as, std::atomic>>; + +/** + * @brief Concept defining types that can be observed for changes. + * + * A type is Observable if it satisfies all requirements for being stored + * and monitored in an ObservableValue container: + * + * - Must be equality comparable to detect changes + * - Must be copy constructible for capturing old values in guards + * - Must be move constructible for efficient value updates + * + * @note Copy assignment is intentionally not required since we use move semantics + * for value updates and only need copy construction for change detection. + */ +template +concept Observable = std::equality_comparable && std::copy_constructible && std::move_constructible; + +namespace impl { + +/** + * @brief Base class containing common ObservableValue functionality. + * + * This class contains all the observer management and notification logic + * that is shared between regular and atomic ObservableValue specializations. + * + * @tparam T The value type (for atomic specializations, this is the underlying type, not std::atomic) + */ +template +class ObservableValueBase { +protected: + boost::signals2::signal onUpdate_; + +public: + virtual ~ObservableValueBase() = default; + + /** + * @brief Registers an observer callback for value changes. + * @param fn Callback function/lambda that accepts T const& + * @return Connection object for managing the subscription + */ + boost::signals2::connection + observe(std::invocable auto&& fn) + { + return onUpdate_.connect(std::forward(fn)); + } + + /** + * @brief Checks if there are any active observers. + * @return true if there are observers, false otherwise + */ + [[nodiscard]] bool + hasObservers() const + { + return not onUpdate_.empty(); + } + + /** + * @brief Forces notification of all observers with the current value. + * + * This method will notify all observers with the current value regardless + * of whether the value has changed since the last notification. + */ + virtual void + forceNotify() = 0; + +protected: + /** + * @brief Notifies all observers with the given value. + * @param value The value to send to observers + */ + void + notifyObservers(T const& value) + { + onUpdate_(value); + } +}; + +} // namespace impl + +// Forward declaration +template +class ObservableValue; + +/** + * @brief An observable value container that notifies observers when the value changes. + * + * ObservableValue wraps a value of type T and provides a mechanism to observe changes to that value. + * When the value is modified (and actually changes), all registered observers are notified. + * + * @tparam T The type of value to observe. Must satisfy the Observable concept. + * + * @par Thread Safety + * - Observer subscription/unsubscription (observe() and connection.disconnect()) are thread-safe + * - Value modification operations (set(), operator=) are NOT thread-safe and require external synchronization + * - Observer callbacks are invoked synchronously on the same thread that triggered the value change + * - If observers need to perform work on different threads, they must handle dispatch themselves + * (e.g., using an async execution context or message queue) + * + * @par Exception Handling + * - If an observer callback throws an exception, the exception will propagate to the caller + * - The value will still be updated even if observers throw exceptions + * - No guarantee is made about whether other observers will be called if one throws + * - It is the caller's responsibility to handle exceptions from observer callbacks + */ +template + requires(not SomeAtomic) +class ObservableValue : public impl::ObservableValueBase { + T value_; + + /** + * @brief RAII guard for deferred notification of value changes. + * + * ObservableGuard captures the current value when created and compares it + * with the final value when destroyed. If the values differ, observers + * are notified. This allows for multiple modifications to the value with + * only a single notification at the end. + * + * @note This class is returned by operator->() and should not be used directly. + */ + struct ObservableGuard { + T const oldValue; ///< Value captured at construction time + ObservableValue& ref; ///< Reference to the observable value + + /** + * @brief Constructs guard and captures current value. + * @param observable The ObservableValue to guard + */ + ObservableGuard(ObservableValue& observable) : oldValue(observable), ref(observable) + { + } + + /** + * @brief Destructor that triggers notification if value changed. + * + * Compares the captured value with the current value. If they differ, + * notifies all observers with the current value. + */ + ~ObservableGuard() + { + if (oldValue != ref.value_) + ref.notifyObservers(ref.value_); + } + + /** + * @brief Provides mutable access to the underlying value. + * @return Mutable reference to the wrapped value + */ + [[nodiscard]] + operator T&() + { + return ref.value_; + } + }; + +public: + /** + * @brief Constructs ObservableValue with initial value. + * @param value Initial value (must be convertible to T) + */ + ObservableValue(std::convertible_to auto&& value) : value_{std::forward(value)} + { + } + + /** + * @brief Constructs ObservableValue with default initial value. + */ + ObservableValue() + requires std::default_initializable + : value_{} + { + } + + ObservableValue(ObservableValue const&) = delete; + ObservableValue(ObservableValue&&) = default; + ObservableValue& + operator=(ObservableValue const&) = delete; + ObservableValue& + operator=(ObservableValue&&) = default; + + /** + * @brief Assignment operator that updates value and notifies observers. + * + * Updates the stored value and notifies observers if the new value + * differs from the current value (using operator!=). + * + * @param val New value (must be convertible to T) + * @return Reference to this object for chaining + * + * @throws Any exception thrown by observer callbacks will propagate + */ + ObservableValue& + operator=(std::convertible_to auto&& val) + { + set(val); + return *this; + } + + /** + * @brief Provides deferred notification access to the value. + * + * Returns an ObservableGuard that allows modification of the value + * with notification deferred until the guard is destroyed. + * + * @return ObservableGuard for deferred notification + */ + [[nodiscard]] ObservableGuard + operator->() + { + return {*this}; + } + + /** + * @brief Implicit conversion to const reference of the value. + * @return Const reference to the stored value + */ + [[nodiscard]] + operator T const&() const + { + return value_; + } + + /** + * @brief Explicitly gets the current value. + * @return Const reference to the stored value + */ + [[nodiscard]] T const& + get() const + { + return value_; + } + + /** + * @brief Sets a new value and notifies observers if changed. + * + * Updates the stored value and notifies all observers if the new value + * differs from the current value (using operator!=). If the values are + * equal, no notification occurs. + * + * @param val New value (must be convertible to T) + * + * @throws Any exception thrown by observer callbacks will propagate + * + * @par Thread Safety + * - This method is NOT thread-safe and requires external synchronization for concurrent access + * - Observer callbacks are invoked synchronously on the calling thread + */ + void + set(std::convertible_to auto&& val) + { + if (value_ != val) { + value_ = std::forward(val); + this->notifyObservers(value_); + } + } + + /** + * @brief Forces notification of all observers with the current value. + * + * This method will notify all observers with the current value regardless + * of whether the value has changed since the last notification. + */ + void + forceNotify() override + { + this->notifyObservers(value_); + } +}; + +/** + * @brief Partial specialization of ObservableValue for atomic types. + * + * This specialization provides thread-safe observation of atomic values while + * maintaining atomic semantics. It avoids the issues of copying atomic values + * and handles race conditions properly. + * + * @tparam T The underlying type stored in the atomic + * + * @par Thread Safety + * - All operations are thread-safe + * - Observer notifications are atomic with respect to value changes + * - Multiple threads can safely modify and observe the atomic value + * + * @par Performance Considerations + * - Uses atomic compare-and-swap operations for updates + * - Minimizes atomic reads during guard operations + * - Observer notifications happen outside of atomic operations when possible + */ +template +class ObservableValue> : public impl::ObservableValueBase { + std::atomic value_; + +public: + /** + * @brief Constructs ObservableValue with initial atomic value. + * @param value Initial value (will be stored in the atomic) + */ + ObservableValue(std::convertible_to auto&& value) : value_{std::forward(value)} + { + } + + /** + * @brief Constructs ObservableValue with default initial value. + */ + ObservableValue() + requires std::default_initializable + : value_{} + { + } + + ObservableValue(ObservableValue const&) = delete; + ObservableValue(ObservableValue&&) = default; + ObservableValue& + operator=(ObservableValue const&) = delete; + ObservableValue& + operator=(ObservableValue&&) = default; + + /** + * @brief Assignment operator that updates atomic value and notifies observers. + * + * Uses atomic compare-and-swap to update the value and notifies observers + * only if the value actually changed. + * + * @param val New value + * @return Reference to this object for chaining + */ + ObservableValue& + operator=(std::convertible_to auto&& val) + { + set(std::forward(val)); + return *this; + } + + /** + * @brief Gets the current atomic value. + * @return Current value stored in the atomic + */ + [[nodiscard]] T + get() const + { + return value_.load(); + } + + /** + * @brief Implicit conversion to the current atomic value. + * @return Current value stored in the atomic + */ + [[nodiscard]] + operator T() const + { + return get(); + } + + /** + * @brief Sets a new atomic value and notifies observers if changed. + * + * Uses atomic compare-and-swap to update the value. Notifies all observers + * if the value actually changed. + * + * @param val New value + */ + void + set(std::convertible_to auto&& val) + { + T newValue = std::forward(val); + T oldValue = value_.load(); + + // Use compare-and-swap to atomically update + while (!value_.compare_exchange_weak(oldValue, newValue)) { + // compare_exchange_weak updates oldValue with current value on failure + // Continue until we succeed + } + + // Notify observers if we actually changed the value + // Note: oldValue now contains the actual previous value that was replaced + if (oldValue != newValue) { + this->notifyObservers(newValue); + } + } + + /** + * @brief Forces notification of all observers with the current value. + * + * This method will notify all observers with the current atomic value + * regardless of whether the value has changed since the last notification. + */ + void + forceNotify() override + { + this->notifyObservers(value_.load()); + } +}; + +} // namespace util diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index cff464b7a..fe15f6330 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -169,6 +169,8 @@ target_sources( util/BytesConverterTests.cpp util/CoroutineTest.cpp util/MoveTrackerTests.cpp + util/ObservableValueTest.cpp + util/ObservableValueAtomicTest.cpp util/RandomTests.cpp util/RepeatTests.cpp util/ResponseExpirationCacheTests.cpp diff --git a/tests/unit/util/ObservableValueAtomicTest.cpp b/tests/unit/util/ObservableValueAtomicTest.cpp new file mode 100644 index 000000000..a68685955 --- /dev/null +++ b/tests/unit/util/ObservableValueAtomicTest.cpp @@ -0,0 +1,446 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/ObservableValue.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +using namespace testing; +using namespace util; + +namespace { + +} // namespace + +class ObservableValueAtomicTest : public ::testing::Test {}; + +TEST_F(ObservableValueAtomicTest, BasicConstruction) +{ + ObservableValue> obs{42}; + + EXPECT_EQ(obs.get(), 42); + EXPECT_EQ(static_cast(obs), 42); + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueAtomicTest, DefaultConstruction) +{ + ObservableValue> obsInt; + EXPECT_EQ(obsInt.get(), 0); + + ObservableValue> obsBool; + EXPECT_FALSE(obsBool.get()); + + EXPECT_FALSE(obsInt.hasObservers()); + EXPECT_FALSE(obsBool.hasObservers()); +} + +TEST_F(ObservableValueAtomicTest, BasicObservation) +{ + ObservableValue> obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(20)); + obs = 20; + EXPECT_EQ(obs.get(), 20); +} + +TEST_F(ObservableValueAtomicTest, SetMethod) +{ + ObservableValue> obs{5}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(15)); + obs.set(15); + EXPECT_EQ(obs.get(), 15); + + obs.set(15); // Same value should not notify + EXPECT_EQ(obs.get(), 15); +} + +TEST_F(ObservableValueAtomicTest, AtomicBasicUsage) +{ + ObservableValue> obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(25)); + obs.set(25); + + EXPECT_EQ(obs.get(), 25); +} + +TEST_F(ObservableValueAtomicTest, AtomicMultipleChanges) +{ + ObservableValue> obs{50}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(100)); // First change: 50→100 + EXPECT_CALL(mockObserver, Call(50)); // Second change: 100→50 + obs.set(100); // Should notify: 50→100 + obs.set(50); // Should notify: 100→50 + + EXPECT_EQ(obs.get(), 50); +} + +TEST_F(ObservableValueAtomicTest, AtomicNoChangeNoNotification) +{ + ObservableValue> obs{42}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + // No EXPECT_CALL since no notification should occur + obs.set(42); // Same value, should not notify + obs.set(42); // Same value again, should not notify + + EXPECT_EQ(obs.get(), 42); +} + +TEST_F(ObservableValueAtomicTest, AtomicSequentialChanges) +{ + ObservableValue> obs{1}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(2)); + obs.set(2); + + EXPECT_CALL(mockObserver, Call(3)); + obs.set(3); + + EXPECT_EQ(obs.get(), 3); +} + +TEST_F(ObservableValueAtomicTest, MultipleObservers) +{ + ObservableValue> obs{0}; + + testing::StrictMock> mockObserver1; + testing::StrictMock> mockObserver2; + + auto conn1 = obs.observe(mockObserver1.AsStdFunction()); + auto conn2 = obs.observe(mockObserver2.AsStdFunction()); + + EXPECT_CALL(mockObserver1, Call(42)); + EXPECT_CALL(mockObserver2, Call(42)); + obs = 42; + + conn1.disconnect(); + EXPECT_CALL(mockObserver2, Call(100)); + obs = 100; +} + +TEST_F(ObservableValueAtomicTest, ThreadSafetyBasic) +{ + ObservableValue> obs{0}; + std::atomic notificationCount{0}; + std::vector values; + std::mutex valuesMutex; + + auto connection = obs.observe([&](int const& value) { + notificationCount.fetch_add(1); + std::lock_guard lock(valuesMutex); + values.push_back(value); + }); + + static constexpr auto kNUM_THREADS = 4; + static constexpr auto kINCREMENTS_PER_THREAD = 100; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs]() { + for (int j = 0; j < kINCREMENTS_PER_THREAD; ++j) { + int expected = obs.get(); + int newValue = expected + 1; + obs.set(newValue); + std::this_thread::sleep_for(std::chrono::microseconds(1)); + } + }); + } + + for (auto& thread : threads) + thread.join(); + + // Final value may be less than kNumThreads * kIncrementsPerThread due to race conditions + EXPECT_GT(obs.get(), 0); + EXPECT_GT(notificationCount.load(), 0); + + std::lock_guard lock(valuesMutex); + for (auto const& value : values) { + EXPECT_GT(value, 0); + } +} + +TEST_F(ObservableValueAtomicTest, ThreadSafetyWithDirectAccess) +{ + ObservableValue> obs{0}; + std::atomic notificationCount{0}; + + auto connection = obs.observe([&](int const&) { notificationCount.fetch_add(1); }); + + static constexpr auto kNUM_THREADS = 4; + static constexpr auto kOPERATIONS_PER_THREAD = 50; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs]() { + for (int j = 0; j < kOPERATIONS_PER_THREAD; ++j) { + int current = obs.get(); + obs.set(current + 1); + std::this_thread::sleep_for(std::chrono::microseconds(1)); + } + }); + } + + for (auto& thread : threads) + thread.join(); + + EXPECT_GT(obs.get(), 0); + EXPECT_GT(notificationCount.load(), 0); +} + +TEST_F(ObservableValueAtomicTest, AtomicBoolSpecialization) +{ + ObservableValue> obs{false}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(true)); + obs = true; + EXPECT_TRUE(obs.get()); + + obs = true; // Same value should not notify + + EXPECT_CALL(mockObserver, Call(false)); + obs.set(false); + EXPECT_FALSE(obs.get()); +} + +TEST_F(ObservableValueAtomicTest, CompareAndSwapBehavior) +{ + ObservableValue> obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + // Test that compare-and-swap works correctly in set() + obs.set(10); // Same value, should not notify + + EXPECT_CALL(mockObserver, Call(20)); + obs.set(20); // Different value, should notify +} + +TEST_F(ObservableValueAtomicTest, RaceConditionNotificationIntegrity) +{ + ObservableValue> obs{0}; + std::atomic notificationCount{0}; + std::vector values; + std::mutex valuesMutex; + + auto connection = obs.observe([&](int const& value) { + notificationCount.fetch_add(1); + std::lock_guard lock(valuesMutex); + values.push_back(value); + }); + + static constexpr auto kNUM_THREADS = 10; + static constexpr auto kOPERATIONS_PER_THREAD = 20; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs]() { + for (int j = 0; j < kOPERATIONS_PER_THREAD; ++j) { + obs.set(j % 3); + std::this_thread::sleep_for(std::chrono::microseconds(1)); + } + }); + } + + for (auto& thread : threads) + thread.join(); + + EXPECT_GT(notificationCount.load(), 0); + + std::lock_guard lock(valuesMutex); + for (auto const& value : values) { + EXPECT_GE(value, 0); + EXPECT_LE(value, 2); + } + + int finalValue = obs.get(); + EXPECT_GE(finalValue, 0); + EXPECT_LE(finalValue, 2); +} + +TEST_F(ObservableValueAtomicTest, DeterministicNotificationTest) +{ + ObservableValue> obs{0}; + std::atomic notificationCount{0}; + std::vector values; + std::mutex valuesMutex; + + auto connection = obs.observe([&](int const& value) { + notificationCount.fetch_add(1); + std::lock_guard lock(valuesMutex); + values.push_back(value); + }); + + static constexpr auto kNUM_THREADS = 5; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs, i]() { obs.set(i + 1); }); + } + + for (auto& thread : threads) + thread.join(); + + // Each thread sets a unique value, so expect exactly kNumThreads notifications + EXPECT_EQ(notificationCount.load(), kNUM_THREADS); + + std::lock_guard lock(valuesMutex); + EXPECT_EQ(values.size(), kNUM_THREADS); + + for (auto const& value : values) { + EXPECT_GE(value, 1); + EXPECT_LE(value, kNUM_THREADS); + } + + int finalValue = obs.get(); + EXPECT_GE(finalValue, 1); + EXPECT_LE(finalValue, kNUM_THREADS); +} + +TEST_F(ObservableValueAtomicTest, NoNotificationForSameValue) +{ + ObservableValue> obs{42}; + std::atomic notificationCount{0}; + + auto connection = obs.observe([&](int const&) { notificationCount.fetch_add(1); }); + + static constexpr auto kNUM_THREADS = 10; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs]() { obs.set(42); }); + } + + for (auto& thread : threads) + thread.join(); + + EXPECT_EQ(notificationCount.load(), 0); // No notifications since value never changed + EXPECT_EQ(obs.get(), 42); +} + +TEST_F(ObservableValueAtomicTest, AtomicRaceConditionCorrectness) +{ + ObservableValue> obs{0}; + std::atomic notificationCount{0}; + std::vector values; + std::mutex valuesMutex; + + auto connection = obs.observe([&](int const& value) { + notificationCount.fetch_add(1); + std::lock_guard lock(valuesMutex); + values.push_back(value); + }); + + static constexpr auto kNUM_THREADS = 3; + + std::vector threads; + threads.reserve(kNUM_THREADS); + + // Test that direct access properly notifies for all value changes + // Each thread will make unique changes to avoid race condition conflicts + for (int i = 0; i < kNUM_THREADS; ++i) { + threads.emplace_back([&obs, i]() { + int baseValue = (i + 1) * 10; // 10, 20, 30 + obs.set(baseValue); // Store unique values + obs.set(baseValue + 1); // Then increment + }); + } + + for (auto& thread : threads) + thread.join(); + + // We should get some notifications (exact count depends on race conditions) + // but at least one per thread since they use unique base values + EXPECT_GE(notificationCount.load(), kNUM_THREADS); + + std::lock_guard lock(valuesMutex); + EXPECT_GE(values.size(), kNUM_THREADS); + + for (auto const& value : values) + EXPECT_GT(value, 0); +} + +TEST_F(ObservableValueAtomicTest, ForceNotify) +{ + ObservableValue> obs{42}; + testing::StrictMock> mockObserver; + + obs.forceNotify(); + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(42)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(42)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(100)); + obs.set(100); + EXPECT_CALL(mockObserver, Call(100)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(100)).Times(3); + obs.forceNotify(); + obs.forceNotify(); + obs.forceNotify(); +} diff --git a/tests/unit/util/ObservableValueTest.cpp b/tests/unit/util/ObservableValueTest.cpp new file mode 100644 index 000000000..09a162f6d --- /dev/null +++ b/tests/unit/util/ObservableValueTest.cpp @@ -0,0 +1,838 @@ +//------------------------------------------------------------------------------ +/* + This file is part of clio: https://github.com/XRPLF/clio + Copyright (c) 2025, the clio developers. + + Permission to use, copy, modify, and distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +*/ +//============================================================================== + +#include "util/ObservableValue.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +using namespace testing; +using namespace util; + +namespace { + +struct TestStruct { + int value = 0; + std::string name; + + bool + operator==(TestStruct const& other) const + { + return value == other.value && name == other.name; + } + + bool + operator!=(TestStruct const& other) const + { + return !(*this == other); + } +}; + +} // namespace + +class ObservableValueTest : public ::testing::Test {}; + +TEST_F(ObservableValueTest, ConceptCompliance) +{ + static_assert(Observable); + static_assert(Observable); + static_assert(Observable); + static_assert(Observable); + static_assert(Observable); + static_assert(Observable); + static_assert(Observable); + + struct NonCopyable { + int value = 0; + NonCopyable() = default; + NonCopyable(NonCopyable const&) = delete; + NonCopyable(NonCopyable&&) = default; + NonCopyable& + operator=(NonCopyable const&) = delete; + NonCopyable& + operator=(NonCopyable&&) = default; + bool + operator==(NonCopyable const& other) const + { + return value == other.value; + } + }; + static_assert(!Observable); + + struct NonMovable { + int value = 0; + NonMovable() = default; + NonMovable(NonMovable const&) = default; + NonMovable(NonMovable&&) = delete; + NonMovable& + operator=(NonMovable const&) = default; + NonMovable& + operator=(NonMovable&&) = delete; + bool + operator==(NonMovable const& other) const + { + return value == other.value; + } + }; + static_assert(!Observable); + + struct NonComparable { + int value = 0; + NonComparable() = default; + NonComparable(NonComparable const&) = default; + NonComparable(NonComparable&&) = default; + NonComparable& + operator=(NonComparable const&) = default; + NonComparable& + operator=(NonComparable&&) = default; + }; + static_assert(!Observable); + + struct NonDefaultInitializable { + int value; + NonDefaultInitializable() = delete; + explicit NonDefaultInitializable(int v) : value(v) + { + } + NonDefaultInitializable(NonDefaultInitializable const&) = default; + NonDefaultInitializable(NonDefaultInitializable&&) = default; + NonDefaultInitializable& + operator=(NonDefaultInitializable const&) = default; + NonDefaultInitializable& + operator=(NonDefaultInitializable&&) = default; + bool + operator==(NonDefaultInitializable const& other) const + { + return value == other.value; + } + }; + + static_assert(Observable); + static_assert(!std::default_initializable); + + static_assert(Observable>); + static_assert(Observable>); + static_assert(Observable>); + static_assert(Observable>); + + static_assert(std::default_initializable); + static_assert(std::default_initializable); + static_assert(std::default_initializable>); + static_assert(std::default_initializable); +} + +TEST_F(ObservableValueTest, Construction) +{ + ObservableValue obs{42}; + + EXPECT_EQ(static_cast(obs), 42); + EXPECT_EQ(obs.get(), 42); + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ConstructionWithDifferentTypes) +{ + ObservableValue obsStr{"hello"}; + EXPECT_EQ(obsStr.get(), "hello"); + + ObservableValue obsDouble{3.14}; + EXPECT_DOUBLE_EQ(obsDouble.get(), 3.14); + + ObservableValue obsBool{true}; + EXPECT_TRUE(obsBool.get()); +} + +TEST_F(ObservableValueTest, DefaultConstruction) +{ + ObservableValue obsInt; + EXPECT_EQ(obsInt.get(), 0); + + ObservableValue obsDouble; + EXPECT_DOUBLE_EQ(obsDouble.get(), 0.0); + + ObservableValue obsBool; + EXPECT_FALSE(obsBool.get()); + + ObservableValue obsChar; + EXPECT_EQ(obsChar.get(), '\0'); + + EXPECT_FALSE(obsInt.hasObservers()); + EXPECT_FALSE(obsDouble.hasObservers()); + EXPECT_FALSE(obsBool.hasObservers()); + EXPECT_FALSE(obsChar.hasObservers()); +} + +TEST_F(ObservableValueTest, DefaultConstructionWithContainers) +{ + ObservableValue obsString; + EXPECT_EQ(obsString.get(), ""); + EXPECT_TRUE(obsString.get().empty()); + + ObservableValue> obsVector; + EXPECT_TRUE(obsVector.get().empty()); + EXPECT_EQ(obsVector.get().size(), 0); + + ObservableValue> obsSet; + EXPECT_TRUE(obsSet.get().empty()); + EXPECT_EQ(obsSet.get().size(), 0); + + ObservableValue> obsMap; + EXPECT_TRUE(obsMap.get().empty()); + EXPECT_EQ(obsMap.get().size(), 0); +} + +TEST_F(ObservableValueTest, DefaultConstructionWithCustomType) +{ + ObservableValue obsStruct; + EXPECT_EQ(obsStruct.get().value, 0); + EXPECT_EQ(obsStruct.get().name, ""); +} + +TEST_F(ObservableValueTest, DefaultConstructionThenAssignment) +{ + ObservableValue obs; + EXPECT_EQ(obs.get(), 0); + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(42)); + obs = 42; + EXPECT_EQ(obs.get(), 42); + + obs = 42; // Same value, should not notify + + EXPECT_CALL(mockObserver, Call(100)); + obs.set(100); + EXPECT_EQ(obs.get(), 100); +} + +TEST_F(ObservableValueTest, DefaultConstructionWithGuard) +{ + ObservableValue obs; + EXPECT_EQ(obs.get(), ""); + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call("modified through guard")); + { + auto guard = obs.operator->(); + std::string& ref = guard; + ref = "modified through guard"; + } + + EXPECT_EQ(obs.get(), "modified through guard"); +} + +TEST_F(ObservableValueTest, DefaultConstructionNotificationBehavior) +{ + ObservableValue obs; + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(1)); + obs = 1; + + EXPECT_CALL(mockObserver, Call(0)); + obs = 0; + + obs = 0; // Same value, should not notify +} + +TEST_F(ObservableValueTest, NonDefaultInitializableTypeWithParameterizedConstructor) +{ + struct NonDefaultInitializable { + int value; + NonDefaultInitializable() = delete; + explicit NonDefaultInitializable(int v) : value(v) + { + } + NonDefaultInitializable(NonDefaultInitializable const&) = default; + NonDefaultInitializable(NonDefaultInitializable&&) = default; + NonDefaultInitializable& + operator=(NonDefaultInitializable const&) = default; + NonDefaultInitializable& + operator=(NonDefaultInitializable&&) = default; + bool + operator==(NonDefaultInitializable const& other) const + { + return value == other.value; + } + }; + + ObservableValue obs{NonDefaultInitializable{42}}; + EXPECT_EQ(obs.get().value, 42); + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(testing::Field(&NonDefaultInitializable::value, 100))); + obs = NonDefaultInitializable{100}; + EXPECT_EQ(obs.get().value, 100); +} + +TEST_F(ObservableValueTest, MoveSemantics) +{ + ObservableValue obs1{100}; + + ObservableValue obs2 = std::move(obs1); + EXPECT_EQ(obs2.get(), 100); + + ObservableValue obs3{200}; + obs3 = std::move(obs2); + EXPECT_EQ(obs3.get(), 100); +} + +TEST_F(ObservableValueTest, CopyOperationsDeleted) +{ + static_assert(!std::is_copy_constructible_v>); + static_assert(!std::is_copy_assignable_v>); +} + +TEST_F(ObservableValueTest, AssignmentOperator) +{ + ObservableValue obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(20)); + obs = 20; + EXPECT_EQ(obs.get(), 20); + + obs = 20; // Same value, should not notify + EXPECT_EQ(obs.get(), 20); +} + +TEST_F(ObservableValueTest, SetMethod) +{ + ObservableValue obs{5}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(15)); + obs.set(15); + EXPECT_EQ(obs.get(), 15); + + obs.set(15); // Same value, should not notify + EXPECT_EQ(obs.get(), 15); +} + +TEST_F(ObservableValueTest, ObserverManagement) +{ + ObservableValue obs{0}; + + EXPECT_FALSE(obs.hasObservers()); + + testing::StrictMock> mockObserver1; + testing::StrictMock> mockObserver2; + + auto conn1 = obs.observe(mockObserver1.AsStdFunction()); + EXPECT_TRUE(obs.hasObservers()); + + auto conn2 = obs.observe(mockObserver2.AsStdFunction()); + EXPECT_TRUE(obs.hasObservers()); + + EXPECT_CALL(mockObserver1, Call(42)); + EXPECT_CALL(mockObserver2, Call(42)); + obs = 42; + + conn1.disconnect(); + EXPECT_CALL(mockObserver2, Call(100)); + obs = 100; + + conn2.disconnect(); + EXPECT_FALSE(obs.hasObservers()); + + obs = 200; // No observers, no calls expected +} + +TEST_F(ObservableValueTest, ObservableGuardBasicUsage) +{ + ObservableValue obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(25)); + { + auto guard = obs.operator->(); + int& ref = guard; + ref = 25; + } + + EXPECT_EQ(obs.get(), 25); +} + +TEST_F(ObservableValueTest, ObservableGuardNoChangeNoNotification) +{ + ObservableValue obs{50}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + // No EXPECT_CALL since no notification should occur + { + auto guard = obs.operator->(); + int& ref = guard; + ref = 100; + ref = 50; // Back to original value + } + + EXPECT_EQ(obs.get(), 50); +} + +TEST_F(ObservableValueTest, ObservableGuardMultipleChanges) +{ + ObservableValue obs{1}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(2)); + { + auto guard = obs.operator->(); + int& ref = guard; + ref = 2; + } + + EXPECT_CALL(mockObserver, Call(3)); + { + auto guard = obs.operator->(); + int& ref = guard; + ref = 3; + } + + EXPECT_EQ(obs.get(), 3); +} + +TEST_F(ObservableValueTest, ComplexTypeObservation) +{ + TestStruct initial{.value = 42, .name = "test"}; + ObservableValue obs{initial}; + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + TestStruct newValue{.value = 100, .name = "changed"}; + EXPECT_CALL( + mockObserver, + Call(testing::AllOf(testing::Field(&TestStruct::value, 100), testing::Field(&TestStruct::name, "changed"))) + ); + obs = newValue; +} + +TEST_F(ObservableValueTest, ComplexTypeGuardModification) +{ + TestStruct initial{.value = 10, .name = "initial"}; + ObservableValue obs{initial}; + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL( + mockObserver, + Call(testing::AllOf(testing::Field(&TestStruct::value, 20), testing::Field(&TestStruct::name, "modified"))) + ); + { + auto guard = obs.operator->(); + TestStruct& ref = guard; + ref.value = 20; + ref.name = "modified"; + } + + EXPECT_EQ(obs.get().value, 20); + EXPECT_EQ(obs.get().name, "modified"); +} + +TEST_F(ObservableValueTest, StringObservation) +{ + ObservableValue obs{"initial"}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call("changed")); + obs = "changed"; + + EXPECT_CALL(mockObserver, Call("set_method")); + obs.set("set_method"); + + obs = "set_method"; // Same value, should not notify +} + +TEST_F(ObservableValueTest, MultipleObserversWithDifferentLifetimes) +{ + ObservableValue obs{0}; + + testing::StrictMock> mockObserver1; + testing::StrictMock> mockObserver2; + testing::StrictMock> mockObserver3; + + auto conn1 = obs.observe(mockObserver1.AsStdFunction()); + + EXPECT_CALL(mockObserver1, Call(1)); + obs = 1; + + auto conn2 = obs.observe(mockObserver2.AsStdFunction()); + EXPECT_CALL(mockObserver1, Call(2)); + EXPECT_CALL(mockObserver2, Call(2)); + obs = 2; + + conn1.disconnect(); + auto conn3 = obs.observe(mockObserver3.AsStdFunction()); + EXPECT_CALL(mockObserver2, Call(3)); + EXPECT_CALL(mockObserver3, Call(3)); + obs = 3; +} + +TEST_F(ObservableValueTest, NoNotificationWhenNoObservers) +{ + ObservableValue obs{0}; + + obs = 1; + obs.set(2); + + { + auto guard = obs.operator->(); + int& ref = guard; + ref = 3; + } + + EXPECT_EQ(obs.get(), 3); + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ManyObservers) +{ + ObservableValue obs{0}; + + std::vector>>> mockObservers; + std::vector connections; + + constexpr int kNUM_OBSERVERS = 100; + for (int i = 0; i < kNUM_OBSERVERS; ++i) { + mockObservers.push_back(std::make_unique>>()); + connections.push_back(obs.observe(mockObservers.back()->AsStdFunction())); + } + + EXPECT_TRUE(obs.hasObservers()); + + for (auto const& mockObserver : mockObservers) { + EXPECT_CALL(*mockObserver, Call(42)); + } + obs = 42; + + for (auto& conn : connections) { + conn.disconnect(); + } + + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, TypeConversions) +{ + ObservableValue obs{1.0}; + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(testing::DoubleEq(2.0))); + obs = 2; + + EXPECT_CALL(mockObserver, Call(testing::DoubleEq(3.14))); + obs = 3.14; + + EXPECT_CALL(mockObserver, Call(testing::DoubleEq(4.0))); + obs = static_cast(4.0f); +} + +TEST_F(ObservableValueTest, EnhancedConceptRequirements) +{ + struct ComplexObservable { + std::string name; + int value{}; + std::vector data; + + ComplexObservable() = default; + ComplexObservable(std::string n, int v, std::vector d) : name(std::move(n)), value(v), data(std::move(d)) + { + } + ComplexObservable(ComplexObservable const& other) = default; + ComplexObservable(ComplexObservable&& other) noexcept = default; + + ComplexObservable& + operator=(ComplexObservable&& other) noexcept + { + if (this != &other) { + name = std::move(other.name); + value = other.value; + data = std::move(other.data); + } + return *this; + } + + bool + operator==(ComplexObservable const& other) const + { + return name == other.name && value == other.value && data == other.data; + } + + ComplexObservable& + operator=(ComplexObservable const& other) + { + if (this != &other) { + name = other.name; + value = other.value; + data = other.data; + } + return *this; + } + }; + + static_assert(Observable); + + ComplexObservable initial{"test", 42, {1, 2, 3}}; + ObservableValue obs{std::move(initial)}; + + testing::StrictMock> mockObserver; + auto connection = obs.observe(mockObserver.AsStdFunction()); + + ComplexObservable newValue{"changed", 100, {4, 5, 6}}; + EXPECT_CALL( + mockObserver, + Call( + testing::AllOf( + testing::Field(&ComplexObservable::name, "changed"), + testing::Field(&ComplexObservable::value, 100), + testing::Field(&ComplexObservable::data, std::vector({4, 5, 6})) + ) + ) + ); + obs = std::move(newValue); + + ComplexObservable sameValue{"changed", 100, {4, 5, 6}}; + obs = std::move(sameValue); // Same value, should not notify +} + +TEST_F(ObservableValueTest, ExceptionInObserver) +{ + ObservableValue obs{0}; + + testing::StrictMock> goodMockObserver; + auto goodConnection = obs.observe(goodMockObserver.AsStdFunction()); + + auto throwingConnection = obs.observe([](int const&) { throw std::runtime_error("Observer exception"); }); + + EXPECT_CALL(goodMockObserver, Call(42)); + EXPECT_THROW(obs = 42, std::runtime_error); + + // Value is still updated even when observers throw + EXPECT_EQ(obs.get(), 42); +} + +TEST_F(ObservableValueTest, GuardExceptionSafety) +{ + ObservableValue obs{10}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(20)); + try { + auto guard = obs.operator->(); + int& ref = guard; + ref = 20; + throw std::runtime_error("Test exception"); + } catch (...) { + [[maybe_unused]] auto nothing = true; + } + + EXPECT_EQ(obs.get(), 20); +} + +TEST_F(ObservableValueTest, ComprehensiveIntegrationTest) +{ + ObservableValue obs{"start"}; + + testing::StrictMock> mockObserver1; + testing::StrictMock> mockObserver2; + auto conn1 = obs.observe(mockObserver1.AsStdFunction()); + auto conn2 = obs.observe(mockObserver2.AsStdFunction()); + + EXPECT_CALL(mockObserver1, Call("first")); + EXPECT_CALL(mockObserver2, Call("first")); + obs = "first"; + + EXPECT_CALL(mockObserver1, Call("second")); + EXPECT_CALL(mockObserver2, Call("second")); + obs.set("second"); + + obs = "second"; // Same value, should not notify + + EXPECT_CALL(mockObserver1, Call("third")); + EXPECT_CALL(mockObserver2, Call("third")); + { + auto guard = obs.operator->(); + std::string& ref = guard; + ref = "third"; + } + + conn1.disconnect(); + EXPECT_CALL(mockObserver2, Call("fourth")); + obs = "fourth"; + + EXPECT_EQ(obs.get(), "fourth"); + EXPECT_TRUE(obs.hasObservers()); + + conn2.disconnect(); + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, RegularConnectionPersistsAfterDestruction) +{ + ObservableValue obs{0}; + testing::StrictMock> mockObserver; + + { + auto connection = obs.observe(mockObserver.AsStdFunction()); + EXPECT_CALL(mockObserver, Call(1)); + obs = 1; + } + + EXPECT_CALL(mockObserver, Call(2)); + obs = 2; + + EXPECT_TRUE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ScopedConnectionDisconnectsOnDestruction) +{ + ObservableValue obs{0}; + testing::StrictMock> mockObserver; + + { + boost::signals2::scoped_connection scoped = obs.observe(mockObserver.AsStdFunction()); + EXPECT_CALL(mockObserver, Call(1)); + obs = 1; + EXPECT_TRUE(obs.hasObservers()); + } + + obs = 2; // No call expected since connection was destroyed + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ManualDisconnectWithRegularConnection) +{ + ObservableValue obs{0}; + testing::StrictMock> mockObserver; + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(1)); + obs = 1; + EXPECT_TRUE(obs.hasObservers()); + + connection.disconnect(); + + obs = 2; // No call expected since connection was disconnected + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ScopedConnectionCanBeDisconnectedManually) +{ + ObservableValue obs{0}; + testing::StrictMock> mockObserver; + + boost::signals2::scoped_connection scoped = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(1)); + obs = 1; + EXPECT_TRUE(obs.hasObservers()); + + scoped.disconnect(); + + obs = 2; // No call expected since connection was disconnected + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, MixedConnectionTypes) +{ + ObservableValue obs{0}; + testing::StrictMock> mockObserver1; + testing::StrictMock> mockObserver2; + testing::StrictMock> mockObserver3; + + auto regularConn = obs.observe(mockObserver1.AsStdFunction()); + + { + boost::signals2::scoped_connection scoped1 = obs.observe(mockObserver2.AsStdFunction()); + boost::signals2::scoped_connection scoped2 = obs.observe(mockObserver3.AsStdFunction()); + + EXPECT_CALL(mockObserver1, Call(1)); + EXPECT_CALL(mockObserver2, Call(1)); + EXPECT_CALL(mockObserver3, Call(1)); + obs = 1; + EXPECT_TRUE(obs.hasObservers()); + } + + EXPECT_CALL(mockObserver1, Call(2)); + obs = 2; // Only mockObserver1 should be called since scoped connections were destroyed + EXPECT_TRUE(obs.hasObservers()); + + regularConn.disconnect(); + EXPECT_FALSE(obs.hasObservers()); +} + +TEST_F(ObservableValueTest, ForceNotify) +{ + ObservableValue obs{42}; + testing::StrictMock> mockObserver; + + obs.forceNotify(); + + auto connection = obs.observe(mockObserver.AsStdFunction()); + + EXPECT_CALL(mockObserver, Call(42)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(42)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(100)); + obs.set(100); + EXPECT_CALL(mockObserver, Call(100)); + obs.forceNotify(); + + EXPECT_CALL(mockObserver, Call(100)).Times(3); + obs.forceNotify(); + obs.forceNotify(); + obs.forceNotify(); +}