Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ C++20 **header-only** library. All logic lives in `include/tickguard/`. Namespac
| `IEventSender.hpp` | `IEventSender<TValue>` — implement this to route events to any transport. The descriptor never knows the receiver. |
| `EventConfig.hpp` | `EventMode::{OneShot, Interval}` + `{delay, interval}`. |
| `EventId.hpp` | `enum class EventId : uint16_t` — append before `Count` to add IDs. |
| `EventValue.hpp` | `std::variant<bool, int32_t>` — type-erased payload for `Supervisor::trigger()`. |
| `EventValue.hpp` | `std::variant<bool, int32_t, double, std::string>` — type-erased payload for `Supervisor::trigger()`. |
| `EventMetrics.hpp` | Atomic `triggered / raised / suppressed` counters per descriptor. |
| `SpinLock.hpp` | Minimal `std::atomic_flag`-based spinlock guarding each descriptor's internal state (short critical sections only). |

Expand Down
2 changes: 1 addition & 1 deletion doc/developer-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ cd cmake-build-debug && ctest --output-on-failure

## Open Design Points

- **Payload types** — currently `EventValue = std::variant<bool, std::int32_t>`.
- **Payload types** — currently `EventValue = std::variant<bool, std::int32_t, double, std::string>`.
Extend the variant if events with other value types are needed.
- **Descriptor capacity** — `EventSupervisor`'s internal `kMaxEvents` is a
fixed constant (currently 32); adjust to the real event count for the
Expand Down
3 changes: 2 additions & 1 deletion include/tickguard/EventValue.hpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#pragma once

#include <cstdint>
#include <string>
#include <variant>

namespace tickguard {
Expand All @@ -20,6 +21,6 @@ namespace tickguard {
* boundary) — callers must ensure the alternative matches the declared
* `TValue` for the given `EventId`.
*/
using EventValue = std::variant<bool, std::int32_t>;
using EventValue = std::variant<bool, std::int32_t, double, std::string>;

} // namespace tickguard
13 changes: 13 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,16 @@ list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/contrib)
include(CTest)
include(Catch)
catch_discover_tests(event_system_tests)

add_executable(event_descriptor_type_mismatch_death_test EventDescriptorTypeMismatchDeathTest.cpp)
target_link_libraries(event_descriptor_type_mismatch_death_test PRIVATE event_system)

if (UNIX)
# CTest reports a subprocess it observes dying to a signal (e.g. SIGABRT from
# std::terminate()) as an unconditional "Exception" failure that neither WILL_FAIL
# nor PASS_REGULAR_EXPRESSION can invert. run_death_test.sh absorbs the signal itself
# and reports success/failure via a normal exit code that CTest can evaluate.
add_test(NAME EventDescriptorTypeMismatchTerminates
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/run_death_test.sh
$<TARGET_FILE:event_descriptor_type_mismatch_death_test>)
endif ()
169 changes: 169 additions & 0 deletions tests/EventDescriptorTests.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#define CATCH_CONFIG_MAIN
#include <string>
#include <vector>

#include <catch2/catch.hpp>
Expand Down Expand Up @@ -29,6 +30,34 @@ class FakeSender final : public IEventSender<bool> {
std::vector<Call> calls;
};

class FakeStringSender final : public IEventSender<std::string> {
public:
struct Call {
EventId id;
std::string value;
};

void send(EventId id, std::string value) noexcept override {
calls.push_back({id, std::move(value)});
}

std::vector<Call> calls;
};

class FakeDoubleSender final : public IEventSender<double> {
public:
struct Call {
EventId id;
double value;
};

void send(EventId id, double value) noexcept override {
calls.push_back({id, value});
}

std::vector<Call> calls;
};

} // namespace

TEST_CASE("Initial image is false and no send happens before first tick", "[EventDescriptor]") {
Expand Down Expand Up @@ -227,3 +256,143 @@ TEST_CASE("Metrics track triggered, raised, and suppressed counts", "[EventDescr
REQUIRE(event.metrics().raised() == 1);
REQUIRE(event.metrics().suppressed() == 0);
}

TEST_CASE("String payload: initial image is stored and emitSnapshot sends it verbatim", "[EventDescriptor][String]") {
FakeStringSender sender;
EventDescriptor<std::string> event(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms},
sender, /*initial=*/"unknown");

REQUIRE(event.image() == "unknown");

event.emitSnapshot();

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].id == EventId::NtpAlive1);
REQUIRE(sender.calls[0].value == "unknown");
}

TEST_CASE("String payload: OneShot debounce sends a persisted change, suppresses a flap back to the original",
"[EventDescriptor][String]") {
FakeStringSender sender;
EventDescriptor<std::string> changed(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
sender, /*initial=*/"v1.2.0");

changed.trigger(EventValue{std::string{"v1.2.1"}});
changed.tick(10000ms);

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == "v1.2.1");

FakeStringSender flapSender;
EventDescriptor<std::string> flapped(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
flapSender, /*initial=*/"v1.2.0");

flapped.trigger(EventValue{std::string{"v1.2.1"}});
flapped.tick(2000ms);
flapped.trigger(EventValue{std::string{"v1.2.0"}}); // reverts to image before delay elapses
flapped.tick(12000ms);

