From 706c558caa4401ebaf0f2df971729586f3534935 Mon Sep 17 00:00:00 2001 From: Arthur Vasseur Date: Fri, 10 Apr 2026 22:32:13 +0200 Subject: [PATCH] feat: implement Signal and Connection classes with unit tests --- Src/Concerto/Core/Signal/Connection.hpp | 80 +++++++ Src/Concerto/Core/Signal/Connection.inl | 42 ++++ Src/Concerto/Core/Signal/Signal.hpp | 89 +++++++ Src/Concerto/Core/Signal/Signal.inl | 151 ++++++++++++ Src/Tests/Signal.cpp | 303 ++++++++++++++++++++++++ 5 files changed, 665 insertions(+) create mode 100644 Src/Concerto/Core/Signal/Connection.hpp create mode 100644 Src/Concerto/Core/Signal/Connection.inl create mode 100644 Src/Concerto/Core/Signal/Signal.hpp create mode 100644 Src/Concerto/Core/Signal/Signal.inl create mode 100644 Src/Tests/Signal.cpp diff --git a/Src/Concerto/Core/Signal/Connection.hpp b/Src/Concerto/Core/Signal/Connection.hpp new file mode 100644 index 0000000..a976c1a --- /dev/null +++ b/Src/Concerto/Core/Signal/Connection.hpp @@ -0,0 +1,80 @@ +// +// Created by arthur on 27/02/2026. +// + +#ifndef CONCERTO_CORE_CONNECTION_HPP +#define CONCERTO_CORE_CONNECTION_HPP + +#include +#include + +namespace cct +{ + /// Non-owning handle to a signal connection. + /// Call Disconnect() to manually remove the slot from the signal. + /// Safe to use after the Signal is destroyed (becomes a no-op). + class Connection + { + public: + Connection() = default; + Connection(const Connection&) = default; + Connection(Connection&&) = default; + Connection& operator=(const Connection&) = default; + Connection& operator=(Connection&&) = default; + ~Connection() = default; + + inline void Disconnect(); + [[nodiscard]] inline bool IsConnected() const; + + private: + explicit Connection(std::function disconnectFn, std::weak_ptr weakSignal) : + m_disconnectFn(std::move(disconnectFn)), + m_weakSignal(std::move(weakSignal)) + { + } + + template + friend class Signal; + + std::function m_disconnectFn; + std::weak_ptr m_weakSignal; + }; + + /// RAII wrapper around Connection. Automatically disconnects when destroyed. + /// Movable, non-copyable. + class ScopedConnection + { + public: + ScopedConnection() = default; + explicit ScopedConnection(Connection connection) : + m_connection(std::move(connection)) + { + } + ~ScopedConnection() { m_connection.Disconnect(); } + + ScopedConnection(const ScopedConnection&) = delete; + ScopedConnection& operator=(const ScopedConnection&) = delete; + + ScopedConnection(ScopedConnection&&) = default; + ScopedConnection& operator=(ScopedConnection&& other) noexcept + { + if (this != &other) + { + m_connection.Disconnect(); + m_connection = std::move(other.m_connection); + } + return *this; + } + + inline void Disconnect(); + [[nodiscard]] inline bool IsConnected() const; + [[nodiscard]] inline Connection Release(); + + private: + Connection m_connection; + }; +} // namespace cct + +#include "Concerto/Core/Signal/Connection.inl" + +#endif // CONCERTO_CORE_CONNECTION_HPP diff --git a/Src/Concerto/Core/Signal/Connection.inl b/Src/Concerto/Core/Signal/Connection.inl new file mode 100644 index 0000000..34295a7 --- /dev/null +++ b/Src/Concerto/Core/Signal/Connection.inl @@ -0,0 +1,42 @@ +// +// Created by arthur on 27/02/2026. +// + +#ifndef CONCERTO_CORE_CONNECTION_INL +#define CONCERTO_CORE_CONNECTION_INL + +namespace cct +{ + inline void Connection::Disconnect() + { + if (m_disconnectFn) + { + m_disconnectFn(); + m_disconnectFn = nullptr; + } + } + + inline bool Connection::IsConnected() const + { + return m_disconnectFn && !m_weakSignal.expired(); + } + + inline void ScopedConnection::Disconnect() + { + m_connection.Disconnect(); + } + + inline bool ScopedConnection::IsConnected() const + { + return m_connection.IsConnected(); + } + + inline Connection ScopedConnection::Release() + { + Connection c = std::move(m_connection); + m_connection = Connection{}; + return c; + } +} // namespace cct + +#endif // CONCERTO_CORE_CONNECTION_INL diff --git a/Src/Concerto/Core/Signal/Signal.hpp b/Src/Concerto/Core/Signal/Signal.hpp new file mode 100644 index 0000000..210bb21 --- /dev/null +++ b/Src/Concerto/Core/Signal/Signal.hpp @@ -0,0 +1,89 @@ +// +// Created by arthur on 27/02/2026. +// + +#ifndef CONCERTO_CORE_SIGNAL_HPP +#define CONCERTO_CORE_SIGNAL_HPP + +#include +#include +#include + +#include "Concerto/Core/Signal/Connection.hpp" + +namespace cct +{ + /// Type-safe signal that can be connected to zero or more slots (callbacks). + /// + /// - Lazy allocation: no heap allocation until the first Connect() call. + /// - Move-safe: connections remain valid after a Signal is moved. + /// - Copy-safe: copying a Signal produces a Signal with NO connections (fresh). + /// - Reentrant-safe: disconnecting inside Emit() is deferred until after emission. + /// - Lifetime-safe: Connection::Disconnect() is a no-op if the Signal was destroyed. + /// + /// Usage: + /// Signal onChanged; + /// auto conn = onChanged.Connect([](int v) { ... }); + /// auto conn2 = onChanged.Connect(myObj, &MyClass::OnChanged); + /// onChanged.Emit(42); + /// conn.Disconnect(); + template + class Signal + { + public: + Signal() = default; + + Signal(const Signal&) : + m_state(nullptr) + { + } + Signal& operator=(const Signal&) + { + return *this; // intentionally do not copy connections + } + + Signal(Signal&&) = default; + Signal& operator=(Signal&&) = default; + + ~Signal() = default; + + [[nodiscard]] Connection Connect(std::function slot); + template + [[nodiscard]] Connection Connect(T* obj, void (T::*method)(Args...)); + template + [[nodiscard]] Connection Connect(const T* obj, void (T::*method)(Args...) const); + + void Disconnect(Connection& connection); + void DisconnectAll(); + + void Emit(Args... args) const; + void operator()(Args... args) const; + + [[nodiscard]] std::size_t GetConnectionCount() const; + [[nodiscard]] bool HasConnections() const; + + private: + struct Slot + { + std::size_t id; + std::function fn; + bool active = true; + }; + + struct SlotList + { + std::vector slots; + std::size_t nextId = 0; + int emitDepth = 0; + }; + + void EnsureState() const; + void DisconnectById(std::size_t id) const; + + mutable std::shared_ptr m_state; // null until first Connect() + }; +} // namespace cct + +#include "Concerto/Core/Signal/Signal.inl" + +#endif // CONCERTO_CORE_SIGNAL_HPP diff --git a/Src/Concerto/Core/Signal/Signal.inl b/Src/Concerto/Core/Signal/Signal.inl new file mode 100644 index 0000000..f2f8778 --- /dev/null +++ b/Src/Concerto/Core/Signal/Signal.inl @@ -0,0 +1,151 @@ +// +// Created by arthur on 27/02/2026. +// + +#ifndef CONCERTO_CORE_SIGNAL_INL +#define CONCERTO_CORE_SIGNAL_INL + +#include + +namespace cct +{ + template + void Signal::EnsureState() const + { + if (!m_state) + m_state = std::make_shared(); + } + + template + void Signal::DisconnectById(std::size_t id) const + { + if (!m_state) + return; + SlotList& state = *m_state; + for (Slot& slot : state.slots) + { + if (slot.id != id) + continue; + if (state.emitDepth > 0) + { + // Deferred removal: mark inactive and clean up after Emit() + slot.active = false; + } + else + { + state.slots.erase( + std::remove_if(state.slots.begin(), state.slots.end(), + [id](const Slot& s) { return s.id == id; }), + state.slots.end()); + } + return; + } + } + + template + Connection Signal::Connect(std::function slot) + { + EnsureState(); + const std::size_t id = m_state->nextId++; + m_state->slots.push_back({id, std::move(slot), true}); + + std::weak_ptr weakState = m_state; + std::weak_ptr weakSignal = m_state; + return Connection( + [weakState, id]() + { + if (auto state = weakState.lock()) + { + for (auto& s : state->slots) + { + if (s.id != id) + continue; + if (state->emitDepth > 0) + s.active = false; + else + state->slots.erase( + std::remove_if(state->slots.begin(), state->slots.end(), + [id](const Slot& e) { return e.id == id; }), + state->slots.end()); + return; + } + } + }, + std::move(weakSignal)); + } + + template + template + Connection Signal::Connect(T* obj, void (T::*method)(Args...)) + { + return Connect([obj, method](Args... args) { (obj->*method)(args...); }); + } + + template + template + Connection Signal::Connect(const T* obj, void (T::*method)(Args...) const) + { + return Connect([obj, method](Args... args) { (obj->*method)(args...); }); + } + + template + void Signal::Disconnect(Connection& connection) + { + connection.Disconnect(); + } + + template + void Signal::DisconnectAll() + { + if (m_state) + m_state->slots.clear(); + } + + template + void Signal::Emit(Args... args) const + { + if (!m_state) + return; + + SlotList& state = *m_state; + ++state.emitDepth; + for (const Slot& slot : state.slots) + { + if (slot.active) + slot.fn(args...); + } + --state.emitDepth; + + if (state.emitDepth == 0) + { + state.slots.erase( + std::remove_if(state.slots.begin(), state.slots.end(), + [](const Slot& s) { return !s.active; }), + state.slots.end()); + } + } + + template + void Signal::operator()(Args... args) const + { + Emit(args...); + } + + template + std::size_t Signal::GetConnectionCount() const + { + if (!m_state) + return 0; + return static_cast( + std::count_if(m_state->slots.begin(), m_state->slots.end(), + [](const Slot& s) { return s.active; })); + } + + template + bool Signal::HasConnections() const + { + return GetConnectionCount() > 0; + } +} // namespace cct + +#endif // CONCERTO_CORE_SIGNAL_INL diff --git a/Src/Tests/Signal.cpp b/Src/Tests/Signal.cpp new file mode 100644 index 0000000..2925c8f --- /dev/null +++ b/Src/Tests/Signal.cpp @@ -0,0 +1,303 @@ +// +// Created by arthur on 27/02/2026. +// + +#define CATCH_CONFIG_RUNNER +#include + +#include +#include + +// Helper: tracks how many times a slot was called and captures the last value +struct CallTracker +{ + int callCount = 0; + std::string lastValue; + + void OnChanged() { ++callCount; } + void OnStringChanged(const std::string& value) + { + ++callCount; + lastValue = value; + } +}; + +SCENARIO("Signal - basic connectivity") +{ + GIVEN("A Signal<> with no connections") + { + cct::Signal<> signal; + + THEN("It has no connections") + { + CHECK(signal.GetConnectionCount() == 0); + CHECK_FALSE(signal.HasConnections()); + } + + WHEN("A lambda is connected") + { + int callCount = 0; + auto conn = signal.Connect([&callCount]() { ++callCount; }); + + THEN("GetConnectionCount reflects it") + { + CHECK(signal.GetConnectionCount() == 1); + CHECK(signal.HasConnections()); + CHECK(conn.IsConnected()); + } + + AND_WHEN("The signal is emitted") + { + signal.Emit(); + THEN("The lambda is called once") { CHECK(callCount == 1); } + } + + AND_WHEN("The signal is emitted via operator()") + { + signal(); + THEN("The lambda is called once") { CHECK(callCount == 1); } + } + + AND_WHEN("The connection is manually disconnected") + { + conn.Disconnect(); + signal.Emit(); + THEN("The lambda is NOT called and connection is marked disconnected") + { + CHECK(callCount == 0); + CHECK_FALSE(conn.IsConnected()); + CHECK(signal.GetConnectionCount() == 0); + } + } + + AND_WHEN("DisconnectAll() is called") + { + signal.DisconnectAll(); + signal.Emit(); + THEN("The lambda is NOT called") + { + CHECK(callCount == 0); + CHECK(signal.GetConnectionCount() == 0); + } + } + } + + WHEN("Multiple lambdas are connected") + { + int a = 0, b = 0, c = 0; + auto connA = signal.Connect([&a]() { ++a; }); + auto connB = signal.Connect([&b]() { ++b; }); + auto connC = signal.Connect([&c]() { ++c; }); + + signal.Emit(); + THEN("All three are called") + { + CHECK(a == 1); + CHECK(b == 1); + CHECK(c == 1); + CHECK(signal.GetConnectionCount() == 3); + } + + connB.Disconnect(); + signal.Emit(); + THEN("Only A and C are called after B is disconnected") + { + CHECK(a == 2); + CHECK(b == 1); + CHECK(c == 2); + CHECK(signal.GetConnectionCount() == 2); + } + } + } +} + +SCENARIO("Signal - member function Connect(obj, &Class::Method)") +{ + GIVEN("A Signal<> and a CallTracker instance") + { + cct::Signal<> signal; + CallTracker tracker; + + WHEN("Connected via (obj, method) two-argument form") + { + auto conn = signal.Connect(&tracker, &CallTracker::OnChanged); + + THEN("The method is called on Emit()") + { + signal.Emit(); + CHECK(tracker.callCount == 1); + signal.Emit(); + CHECK(tracker.callCount == 2); + } + + AND_WHEN("Disconnected") + { + conn.Disconnect(); + signal.Emit(); + THEN("The method is NOT called") { CHECK(tracker.callCount == 0); } + } + } + } + + GIVEN("A Signal and a CallTracker") + { + cct::Signal signal; + CallTracker tracker; + + WHEN("Connected via (obj, method) two-argument form") + { + auto conn = signal.Connect(&tracker, &CallTracker::OnStringChanged); + signal.Emit("hello"); + + THEN("The method receives the correct argument") + { + CHECK(tracker.callCount == 1); + CHECK(tracker.lastValue == "hello"); + } + } + } +} + +SCENARIO("Signal - ScopedConnection RAII") +{ + GIVEN("A Signal<> and a call counter") + { + cct::Signal<> signal; + int callCount = 0; + + WHEN("A ScopedConnection goes out of scope") + { + { + cct::ScopedConnection sc{signal.Connect([&callCount]() { ++callCount; })}; + CHECK(sc.IsConnected()); + signal.Emit(); + CHECK(callCount == 1); + } // auto-disconnect + + signal.Emit(); + THEN("The lambda is NOT called after scope exit") + { + CHECK(callCount == 1); + CHECK(signal.GetConnectionCount() == 0); + } + } + + WHEN("A ScopedConnection is moved") + { + cct::ScopedConnection sc1{signal.Connect([&callCount]() { ++callCount; })}; + cct::ScopedConnection sc2 = std::move(sc1); + + CHECK_FALSE(sc1.IsConnected()); + CHECK(sc2.IsConnected()); + + signal.Emit(); + THEN("Only the moved-into connection fires") { CHECK(callCount == 1); } + } + + WHEN("Release() is called on a ScopedConnection") + { + cct::Connection conn; + { + cct::ScopedConnection sc{signal.Connect([&callCount]() { ++callCount; })}; + conn = sc.Release(); + CHECK_FALSE(sc.IsConnected()); + CHECK(conn.IsConnected()); + } // no auto-disconnect + + signal.Emit(); + THEN("The slot is still active after scope exit") { CHECK(callCount == 1); } + + conn.Disconnect(); + signal.Emit(); + THEN("After manual disconnect, slot is gone") { CHECK(callCount == 1); } + } + } +} + +SCENARIO("Signal - reentrancy: disconnect inside Emit()") +{ + GIVEN("A Signal<> with a slot that disconnects itself during emission") + { + cct::Signal<> signal; + int callCount = 0; + cct::Connection selfConn; + + selfConn = signal.Connect([&]() + { + ++callCount; + selfConn.Disconnect(); + }); + int bCount = 0; + signal.Connect([&bCount]() { ++bCount; }); + + signal.Emit(); + THEN("No crash, self-disconnected slot called once, other slot called") + { + CHECK(callCount == 1); + CHECK(bCount == 1); + CHECK(signal.GetConnectionCount() == 1); + } + + signal.Emit(); + THEN("After second emit, only b fires") + { + CHECK(callCount == 1); + CHECK(bCount == 2); + } + } +} + +SCENARIO("Signal - lifetime safety: Signal destroyed before Connection") +{ + GIVEN("A Connection that outlives its Signal") + { + cct::Connection conn; + { + cct::Signal<> signal; + int callCount = 0; + conn = signal.Connect([&callCount]() { ++callCount; }); + CHECK(conn.IsConnected()); + } // signal destroyed here + + THEN("IsConnected() returns false and Disconnect() is a no-op") + { + CHECK_FALSE(conn.IsConnected()); + conn.Disconnect(); // must not crash + } + } +} + +SCENARIO("Signal - copy and move semantics") +{ + GIVEN("A Signal<> with one connection") + { + int callCount = 0; + cct::Signal<> original; + auto conn = original.Connect([&callCount]() { ++callCount; }); + + WHEN("The Signal is copied") + { + cct::Signal<> copy = original; + copy.Emit(); + THEN("The copy has NO connections (fresh)") + { + CHECK(copy.GetConnectionCount() == 0); + CHECK(callCount == 0); + } + + original.Emit(); + THEN("The original still fires its connection") { CHECK(callCount == 1); } + } + + WHEN("The Signal is moved") + { + cct::Signal<> moved = std::move(original); + moved.Emit(); + THEN("The moved signal fires the original connection") + { + CHECK(callCount == 1); + CHECK(conn.IsConnected()); + } + } + } +}