Skip to content

Commit 43d1827

Browse files
authored
Extend EventValue with std::string and double payload types (#6)
Add std::string and double as EventValue alternatives alongside bool and int32_t. Add representative Catch2 coverage for each new type (initial image/snapshot, debounce send-vs-suppress, heartbeat resend, equality-driven suppression), plus a CTest death test proving the documented std::get<TValue> -> std::terminate() crash contract still holds on a genuine type mismatch. WILL_FAIL/PASS_REGULAR_EXPRESSION can't detect a signal-killed subprocess, so the death test runs through run_death_test.sh, which absorbs the signal and reports pass/fail via a normal exit code (POSIX-only, gated by if(UNIX)). Update AGENTS.md and doc/developer-guide.md to reflect the extended variant.
1 parent 0f93e7f commit 43d1827

7 files changed

Lines changed: 232 additions & 3 deletions

File tree

AGENTS.md

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

doc/developer-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ cd cmake-build-debug && ctest --output-on-failure
352352

353353
## Open Design Points
354354

355-
- **Payload types** — currently `EventValue = std::variant<bool, std::int32_t>`.
355+
- **Payload types** — currently `EventValue = std::variant<bool, std::int32_t, double, std::string>`.
356356
Extend the variant if events with other value types are needed.
357357
- **Descriptor capacity**`EventSupervisor`'s internal `kMaxEvents` is a
358358
fixed constant (currently 32); adjust to the real event count for the

include/tickguard/EventValue.hpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#pragma once
22

33
#include <cstdint>
4+
#include <string>
45
#include <variant>
56

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

2526
} // namespace tickguard

tests/CMakeLists.txt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,16 @@ list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/contrib)
2121
include(CTest)
2222
include(Catch)
2323
catch_discover_tests(event_system_tests)
24+
25+
add_executable(event_descriptor_type_mismatch_death_test EventDescriptorTypeMismatchDeathTest.cpp)
26+
target_link_libraries(event_descriptor_type_mismatch_death_test PRIVATE event_system)
27+
28+
if (UNIX)
29+
# CTest reports a subprocess it observes dying to a signal (e.g. SIGABRT from
30+
# std::terminate()) as an unconditional "Exception" failure that neither WILL_FAIL
31+
# nor PASS_REGULAR_EXPRESSION can invert. run_death_test.sh absorbs the signal itself
32+
# and reports success/failure via a normal exit code that CTest can evaluate.
33+
add_test(NAME EventDescriptorTypeMismatchTerminates
34+
COMMAND bash ${CMAKE_CURRENT_SOURCE_DIR}/run_death_test.sh
35+
$<TARGET_FILE:event_descriptor_type_mismatch_death_test>)
36+
endif ()

tests/EventDescriptorTests.cpp

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#define CATCH_CONFIG_MAIN
2+
#include <string>
23
#include <vector>
34

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

33+
class FakeStringSender final : public IEventSender<std::string> {
34+
public:
35+
struct Call {
36+
EventId id;
37+
std::string value;
38+
};
39+
40+
void send(EventId id, std::string value) noexcept override {
41+
calls.push_back({id, std::move(value)});
42+
}
43+
44+
std::vector<Call> calls;
45+
};
46+
47+
class FakeDoubleSender final : public IEventSender<double> {
48+
public:
49+
struct Call {
50+
EventId id;
51+
double value;
52+
};
53+
54+
void send(EventId id, double value) noexcept override {
55+
calls.push_back({id, value});
56+
}
57+
58+
std::vector<Call> calls;
59+
};
60+
3261
} // namespace
3362