REQUIRE(flapSender.calls.empty());
}

TEST_CASE("String payload: Interval heartbeat resends the same string unconditionally", "[EventDescriptor][String]") {
FakeStringSender sender;
EventDescriptor<std::string> event(EventId::ChannelLifeEthernet0,
EventConfig{.mode = EventMode::Interval, .delay = 10000ms, .interval = 30000ms},
sender, /*initial=*/"unknown");

event.trigger(EventValue{std::string{"host-01"}});
event.tick(10000ms);
REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == "host-01");

event.tick(40000ms); // no new trigger, heartbeat must still resend
REQUIRE(sender.calls.size() == 2);
REQUIRE(sender.calls[1].value == "host-01");
}

TEST_CASE("String payload: suppression is driven by content equality, not size", "[EventDescriptor][String]") {
FakeStringSender sender;
EventDescriptor<std::string> event(EventId::ChannelLifeEthernet0,
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms},
sender, /*initial=*/"host-1");

event.trigger(EventValue{std::string{"host-01"}}); // same length class, different content -> must send
event.tick(5000ms);

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == "host-01");
}

TEST_CASE("Double payload: initial image is stored and emitSnapshot sends it verbatim", "[EventDescriptor][Double]") {
FakeDoubleSender sender;
EventDescriptor<double> event(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms}, sender,
/*initial=*/0.0);

REQUIRE(event.image() == 0.0);

event.emitSnapshot();

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].id == EventId::NtpAlive1);
REQUIRE(sender.calls[0].value == 0.0);
}

TEST_CASE("Double payload: OneShot debounce sends a persisted change, suppresses a flap back to the original",
"[EventDescriptor][Double]") {
FakeDoubleSender sender;
EventDescriptor<double> changed(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms}, sender,
/*initial=*/21.5);

changed.trigger(EventValue{22.0});
changed.tick(10000ms);

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == 22.0);

FakeDoubleSender flapSender;
EventDescriptor<double> flapped(EventId::NtpAlive1,
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
flapSender, /*initial=*/21.5);

flapped.trigger(EventValue{22.0});
flapped.tick(2000ms);
flapped.trigger(EventValue{21.5}); // reverts to image before delay elapses
flapped.tick(12000ms);

REQUIRE(flapSender.calls.empty());
}

TEST_CASE("Double payload: Interval heartbeat resends the same value unconditionally", "[EventDescriptor][Double]") {
FakeDoubleSender sender;
EventDescriptor<double> event(EventId::ChannelLifeEthernet0,
EventConfig{.mode = EventMode::Interval, .delay = 10000ms, .interval = 30000ms}, sender,
/*initial=*/0.0);

event.trigger(EventValue{36.6});
event.tick(10000ms);
REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == 36.6);

event.tick(40000ms); // no new trigger, heartbeat must still resend
REQUIRE(sender.calls.size() == 2);
REQUIRE(sender.calls[1].value == 36.6);
}

TEST_CASE("Double payload: suppression is driven by exact equality", "[EventDescriptor][Double]") {
FakeDoubleSender sender;
EventDescriptor<double> event(EventId::ChannelLifeEthernet0,
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms}, sender,
/*initial=*/1.0);

event.trigger(EventValue{1.1}); // close but distinct -> must send
event.tick(5000ms);

REQUIRE(sender.calls.size() == 1);
REQUIRE(sender.calls[0].value == 1.1);
}
27 changes: 27 additions & 0 deletions tests/EventDescriptorTypeMismatchDeathTest.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#include <chrono>
#include <string>

#include "tickguard/EventDescriptor.hpp"

using namespace std::chrono_literals;

// Standalone (no Catch2) executable: feeding an EventDescriptor<bool> an EventValue
// holding a std::string must terminate the process via std::get<TValue>'s hard-crash
// contract (see EventValue.hpp). Registered as a CTest death test in
// tests/CMakeLists.txt, which passes based on the crash message it prints.
int main() {
class NullSender final : public tickguard::IEventSender<bool> {
public:
void send(tickguard::EventId, bool) noexcept override {}
};

NullSender sender;
tickguard::EventDescriptor<bool> event(
tickguard::EventId::NtpAlive1,
tickguard::EventConfig{.mode = tickguard::EventMode::OneShot, .delay = 1000ms, .interval = 1000ms}, sender,
/*initial=*/false);

event.trigger(tickguard::EventValue{std::string{"mismatched"}});

return 0; // Unreachable if the crash contract holds.
}
19 changes: 19 additions & 0 deletions tests/run_death_test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Runs a "death test" binary and reports success iff it was killed by a signal
# (e.g. SIGABRT from std::terminate()). CTest treats a subprocess it observes
# dying to a signal as an unconditional "Exception" failure, ignoring
# WILL_FAIL/PASS_REGULAR_EXPRESSION — so this wrapper absorbs the signal
# itself and turns the outcome into a normal (non-signal) exit code that
# CTest can evaluate as pass/fail.
set -u

"$@"
status=$?

if [ "$status" -gt 128 ]; then
echo "death test binary terminated via signal $((status - 128)) as expected"
exit 0
fi

echo "death test binary exited with status $status instead of being killed by a signal"
exit 1
Loading