diff --git a/CMakeLists.txt b/CMakeLists.txt index aaa39bb..1f978f6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,7 @@ project(observable) cmake_minimum_required(VERSION 3.5) enable_testing() +set (CPP_STANDARD 17) set_property(GLOBAL PROPERTY USE_FOLDERS ON) set_property(GLOBAL PROPERTY PREDEFINED_TARGETS_FOLDER "cmake") diff --git a/cmake/compile_flags.cmake b/cmake/compile_flags.cmake index 4d33eb6..3c55901 100644 --- a/cmake/compile_flags.cmake +++ b/cmake/compile_flags.cmake @@ -73,7 +73,7 @@ function(set_cpp_standard target_name) get_property(cpp_standard GLOBAL PROPERTY cpp_standard) if(NOT CPP_STANDARD AND NOT cpp_standard) - set(cpp_standard 14) + set(cpp_standard 17) message(STATUS "You can set the C++ standard by defining CPP_STANDARD") elseif(NOT cpp_standard) set(cpp_standard ${CPP_STANDARD}) diff --git a/observable/CMakeLists.txt b/observable/CMakeLists.txt index 45d47c5..4e1d19a 100644 --- a/observable/CMakeLists.txt +++ b/observable/CMakeLists.txt @@ -2,6 +2,7 @@ add_library(observable INTERFACE) add_custom_target(observable_headers # Just to generate a project in IDEs. SOURCES + include/observable/collection.hpp include/observable/observable.hpp include/observable/observe.hpp include/observable/subject.hpp diff --git a/observable/include/observable/collection.hpp b/observable/include/observable/collection.hpp new file mode 100644 index 0000000..ebd8844 --- /dev/null +++ b/observable/include/observable/collection.hpp @@ -0,0 +1,167 @@ +#pragma once +#include + +#include + +#include +OBSERVABLE_BEGIN_CONFIGURE_WARNINGS + + +namespace observable { + +template < + typename ValueType, + template typename ContainerType = std::unordered_set, + typename... ContainerArgs +> +class collection : public value> +{ + using change_subject = subject; + using base_class = value>; + +public: + using container_type = ContainerType; + + //! Create a default-constructed observable collection. + //! + //! The backing container will be default constructed and empty + constexpr collection() =default; + + //! Create an initialized observable collection. + //! + //! \param initial_value The observable collection's initial value. + constexpr collection(std::initializer_list initial_value) : + base_class(container_type(initial_value)) + {} + + template + auto subscribe_changes(Callable && observer) const + { + static_assert(detail::is_compatible_with_subject::value, + "Observer is not valid. Please provide an observer that takes ValueType as" + "its first argument and a boolean as its second argument"); + + return subscribe_changes_impl(std::forward(observer)); + } + + //! Insert a new value into collection, possibly notifying any subscribed observers. + //! + //! The new value is inserted respecting the rules of the underlying container, if + //! it is not possible to add no observers will be notified + //! + //! \param new_value The new value to add. + //! \throw readonly_value if the value has an associated updater. + //! \see subject::notify() + bool insert(ValueType new_value) + { + return insert_impl(std::move(new_value)); + } + + //! Emplace a new value into collection, possibly notifying any subscribed observers. + //! + //! The new value is emplaced respecting the rules of the underlying container, if + //! it is not possible to add no observers will be notified + //! + //! \param new_value The new value to add. + //! \throw readonly_value if the value has an associated updater. + //! \see subject::notify() + bool emplace(ValueType && new_value) + { + return emplace_impl(std::forward(new_value)); + } + + //! Remove a value from collection, possibly notifying any subscribed observers. + //! + //! The new value is removed respecting the rules of the underlying container, if it + //! is not found in the container no observers will be notified + //! + //! \param new_value The new value to add. + //! \throw readonly_value if the value has an associated updater. + //! \see subject::notify() + bool remove(ValueType value) + { + return remove_impl(std::move(value)); + } + +private: + template + auto subscribe_changes_impl(Callable && observer) const + { + return change_observers_.subscribe(std::forward(observer)); + } + + bool insert_impl(ValueType new_value) + { + const auto [it, inserted] = value_.insert(new_value); + + if (inserted) + { + change_observers_.notify(*it, true); + void_observers_.notify(); + value_observers_.notify(value_); + } + + return inserted; + } + + bool emplace_impl(ValueType new_value) + { + const auto [it, inserted] = value_.emplace(new_value); + + if (inserted) + { + change_observers_.notify(*it, true); + void_observers_.notify(); + value_observers_.notify(value_); + } + + return inserted; + } + +#if __cplusplus > 201703L + bool remove_impl(ValueType value) + { + auto nh = value_.extract(value); + bool removed = false; + + if (nh) + { + removed = true; + change_observers_.notify(nh.value(), false); + void_observers_.notify(); + value_observers_.notify(value_); + } + + return removed; + } +#else + bool remove_impl(ValueType value) + { + // Without the C++17 extract function, we have to call the + // change_observer with the reference to the item in the collection + // before we actually remove it + bool removed = false; + + auto found_it = value_.find(value); + + if (found_it != value_.end()) + { + change_observers_.notify(*found_it, false); + + value_.erase(found_it); + void_observers_.notify(); + value_observers_.notify(value_); + + removed = true; + } + + return removed; + } +#endif + + mutable change_subject change_observers_; +}; + +} + +OBSERVABLE_END_CONFIGURE_WARNINGS \ No newline at end of file diff --git a/observable/include/observable/observable.hpp b/observable/include/observable/observable.hpp index 6c0d78a..bec421d 100644 --- a/observable/include/observable/observable.hpp +++ b/observable/include/observable/observable.hpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include diff --git a/observable/include/observable/value.hpp b/observable/include/observable/value.hpp index 14204fb..06356b0 100644 --- a/observable/include/observable/value.hpp +++ b/observable/include/observable/value.hpp @@ -229,7 +229,7 @@ class value subject> destroyed; //! Destructor. - ~value() { destroyed.notify(); } + virtual ~value() { destroyed.notify(); } public: //! Observable values are **not** copy-constructible. @@ -287,7 +287,7 @@ class value return *this; } -private: +protected: template auto subscribe_impl(Callable && observer) const -> std::enable_if_t::value && @@ -332,7 +332,7 @@ class value value_observers_.notify(value_); } -private: +protected: ValueType value_; std::function eq_ { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index da9fd44..c963fb2 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -10,6 +10,7 @@ add_executable(tests src/expressions/math.cpp src/expressions/operators.cpp src/expressions/tree.cpp + src/collection.cpp src/infinite_subscription.cpp src/observe.cpp src/shared_subscription.cpp diff --git a/tests/src/collection.cpp b/tests/src/collection.cpp new file mode 100644 index 0000000..7f3339b --- /dev/null +++ b/tests/src/collection.cpp @@ -0,0 +1,286 @@ +#include +#include +#include + +#include + +namespace observable { namespace test { + +TEST_CASE("collection/basic collection creation", "[collection]") +{ + SECTION("collections are default-constructible") + { + collection { }; + + REQUIRE(std::is_default_constructible>::value); + } + + SECTION("can create initialised value") + { + collection { 1, 2, 3, }; + } +} + +TEST_CASE("collection/copying", "[collection]") +{ + SECTION("collections are not copy-constructible") + { + REQUIRE_FALSE(std::is_copy_constructible>::value); + } + + SECTION("collections are not copy-assignable") + { + REQUIRE_FALSE(std::is_copy_assignable>::value); + } +} + +TEST_CASE("collection/value getter", "[value]") +{ + SECTION("can get value") + { + auto col = collection{ 1, 2, 3 }; + REQUIRE(col.get() == std::unordered_set{ 1, 2, 3 }); + } + + SECTION("getter is nothrow") + { + auto col = collection{ }; + REQUIRE(noexcept(col.get())); + } +} + +TEST_CASE("collection/conversions", "[value]") +{ + SECTION("can convert to collection_type") + { + auto col = collection{ 1, 2, 3 }; + auto c = static_cast>(col); + REQUIRE(c == std::unordered_set{ 1, 2, 3 }); + + auto set_col = collection{ 1, 2, 3 }; + auto set_v = static_cast>(set_col); + REQUIRE(set_v == std::set{ 1, 2, 3 }); + } + + SECTION("conversion operator is nothrow") + { + auto col = value{ }; + REQUIRE(noexcept(static_cast(col))); + } +} + +TEST_CASE("collection/insertion", "[value]") +{ + SECTION("can insert value") + { + auto col = collection{ 1, 2, 3 }; + auto is_inserted = col.insert(4); + + REQUIRE(is_inserted); + REQUIRE(col.get() == std::unordered_set{ 1, 2, 3, 4 }); + } + + SECTION("can't insert existing value") + { + auto col = collection{ 1, 2, 3 }; + auto is_inserted = col.insert(3); + + REQUIRE_FALSE(is_inserted); + REQUIRE(col.get() == std::unordered_set{ 1, 2, 3 }); + } +} + +TEST_CASE("collection/removal", "[value]") +{ + SECTION("can remove existing value") + { + auto col = collection{ 1, 2, 3 }; + auto is_removed = col.remove(3); + + REQUIRE(is_removed); + REQUIRE(col.get() == std::unordered_set{ 1, 2, }); + } + + SECTION("can't remove nonexistent value") + { + auto col = collection{ 1, 2, 3 }; + auto is_removed = col.remove(4); + + REQUIRE_FALSE(is_removed); + REQUIRE(col.get() == std::unordered_set{ 1, 2, 3 }); + } +} + +TEST_CASE("collection/subscribing", "[value]") +{ + SECTION("can change collection with no subscribed observers") + { + auto col = collection{ 5, 6, 7 }; + col.set({ 3, 4, 5, 6 }); + + REQUIRE(col.get() == std::unordered_set{ 3, 4, 5, 6 }); + } + + SECTION("can subscribe to value changes") + { + auto call_count = 0; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }).release(); + col.set({ 1, 2, 3, 4 }); + + REQUIRE(call_count == 1); + } + + SECTION("can subscribe to value changes on const collections") + { + auto call_count = 0; + + auto col = collection{ 1, 2, 3 }; + auto const& const_col = col; + const_col.subscribe([&]() { ++call_count; }); + col.set({ 1, 2, 3, 4 }); + + REQUIRE(call_count == 1); + } + + SECTION("can subscribe to inserted values") + { + auto call_count = 0; + auto inserted_val = 0; + auto is_inserted = false; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }).release(); + col.subscribe_changes([&](const int& val, bool inserted) { + inserted_val = val; + is_inserted = inserted; + }).release(); + + col.insert(4); + + REQUIRE(call_count == 1); + REQUIRE(inserted_val == 4); + REQUIRE(is_inserted); + } + + SECTION("can subscribe to added values on const collections") + { + auto call_count = 0; + auto inserted_val = 0; + auto is_inserted = false; + + auto col = collection{ 1, 2, 3 }; + auto const& const_col = col; + const_col.subscribe([&]() { ++call_count; }).release(); + const_col.subscribe_changes([&](const int& val, bool inserted) { + inserted_val = val; + is_inserted = inserted; + }).release(); + + col.insert(4); + + REQUIRE(call_count == 1); + REQUIRE(inserted_val == 4); + REQUIRE(is_inserted); + } + + SECTION("can subscribe to removed values") + { + auto call_count = 0; + auto removed_val = 0; + auto is_inserted = false; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }).release(); + col.subscribe_changes([&](const int& val, bool inserted) { + removed_val = val; + is_inserted = inserted; + }).release(); + + col.remove(3); + + REQUIRE(call_count == 1); + REQUIRE(removed_val == 3); + REQUIRE_FALSE(is_inserted); + } + + SECTION("can subscribe to removed values on const collections") + { + auto call_count = 0; + auto removed_val = 0; + auto is_inserted = false; + + auto col = collection{ 1, 2, 3 }; + auto const& const_col = col; + const_col.subscribe([&]() { ++call_count; }).release(); + const_col.subscribe_changes([&](const int& val, bool inserted) { + removed_val = val; + is_inserted = inserted; + }).release(); + + col.remove(3); + + REQUIRE(call_count == 1); + REQUIRE(removed_val == 3); + REQUIRE_FALSE(is_inserted); + } + + SECTION("setting same value does not trigger subscribers") + { + auto call_count = 0; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }); + col.subscribe([&](const std::unordered_set&) { ++call_count; }); + col.set({ 1, 2, 3 }); + + REQUIRE(call_count == 0); + } + + SECTION("inserting existing value does not trigger subscribers") + { + auto call_count = 0; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }); + col.subscribe([&](const std::unordered_set&) { ++call_count; }); + col.insert(3); + + REQUIRE(call_count == 0); + } + + SECTION("removing non-existing value does not trigger subscribers") + { + auto call_count = 0; + + auto col = collection{ 1, 2, 3 }; + col.subscribe([&]() { ++call_count; }); + col.subscribe([&](const std::unordered_set&) { ++call_count; }); + col.remove(4); + + REQUIRE(call_count == 0); + } + + SECTION("can subscribe and immediately call observer") + { + auto col = collection{ 5, 6, 7 }; + + auto call_count = 0; + auto sub = col.subscribe_and_call([&]() { ++call_count; }); + + REQUIRE(call_count == 1); + } + + SECTION("immediately called observer receives the current value") + { + auto col = collection{ 5, 6, 7 }; + + std::unordered_set call_value{ 3, 4, 5 }; + auto sub = col.subscribe_and_call([&](const std::unordered_set& v) { call_value = v; }); + + REQUIRE(call_value == std::unordered_set { 5, 6, 7 }); + } +} + +} } \ No newline at end of file