3463
TEST_CASE("Initial image is false and no send happens before first tick", "[EventDescriptor]") {
@@ -227,3 +256,143 @@ TEST_CASE("Metrics track triggered, raised, and suppressed counts", "[EventDescr
227256
REQUIRE(event.metrics().raised() == 1);
228257
REQUIRE(event.metrics().suppressed() == 0);
229258
}
259+
260+
TEST_CASE("String payload: initial image is stored and emitSnapshot sends it verbatim", "[EventDescriptor][String]") {
261+
FakeStringSender sender;
262+
EventDescriptor<std::string> event(EventId::NtpAlive1,
263+
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms},
264+
sender, /*initial=*/"unknown");
265+
266+
REQUIRE(event.image() == "unknown");
267+
268+
event.emitSnapshot();
269+
270+
REQUIRE(sender.calls.size() == 1);
271+
REQUIRE(sender.calls[0].id == EventId::NtpAlive1);
272+
REQUIRE(sender.calls[0].value == "unknown");
273+
}
274+
275+
TEST_CASE("String payload: OneShot debounce sends a persisted change, suppresses a flap back to the original",
276+
"[EventDescriptor][String]") {
277+
FakeStringSender sender;
278+
EventDescriptor<std::string> changed(EventId::NtpAlive1,
279+
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
280+
sender, /*initial=*/"v1.2.0");
281+
282+
changed.trigger(EventValue{std::string{"v1.2.1"}});
283+
changed.tick(10000ms);
284+
285+
REQUIRE(sender.calls.size() == 1);
286+
REQUIRE(sender.calls[0].value == "v1.2.1");
287+
288+
FakeStringSender flapSender;
289+
EventDescriptor<std::string> flapped(EventId::NtpAlive1,
290+
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
291+
flapSender, /*initial=*/"v1.2.0");
292+
293+
flapped.trigger(EventValue{std::string{"v1.2.1"}});
294+
flapped.tick(2000ms);
295+
flapped.trigger(EventValue{std::string{"v1.2.0"}}); // reverts to image before delay elapses
296+
flapped.tick(12000ms);
297+
298+
REQUIRE(flapSender.calls.empty());
299+
}
300+
301+
TEST_CASE("String payload: Interval heartbeat resends the same string unconditionally", "[EventDescriptor][String]") {
302+
FakeStringSender sender;
303+
EventDescriptor<std::string> event(EventId::ChannelLifeEthernet0,
304+
EventConfig{.mode = EventMode::Interval, .delay = 10000ms, .interval = 30000ms},
305+
sender, /*initial=*/"unknown");
306+
307+
event.trigger(EventValue{std::string{"host-01"}});
308+
event.tick(10000ms);
309+
REQUIRE(sender.calls.size() == 1);
310+
REQUIRE(sender.calls[0].value == "host-01");
311+
312+
event.tick(40000ms); // no new trigger, heartbeat must still resend
313+
REQUIRE(sender.calls.size() == 2);
314+
REQUIRE(sender.calls[1].value == "host-01");
315+
}
316+
317+
TEST_CASE("String payload: suppression is driven by content equality, not size", "[EventDescriptor][String]") {
318+
FakeStringSender sender;
319+
EventDescriptor<std::string> event(EventId::ChannelLifeEthernet0,
320+
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms},
321+
sender, /*initial=*/"host-1");
322+
323+
event.trigger(EventValue{std::string{"host-01"}}); // same length class, different content -> must send
324+
event.tick(5000ms);
325+
326+
REQUIRE(sender.calls.size() == 1);
327+
REQUIRE(sender.calls[0].value == "host-01");
328+
}
329+
330+
TEST_CASE("Double payload: initial image is stored and emitSnapshot sends it verbatim", "[EventDescriptor][Double]") {
331+
FakeDoubleSender sender;
332+
EventDescriptor<double> event(EventId::NtpAlive1,
333+
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms}, sender,
334+
/*initial=*/0.0);
335+
336+
REQUIRE(event.image() == 0.0);
337+
338+
event.emitSnapshot();
339+
340+
REQUIRE(sender.calls.size() == 1);
341+
REQUIRE(sender.calls[0].id == EventId::NtpAlive1);
342+
REQUIRE(sender.calls[0].value == 0.0);
343+
}
344+
345+
TEST_CASE("Double payload: OneShot debounce sends a persisted change, suppresses a flap back to the original",
346+
"[EventDescriptor][Double]") {
347+
FakeDoubleSender sender;
348+
EventDescriptor<double> changed(EventId::NtpAlive1,
349+
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms}, sender,
350+
/*initial=*/21.5);
351+
352+
changed.trigger(EventValue{22.0});
353+
changed.tick(10000ms);
354+
355+
REQUIRE(sender.calls.size() == 1);
356+
REQUIRE(sender.calls[0].value == 22.0);
357+
358+
FakeDoubleSender flapSender;
359+
EventDescriptor<double> flapped(EventId::NtpAlive1,
360+
EventConfig{.mode = EventMode::OneShot, .delay = 10000ms, .interval = 1000ms},
361+
flapSender, /*initial=*/21.5);
362+
363+
flapped.trigger(EventValue{22.0});
364+
flapped.tick(2000ms);
365+
flapped.trigger(EventValue{21.5}); // reverts to image before delay elapses
366+
flapped.tick(12000ms);
367+
368+
REQUIRE(flapSender.calls.empty());
369+
}
370+
371+
TEST_CASE("Double payload: Interval heartbeat resends the same value unconditionally", "[EventDescriptor][Double]") {
372+
FakeDoubleSender sender;
373+
EventDescriptor<double> event(EventId::ChannelLifeEthernet0,
374+
EventConfig{.mode = EventMode::Interval, .delay = 10000ms, .interval = 30000ms}, sender,
375+
/*initial=*/0.0);
376+
377+
event.trigger(EventValue{36.6});
378+
event.tick(10000ms);
379+
REQUIRE(sender.calls.size() == 1);
380+
REQUIRE(sender.calls[0].value == 36.6);
381+
382+
event.tick(40000ms); // no new trigger, heartbeat must still resend
383+
REQUIRE(sender.calls.size() == 2);
384+
REQUIRE(sender.calls[1].value == 36.6);
385+
}
386+
387+
TEST_CASE("Double payload: suppression is driven by exact equality", "[EventDescriptor][Double]") {
388+
FakeDoubleSender sender;
389+
EventDescriptor<double> event(EventId::ChannelLifeEthernet0,
390+
EventConfig{.mode = EventMode::OneShot, .delay = 5000ms, .interval = 1000ms}, sender,
391+
/*initial=*/1.0);
392+
393+
event.trigger(EventValue{1.1}); // close but distinct -> must send
394+
event.tick(5000ms);
395+
396+
REQUIRE(sender.calls.size() == 1);
397+
REQUIRE(sender.calls[0].value == 1.1);
398+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
#include <chrono>
2+
#include <string>
3+
4+
#include "tickguard/EventDescriptor.hpp"
5+
6+
using namespace std::chrono_literals;
7+
8+
// Standalone (no Catch2) executable: feeding an EventDescriptor<bool> an EventValue
9+
// holding a std::string must terminate the process via std::get<TValue>'s hard-crash
10+
// contract (see EventValue.hpp). Registered as a CTest death test in
11+
// tests/CMakeLists.txt, which passes based on the crash message it prints.
12+
int main() {
13+
class NullSender final : public tickguard::IEventSender<bool> {
14+
public:
15+
void send(tickguard::EventId, bool) noexcept override {}
16+
};
17+
18+
NullSender sender;
19+
tickguard::EventDescriptor<bool> event(
20+
tickguard::EventId::NtpAlive1,
21+
tickguard::EventConfig{.mode = tickguard::EventMode::OneShot, .delay = 1000ms, .interval = 1000ms}, sender,
22+
/*initial=*/false);
23+
24+
event.trigger(tickguard::EventValue{std::string{"mismatched"}});
25+
26+
return 0; // Unreachable if the crash contract holds.
27+
}

tests/run_death_test.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
# Runs a "death test" binary and reports success iff it was killed by a signal
3+
# (e.g. SIGABRT from std::terminate()). CTest treats a subprocess it observes
4+
# dying to a signal as an unconditional "Exception" failure, ignoring
5+
# WILL_FAIL/PASS_REGULAR_EXPRESSION — so this wrapper absorbs the signal
6+
# itself and turns the outcome into a normal (non-signal) exit code that
7+
# CTest can evaluate as pass/fail.
8+
set -u
9+
10+
"$@"
11+
status=$?
12+
13+
if [ "$status" -gt 128 ]; then
14+
echo "death test binary terminated via signal $((status - 128)) as expected"
15+
exit 0
16+
fi
17+
18+
echo "death test binary exited with status $status instead of being killed by a signal"
19+
exit 1

0 commit comments

Comments
 (0)