mirror of
https://github.com/XRPLF/clio.git
synced 2026-04-29 15:37:53 +00:00
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.
This commit is contained in:
426
src/util/ObservableValue.hpp
Normal file
426
src/util/ObservableValue.hpp
Normal file
@@ -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 <boost/signals2/connection.hpp>
|
||||
#include <boost/signals2/signal.hpp>
|
||||
#include <boost/signals2/variadic_signal.hpp>
|
||||
|
||||
#include <atomic>
|
||||
#include <concepts>
|
||||
#include <type_traits>
|
||||
|
||||
namespace util {
|
||||
|
||||
template <typename T>
|
||||
concept SomeAtomic = std::same_as<std::remove_cvref_t<T>, std::atomic<std::remove_cvref_t<typename T::value_type>>>;
|
||||
|
||||
/**
|
||||
* @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 <typename T>
|
||||
concept Observable = std::equality_comparable<T> && std::copy_constructible<T> && std::move_constructible<T>;
|
||||
|
||||
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<T>)
|
||||
*/
|
||||
template <Observable T>
|
||||
class ObservableValueBase {
|
||||
protected:
|
||||
boost::signals2::signal<void(T const&)> 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<T const&> auto&& fn)
|
||||
{
|
||||
return onUpdate_.connect(std::forward<decltype(fn)>(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 <typename T>
|
||||
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 <Observable T>
|
||||
requires(not SomeAtomic<T>)
|
||||
class ObservableValue<T> : public impl::ObservableValueBase<T> {
|
||||
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<T>& ref; ///< Reference to the observable value
|
||||
|
||||
/**
|
||||
* @brief Constructs guard and captures current value.
|
||||
* @param observable The ObservableValue to guard
|
||||
*/
|
||||
ObservableGuard(ObservableValue<T>& 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<T> auto&& value) : value_{std::forward<decltype(value)>(value)}
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Constructs ObservableValue with default initial value.
|
||||
*/
|
||||
ObservableValue()
|
||||
requires std::default_initializable<T>
|
||||
: 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<T> 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<T> auto&& val)
|
||||
{
|
||||
if (value_ != val) {
|
||||
value_ = std::forward<decltype(val)>(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 <Observable T>
|
||||
class ObservableValue<std::atomic<T>> : public impl::ObservableValueBase<T> {
|
||||
std::atomic<T> value_;
|
||||
|
||||
public:
|
||||
/**
|
||||
* @brief Constructs ObservableValue with initial atomic value.
|
||||
* @param value Initial value (will be stored in the atomic)
|
||||
*/
|
||||
ObservableValue(std::convertible_to<T> auto&& value) : value_{std::forward<decltype(value)>(value)}
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Constructs ObservableValue with default initial value.
|
||||
*/
|
||||
ObservableValue()
|
||||
requires std::default_initializable<T>
|
||||
: 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<T> auto&& val)
|
||||
{
|
||||
set(std::forward<decltype(val)>(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<T> auto&& val)
|
||||
{
|
||||
T newValue = std::forward<decltype(val)>(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
|
||||
@@ -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
|
||||
|
||||
446
tests/unit/util/ObservableValueAtomicTest.cpp
Normal file
446
tests/unit/util/ObservableValueAtomicTest.cpp
Normal file
@@ -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 <boost/signals2/connection.hpp>
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
using namespace testing;
|
||||
using namespace util;
|
||||
|
||||
namespace {
|
||||
|
||||
} // namespace
|
||||
|
||||
class ObservableValueAtomicTest : public ::testing::Test {};
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, BasicConstruction)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obs{42};
|
||||
|
||||
EXPECT_EQ(obs.get(), 42);
|
||||
EXPECT_EQ(static_cast<int>(obs), 42);
|
||||
EXPECT_FALSE(obs.hasObservers());
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, DefaultConstruction)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obsInt;
|
||||
EXPECT_EQ(obsInt.get(), 0);
|
||||
|
||||
ObservableValue<std::atomic<bool>> obsBool;
|
||||
EXPECT_FALSE(obsBool.get());
|
||||
|
||||
EXPECT_FALSE(obsInt.hasObservers());
|
||||
EXPECT_FALSE(obsBool.hasObservers());
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, BasicObservation)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver;
|
||||
|
||||
auto connection = obs.observe(mockObserver.AsStdFunction());
|
||||
|
||||
EXPECT_CALL(mockObserver, Call(20));
|
||||
obs = 20;
|
||||
EXPECT_EQ(obs.get(), 20);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, SetMethod)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obs{5};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{50};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{42};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{1};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{0};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver1;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{0};
|
||||
std::atomic<int> notificationCount{0};
|
||||
std::vector<int> values;
|
||||
std::mutex valuesMutex;
|
||||
|
||||
auto connection = obs.observe([&](int const& value) {
|
||||
notificationCount.fetch_add(1);
|
||||
std::lock_guard<std::mutex> lock(valuesMutex);
|
||||
values.push_back(value);
|
||||
});
|
||||
|
||||
static constexpr auto kNUM_THREADS = 4;
|
||||
static constexpr auto kINCREMENTS_PER_THREAD = 100;
|
||||
|
||||
std::vector<std::thread> 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<std::mutex> lock(valuesMutex);
|
||||
for (auto const& value : values) {
|
||||
EXPECT_GT(value, 0);
|
||||
}
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, ThreadSafetyWithDirectAccess)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obs{0};
|
||||
std::atomic<int> 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<std::thread> 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<std::atomic<bool>> obs{false};
|
||||
testing::StrictMock<testing::MockFunction<void(bool const&)>> 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<std::atomic<int>> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::atomic<int>> obs{0};
|
||||
std::atomic<int> notificationCount{0};
|
||||
std::vector<int> values;
|
||||
std::mutex valuesMutex;
|
||||
|
||||
auto connection = obs.observe([&](int const& value) {
|
||||
notificationCount.fetch_add(1);
|
||||
std::lock_guard<std::mutex> lock(valuesMutex);
|
||||
values.push_back(value);
|
||||
});
|
||||
|
||||
static constexpr auto kNUM_THREADS = 10;
|
||||
static constexpr auto kOPERATIONS_PER_THREAD = 20;
|
||||
|
||||
std::vector<std::thread> 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<std::mutex> 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<std::atomic<int>> obs{0};
|
||||
std::atomic<int> notificationCount{0};
|
||||
std::vector<int> values;
|
||||
std::mutex valuesMutex;
|
||||
|
||||
auto connection = obs.observe([&](int const& value) {
|
||||
notificationCount.fetch_add(1);
|
||||
std::lock_guard<std::mutex> lock(valuesMutex);
|
||||
values.push_back(value);
|
||||
});
|
||||
|
||||
static constexpr auto kNUM_THREADS = 5;
|
||||
|
||||
std::vector<std::thread> 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<std::mutex> 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<std::atomic<int>> obs{42};
|
||||
std::atomic<int> notificationCount{0};
|
||||
|
||||
auto connection = obs.observe([&](int const&) { notificationCount.fetch_add(1); });
|
||||
|
||||
static constexpr auto kNUM_THREADS = 10;
|
||||
|
||||
std::vector<std::thread> 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<std::atomic<int>> obs{0};
|
||||
std::atomic<int> notificationCount{0};
|
||||
std::vector<int> values;
|
||||
std::mutex valuesMutex;
|
||||
|
||||
auto connection = obs.observe([&](int const& value) {
|
||||
notificationCount.fetch_add(1);
|
||||
std::lock_guard<std::mutex> lock(valuesMutex);
|
||||
values.push_back(value);
|
||||
});
|
||||
|
||||
static constexpr auto kNUM_THREADS = 3;
|
||||
|
||||
std::vector<std::thread> 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<std::mutex> lock(valuesMutex);
|
||||
EXPECT_GE(values.size(), kNUM_THREADS);
|
||||
|
||||
for (auto const& value : values)
|
||||
EXPECT_GT(value, 0);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueAtomicTest, ForceNotify)
|
||||
{
|
||||
ObservableValue<std::atomic<int>> obs{42};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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();
|
||||
}
|
||||
838
tests/unit/util/ObservableValueTest.cpp
Normal file
838
tests/unit/util/ObservableValueTest.cpp
Normal file
@@ -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 <boost/signals2/connection.hpp>
|
||||
#include <gmock/gmock.h>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <concepts>
|
||||
#include <map>
|
||||
#include <set>
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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<int>);
|
||||
static_assert(Observable<std::string>);
|
||||
static_assert(Observable<double>);
|
||||
static_assert(Observable<TestStruct>);
|
||||
static_assert(Observable<bool>);
|
||||
static_assert(Observable<char>);
|
||||
static_assert(Observable<float>);
|
||||
|
||||
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<NonCopyable>);
|
||||
|
||||
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<NonMovable>);
|
||||
|
||||
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<NonComparable>);
|
||||
|
||||
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<NonDefaultInitializable>);
|
||||
static_assert(!std::default_initializable<NonDefaultInitializable>);
|
||||
|
||||
static_assert(Observable<std::vector<int>>);
|
||||
static_assert(Observable<std::map<int, int>>);
|
||||
static_assert(Observable<std::set<int>>);
|
||||
static_assert(Observable<std::pair<int, std::string>>);
|
||||
|
||||
static_assert(std::default_initializable<int>);
|
||||
static_assert(std::default_initializable<std::string>);
|
||||
static_assert(std::default_initializable<std::vector<int>>);
|
||||
static_assert(std::default_initializable<TestStruct>);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, Construction)
|
||||
{
|
||||
ObservableValue<int> obs{42};
|
||||
|
||||
EXPECT_EQ(static_cast<int>(obs), 42);
|
||||
EXPECT_EQ(obs.get(), 42);
|
||||
EXPECT_FALSE(obs.hasObservers());
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, ConstructionWithDifferentTypes)
|
||||
{
|
||||
ObservableValue<std::string> obsStr{"hello"};
|
||||
EXPECT_EQ(obsStr.get(), "hello");
|
||||
|
||||
ObservableValue<double> obsDouble{3.14};
|
||||
EXPECT_DOUBLE_EQ(obsDouble.get(), 3.14);
|
||||
|
||||
ObservableValue<bool> obsBool{true};
|
||||
EXPECT_TRUE(obsBool.get());
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, DefaultConstruction)
|
||||
{
|
||||
ObservableValue<int> obsInt;
|
||||
EXPECT_EQ(obsInt.get(), 0);
|
||||
|
||||
ObservableValue<double> obsDouble;
|
||||
EXPECT_DOUBLE_EQ(obsDouble.get(), 0.0);
|
||||
|
||||
ObservableValue<bool> obsBool;
|
||||
EXPECT_FALSE(obsBool.get());
|
||||
|
||||
ObservableValue<char> 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<std::string> obsString;
|
||||
EXPECT_EQ(obsString.get(), "");
|
||||
EXPECT_TRUE(obsString.get().empty());
|
||||
|
||||
ObservableValue<std::vector<int>> obsVector;
|
||||
EXPECT_TRUE(obsVector.get().empty());
|
||||
EXPECT_EQ(obsVector.get().size(), 0);
|
||||
|
||||
ObservableValue<std::set<int>> obsSet;
|
||||
EXPECT_TRUE(obsSet.get().empty());
|
||||
EXPECT_EQ(obsSet.get().size(), 0);
|
||||
|
||||
ObservableValue<std::map<int, std::string>> obsMap;
|
||||
EXPECT_TRUE(obsMap.get().empty());
|
||||
EXPECT_EQ(obsMap.get().size(), 0);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, DefaultConstructionWithCustomType)
|
||||
{
|
||||
ObservableValue<TestStruct> obsStruct;
|
||||
EXPECT_EQ(obsStruct.get().value, 0);
|
||||
EXPECT_EQ(obsStruct.get().name, "");
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, DefaultConstructionThenAssignment)
|
||||
{
|
||||
ObservableValue<int> obs;
|
||||
EXPECT_EQ(obs.get(), 0);
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::string> obs;
|
||||
EXPECT_EQ(obs.get(), "");
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(std::string const&)>> 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<int> obs;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<NonDefaultInitializable> obs{NonDefaultInitializable{42}};
|
||||
EXPECT_EQ(obs.get().value, 42);
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(NonDefaultInitializable const&)>> 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<int> obs1{100};
|
||||
|
||||
ObservableValue<int> obs2 = std::move(obs1);
|
||||
EXPECT_EQ(obs2.get(), 100);
|
||||
|
||||
ObservableValue<int> obs3{200};
|
||||
obs3 = std::move(obs2);
|
||||
EXPECT_EQ(obs3.get(), 100);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, CopyOperationsDeleted)
|
||||
{
|
||||
static_assert(!std::is_copy_constructible_v<ObservableValue<int>>);
|
||||
static_assert(!std::is_copy_assignable_v<ObservableValue<int>>);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, AssignmentOperator)
|
||||
{
|
||||
ObservableValue<int> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{5};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{0};
|
||||
|
||||
EXPECT_FALSE(obs.hasObservers());
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver1;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{50};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{1};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<TestStruct> obs{initial};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(TestStruct const&)>> 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<TestStruct> obs{initial};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(TestStruct const&)>> 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<std::string> obs{"initial"};
|
||||
testing::StrictMock<testing::MockFunction<void(std::string const&)>> 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<int> obs{0};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver1;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver2;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> 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<int> obs{0};
|
||||
|
||||
std::vector<std::unique_ptr<testing::StrictMock<testing::MockFunction<void(int const&)>>>> mockObservers;
|
||||
std::vector<boost::signals2::connection> connections;
|
||||
|
||||
constexpr int kNUM_OBSERVERS = 100;
|
||||
for (int i = 0; i < kNUM_OBSERVERS; ++i) {
|
||||
mockObservers.push_back(std::make_unique<testing::StrictMock<testing::MockFunction<void(int const&)>>>());
|
||||
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<double> obs{1.0};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(double const&)>> 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<double>(4.0f);
|
||||
}
|
||||
|
||||
TEST_F(ObservableValueTest, EnhancedConceptRequirements)
|
||||
{
|
||||
struct ComplexObservable {
|
||||
std::string name;
|
||||
int value{};
|
||||
std::vector<int> data;
|
||||
|
||||
ComplexObservable() = default;
|
||||
ComplexObservable(std::string n, int v, std::vector<int> 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>);
|
||||
|
||||
ComplexObservable initial{"test", 42, {1, 2, 3}};
|
||||
ObservableValue<ComplexObservable> obs{std::move(initial)};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(ComplexObservable const&)>> 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<int>({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<int> obs{0};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{10};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<std::string> obs{"start"};
|
||||
|
||||
testing::StrictMock<testing::MockFunction<void(std::string const&)>> mockObserver1;
|
||||
testing::StrictMock<testing::MockFunction<void(std::string const&)>> 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<int> obs{0};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{0};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{0};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{0};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{0};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver1;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> mockObserver2;
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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<int> obs{42};
|
||||
testing::StrictMock<testing::MockFunction<void(int const&)>> 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();
|
||||
}
|
||||
Reference in New Issue
Block a user