Monitoring System 0.1.0
System resource monitoring with pluggable collectors and alerting
Loading...
Searching...
No Matches
test_alert_triggers.cpp
Go to the documentation of this file.
1// BSD 3-Clause License
2// Copyright (c) 2021-2025, 🍀☀🌕🌥 🌊
3// See the LICENSE file in the project root for full license information.
4
18#include <gtest/gtest.h>
20
21#include <chrono>
22#include <cmath>
23#include <memory>
24#include <thread>
25#include <vector>
26
27using namespace kcenon::monitoring;
28using namespace std::chrono_literals;
29
30// =============================================================================
31// threshold_trigger Tests
32// =============================================================================
33
34class ThresholdTriggerTest : public ::testing::Test {
35protected:
36 std::shared_ptr<threshold_trigger> trigger_;
37};
38
40 trigger_ = std::make_shared<threshold_trigger>(80.0, comparison_operator::greater_than);
41 EXPECT_TRUE(trigger_->evaluate(81.0));
42 EXPECT_FALSE(trigger_->evaluate(80.0));
43 EXPECT_FALSE(trigger_->evaluate(79.0));
44}
45
46TEST_F(ThresholdTriggerTest, GreaterOrEqual) {
47 trigger_ = std::make_shared<threshold_trigger>(80.0, comparison_operator::greater_or_equal);
48 EXPECT_TRUE(trigger_->evaluate(81.0));
49 EXPECT_TRUE(trigger_->evaluate(80.0));
50 EXPECT_FALSE(trigger_->evaluate(79.0));
51}
52
54 trigger_ = std::make_shared<threshold_trigger>(20.0, comparison_operator::less_than);
55 EXPECT_TRUE(trigger_->evaluate(19.0));
56 EXPECT_FALSE(trigger_->evaluate(20.0));
57 EXPECT_FALSE(trigger_->evaluate(21.0));
58}
59
61 trigger_ = std::make_shared<threshold_trigger>(20.0, comparison_operator::less_or_equal);
62 EXPECT_TRUE(trigger_->evaluate(19.0));
63 EXPECT_TRUE(trigger_->evaluate(20.0));
64 EXPECT_FALSE(trigger_->evaluate(21.0));
65}
66
68 trigger_ = std::make_shared<threshold_trigger>(50.0, comparison_operator::equal);
69 EXPECT_TRUE(trigger_->evaluate(50.0));
70 EXPECT_TRUE(trigger_->evaluate(50.0 + 1e-10)); // Within epsilon
71 EXPECT_FALSE(trigger_->evaluate(50.1));
72}
73
75 trigger_ = std::make_shared<threshold_trigger>(50.0, comparison_operator::not_equal);
76 EXPECT_FALSE(trigger_->evaluate(50.0));
77 EXPECT_TRUE(trigger_->evaluate(50.1));
78 EXPECT_TRUE(trigger_->evaluate(49.9));
79}
80
82 auto t = threshold_trigger::above(80.0);
83 EXPECT_TRUE(t->evaluate(81.0));
84 EXPECT_FALSE(t->evaluate(80.0));
85 EXPECT_EQ(t->threshold(), 80.0);
86 EXPECT_EQ(t->op(), comparison_operator::greater_than);
87}
88
90 auto t = threshold_trigger::below(20.0);
91 EXPECT_TRUE(t->evaluate(19.0));
92 EXPECT_FALSE(t->evaluate(20.0));
93}
94
95TEST_F(ThresholdTriggerTest, FactoryAboveOrEqual) {
97 EXPECT_TRUE(t->evaluate(80.0));
98 EXPECT_FALSE(t->evaluate(79.9));
99}
100
101TEST_F(ThresholdTriggerTest, FactoryBelowOrEqual) {
103 EXPECT_TRUE(t->evaluate(20.0));
104 EXPECT_FALSE(t->evaluate(20.1));
105}
106
108 trigger_ = threshold_trigger::above(80.0);
109 EXPECT_EQ(trigger_->type_name(), "threshold");
110}
111
113 trigger_ = threshold_trigger::above(80.0);
114 auto desc = trigger_->description();
115 EXPECT_FALSE(desc.empty());
116 EXPECT_NE(desc.find(">"), std::string::npos);
117}
118
119TEST_F(ThresholdTriggerTest, NegativeThreshold) {
120 trigger_ = threshold_trigger::below(-10.0);
121 EXPECT_TRUE(trigger_->evaluate(-11.0));
122 EXPECT_FALSE(trigger_->evaluate(-9.0));
123}
124
126 trigger_ = threshold_trigger::above(0.0);
127 EXPECT_TRUE(trigger_->evaluate(0.001));
128 EXPECT_FALSE(trigger_->evaluate(0.0));
129 EXPECT_FALSE(trigger_->evaluate(-1.0));
130}
131
132// =============================================================================
133// range_trigger Tests
134// =============================================================================
135
136class RangeTriggerTest : public ::testing::Test {};
137
139 auto trigger = std::make_shared<range_trigger>(10.0, 90.0, true);
140 EXPECT_TRUE(trigger->evaluate(50.0));
141 EXPECT_TRUE(trigger->evaluate(10.0)); // inclusive
142 EXPECT_TRUE(trigger->evaluate(90.0)); // inclusive
143 EXPECT_FALSE(trigger->evaluate(9.9));
144 EXPECT_FALSE(trigger->evaluate(90.1));
145}
146
147TEST_F(RangeTriggerTest, OutsideRange) {
148 auto trigger = std::make_shared<range_trigger>(10.0, 90.0, false);
149 EXPECT_TRUE(trigger->evaluate(5.0));
150 EXPECT_TRUE(trigger->evaluate(95.0));
151 EXPECT_FALSE(trigger->evaluate(50.0));
152 EXPECT_FALSE(trigger->evaluate(10.0));
153 EXPECT_FALSE(trigger->evaluate(90.0));
154}
155
156TEST_F(RangeTriggerTest, FactoryInRange) {
157 auto trigger = threshold_trigger::in_range(20.0, 80.0);
158 EXPECT_TRUE(trigger->evaluate(50.0));
159 EXPECT_FALSE(trigger->evaluate(10.0));
160}
161
162TEST_F(RangeTriggerTest, FactoryOutOfRange) {
163 auto trigger = threshold_trigger::out_of_range(20.0, 80.0);
164 EXPECT_TRUE(trigger->evaluate(10.0));
165 EXPECT_TRUE(trigger->evaluate(90.0));
166 EXPECT_FALSE(trigger->evaluate(50.0));
167}
168
169TEST_F(RangeTriggerTest, TypeNameAndDescription) {
170 auto trigger = std::make_shared<range_trigger>(10.0, 90.0, true);
171 EXPECT_EQ(trigger->type_name(), "range");
172 auto desc = trigger->description();
173 EXPECT_NE(desc.find("in"), std::string::npos);
174}
175
176TEST_F(RangeTriggerTest, OutsideRangeDescription) {
177 auto trigger = std::make_shared<range_trigger>(10.0, 90.0, false);
178 auto desc = trigger->description();
179 EXPECT_NE(desc.find("outside"), std::string::npos);
180}
181
182// =============================================================================
183// rate_of_change_trigger Tests
184// =============================================================================
185
186class RateOfChangeTriggerTest : public ::testing::Test {};
187
188TEST_F(RateOfChangeTriggerTest, InsufficientSamplesDoesNotFire) {
189 auto trigger = std::make_shared<rate_of_change_trigger>(
190 10.0, 1000ms, rate_of_change_trigger::rate_direction::either, 3);
191 // Only 1 sample
192 EXPECT_FALSE(trigger->evaluate(50.0));
193 // Only 2 samples
194 EXPECT_FALSE(trigger->evaluate(60.0));
195}
196
197TEST_F(RateOfChangeTriggerTest, IncreasingRateDetected) {
198 auto trigger = std::make_shared<rate_of_change_trigger>(
199 5.0, 1000ms, rate_of_change_trigger::rate_direction::increasing, 2);
200
201 trigger->evaluate(0.0);
202 std::this_thread::sleep_for(10ms);
203 // Large jump should produce high positive rate
204 bool fired = trigger->evaluate(100.0);
205 // Rate depends on timing, but a jump of 100 in ~10ms over a 1s window
206 // should yield a very high rate
207 EXPECT_TRUE(fired);
208}
209
210TEST_F(RateOfChangeTriggerTest, DecreasingDirection) {
211 auto trigger = std::make_shared<rate_of_change_trigger>(
212 5.0, 1000ms, rate_of_change_trigger::rate_direction::decreasing, 2);
213
214 trigger->evaluate(100.0);
215 std::this_thread::sleep_for(10ms);
216 bool fired = trigger->evaluate(0.0);
217 EXPECT_TRUE(fired);
218}
219
221 auto trigger = std::make_shared<rate_of_change_trigger>(
222 5.0, 1000ms, rate_of_change_trigger::rate_direction::either, 2);
223
224 trigger->evaluate(50.0);
225 std::this_thread::sleep_for(10ms);
226 // Large change in either direction
227 EXPECT_TRUE(trigger->evaluate(200.0));
228}
229
231 auto trigger = std::make_shared<rate_of_change_trigger>(
232 5.0, 1000ms, rate_of_change_trigger::rate_direction::either, 2);
233
234 trigger->evaluate(0.0);
235 trigger->evaluate(100.0);
236 trigger->reset();
237
238 // After reset, insufficient samples again
239 EXPECT_FALSE(trigger->evaluate(50.0));
240}
241
242TEST_F(RateOfChangeTriggerTest, TypeNameAndDescription) {
243 auto trigger = std::make_shared<rate_of_change_trigger>(
244 10.0, 1000ms, rate_of_change_trigger::rate_direction::increasing);
245 EXPECT_EQ(trigger->type_name(), "rate_of_change");
246 EXPECT_FALSE(trigger->description().empty());
247}
248
249// =============================================================================
250// anomaly_trigger Tests
251// =============================================================================
252
253class AnomalyTriggerTest : public ::testing::Test {};
254
255TEST_F(AnomalyTriggerTest, InsufficientSamplesDoesNotFire) {
256 auto trigger = std::make_shared<anomaly_trigger>(3.0, 100, 10);
257 // Feed fewer than min_samples
258 for (int i = 0; i < 9; ++i) {
259 EXPECT_FALSE(trigger->evaluate(50.0));
260 }
261}
262
263TEST_F(AnomalyTriggerTest, NormalValuesDoNotFire) {
264 auto trigger = std::make_shared<anomaly_trigger>(3.0, 100, 10);
265 // Feed stable values
266 for (int i = 0; i < 50; ++i) {
267 EXPECT_FALSE(trigger->evaluate(50.0 + (i % 3) * 0.1));
268 }
269}
270
271TEST_F(AnomalyTriggerTest, AnomalousValueFires) {
272 auto trigger = std::make_shared<anomaly_trigger>(2.0, 100, 10);
273 // Build up history with values around 50
274 for (int i = 0; i < 20; ++i) {
275 trigger->evaluate(50.0 + (i % 2 == 0 ? 0.5 : -0.5));
276 }
277 // Now inject a value far from the mean
278 bool fired = trigger->evaluate(200.0);
279 EXPECT_TRUE(fired);
280}
281
283 auto trigger = std::make_shared<anomaly_trigger>(3.0, 100, 5);
284 for (int i = 0; i < 10; ++i) {
285 trigger->evaluate(10.0);
286 }
287 EXPECT_NEAR(trigger->current_mean(), 10.0, 0.01);
288 EXPECT_NEAR(trigger->current_stddev(), 0.0, 0.01);
289}
290
292 auto trigger = std::make_shared<anomaly_trigger>(3.0, 100, 10);
293 for (int i = 0; i < 20; ++i) {
294 trigger->evaluate(50.0);
295 }
296 trigger->reset();
297 // After reset, should need min_samples again
298 EXPECT_FALSE(trigger->evaluate(200.0));
299}
300
301TEST_F(AnomalyTriggerTest, ZeroStddevDoesNotFire) {
302 // When all values are the same, stddev = 0, should not fire (avoid division by zero)
303 auto trigger = std::make_shared<anomaly_trigger>(3.0, 100, 5);
304 for (int i = 0; i < 10; ++i) {
305 trigger->evaluate(50.0);
306 }
307 // Evaluating the same value keeps stddev at 0, guard should return false
308 EXPECT_FALSE(trigger->evaluate(50.0));
309}
310
311TEST_F(AnomalyTriggerTest, TypeNameAndDescription) {
312 auto trigger = std::make_shared<anomaly_trigger>(3.0);
313 EXPECT_EQ(trigger->type_name(), "anomaly");
314 EXPECT_NE(trigger->description().find("std devs"), std::string::npos);
315}
316
317// =============================================================================
318// composite_trigger Tests
319// =============================================================================
320
321class CompositeTriggerTest : public ::testing::Test {
322protected:
323 std::shared_ptr<threshold_trigger> high_ = threshold_trigger::above(80.0);
324 std::shared_ptr<threshold_trigger> low_ = threshold_trigger::below(20.0);
325};
326
328 auto composite = composite_trigger::all_of({high_, low_});
329 // Single value evaluated against both: 90 > 80 (true) but 90 < 20 (false)
330 EXPECT_FALSE(composite->evaluate(90.0));
331}
332
333TEST_F(CompositeTriggerTest, AndEvaluateMulti) {
334 auto composite = composite_trigger::all_of({high_, low_});
335 // 90 > 80 (true), 10 < 20 (true) => AND = true
336 EXPECT_TRUE(composite->evaluate_multi({90.0, 10.0}));
337 // 90 > 80 (true), 30 < 20 (false) => AND = false
338 EXPECT_FALSE(composite->evaluate_multi({90.0, 30.0}));
339}
340
342 auto composite = composite_trigger::any_of({high_, low_});
343 // 90 > 80 = true, so OR = true
344 EXPECT_TRUE(composite->evaluate(90.0));
345 // 10 < 20 = true, 10 > 80 = false, but OR = true
346 EXPECT_TRUE(composite->evaluate(10.0));
347 // 50: not > 80 and not < 20
348 EXPECT_FALSE(composite->evaluate(50.0));
349}
350
352 auto composite = std::make_shared<composite_trigger>(
353 composite_operation::XOR,
354 std::vector<std::shared_ptr<alert_trigger>>{high_, low_});
355
356 // 90: high fires (true), low doesn't (false) => XOR = true (exactly 1)
357 EXPECT_TRUE(composite->evaluate(90.0));
358 // 50: neither fires => XOR = false
359 EXPECT_FALSE(composite->evaluate(50.0));
360}
361
362TEST_F(CompositeTriggerTest, XorBothTrueIsFalse) {
363 auto composite = std::make_shared<composite_trigger>(
364 composite_operation::XOR,
365 std::vector<std::shared_ptr<alert_trigger>>{high_, low_});
366
367 // Both true via evaluate_multi => XOR = false
368 EXPECT_FALSE(composite->evaluate_multi({90.0, 10.0}));
369}
370
372 auto composite = composite_trigger::invert(high_);
373 // 90 > 80 = true, NOT = false
374 EXPECT_FALSE(composite->evaluate(90.0));
375 // 50 > 80 = false, NOT = true
376 EXPECT_TRUE(composite->evaluate(50.0));
377}
378
379TEST_F(CompositeTriggerTest, EmptyTriggersIsFalse) {
380 auto composite = std::make_shared<composite_trigger>(
381 composite_operation::AND,
382 std::vector<std::shared_ptr<alert_trigger>>{});
383 EXPECT_FALSE(composite->evaluate(50.0));
384}
385
386TEST_F(CompositeTriggerTest, EvaluateMultiFewerValuesThanTriggers) {
387 auto composite = composite_trigger::all_of({high_, low_});
388 // Only one value provided: last value repeated for missing triggers
389 // 90 > 80 (true), 90 < 20 (false) => AND = false
390 EXPECT_FALSE(composite->evaluate_multi({90.0}));
391}
392
393TEST_F(CompositeTriggerTest, TriggersAccessor) {
394 auto composite = composite_trigger::all_of({high_, low_});
395 EXPECT_EQ(composite->triggers().size(), 2u);
396}
397
398TEST_F(CompositeTriggerTest, TypeNameAndDescription) {
399 auto composite = composite_trigger::all_of({high_, low_});
400 EXPECT_EQ(composite->type_name(), "composite");
401 auto desc = composite->description();
402 EXPECT_NE(desc.find("AND"), std::string::npos);
403}
404
405TEST_F(CompositeTriggerTest, NotDescription) {
406 auto composite = composite_trigger::invert(high_);
407 auto desc = composite->description();
408 EXPECT_NE(desc.find("NOT"), std::string::npos);
409}
410
411// =============================================================================
412// absent_trigger Tests
413// =============================================================================
414
415class AbsentTriggerTest : public ::testing::Test {};
416
417TEST_F(AbsentTriggerTest, FirstEvaluationDoesNotFire) {
418 auto trigger = std::make_shared<absent_trigger>(100ms);
419 EXPECT_FALSE(trigger->evaluate(1.0));
420}
421
422TEST_F(AbsentTriggerTest, QuickSecondEvaluationDoesNotFire) {
423 auto trigger = std::make_shared<absent_trigger>(100ms);
424 trigger->evaluate(1.0);
425 // Immediately evaluate again - gap is tiny
426 EXPECT_FALSE(trigger->evaluate(2.0));
427}
428
429TEST_F(AbsentTriggerTest, GapExceedingDurationFires) {
430 auto trigger = std::make_shared<absent_trigger>(50ms);
431 trigger->evaluate(1.0);
432 std::this_thread::sleep_for(60ms);
433 EXPECT_TRUE(trigger->evaluate(2.0));
434}
435
436TEST_F(AbsentTriggerTest, ResetClearsState) {
437 auto trigger = std::make_shared<absent_trigger>(50ms);
438 trigger->evaluate(1.0);
439 trigger->reset();
440 // After reset, first evaluation again
441 EXPECT_FALSE(trigger->evaluate(2.0));
442}
443
444TEST_F(AbsentTriggerTest, TypeNameAndDescription) {
445 auto trigger = std::make_shared<absent_trigger>(5000ms);
446 EXPECT_EQ(trigger->type_name(), "absent");
447 auto desc = trigger->description();
448 EXPECT_NE(desc.find("no data"), std::string::npos);
449}
450
451// =============================================================================
452// delta_trigger Tests
453// =============================================================================
454
455class DeltaTriggerTest : public ::testing::Test {};
456
457TEST_F(DeltaTriggerTest, FirstEvaluationDoesNotFire) {
458 auto trigger = std::make_shared<delta_trigger>(10.0);
459 EXPECT_FALSE(trigger->evaluate(50.0));
460}
461
462TEST_F(DeltaTriggerTest, AbsoluteSmallChangeDoesNotFire) {
463 auto trigger = std::make_shared<delta_trigger>(10.0, true);
464 trigger->evaluate(50.0);
465 EXPECT_FALSE(trigger->evaluate(55.0)); // |5| <= 10
466}
467
468TEST_F(DeltaTriggerTest, AbsoluteLargeChangeFires) {
469 auto trigger = std::make_shared<delta_trigger>(10.0, true);
470 trigger->evaluate(50.0);
471 EXPECT_TRUE(trigger->evaluate(70.0)); // |20| > 10
472}
473
474TEST_F(DeltaTriggerTest, AbsoluteNegativeChangeFires) {
475 auto trigger = std::make_shared<delta_trigger>(10.0, true);
476 trigger->evaluate(50.0);
477 EXPECT_TRUE(trigger->evaluate(30.0)); // |-20| > 10
478}
479
480TEST_F(DeltaTriggerTest, SignedPositiveChangeOnly) {
481 auto trigger = std::make_shared<delta_trigger>(10.0, false);
482 trigger->evaluate(50.0);
483 // Decrease: delta = -20, not > 10
484 EXPECT_FALSE(trigger->evaluate(30.0));
485 // Increase: delta = 40, > 10
486 EXPECT_TRUE(trigger->evaluate(70.0));
487}
488
489TEST_F(DeltaTriggerTest, ContinuousTracking) {
490 auto trigger = std::make_shared<delta_trigger>(5.0, true);
491 trigger->evaluate(10.0); // first - no fire
492 trigger->evaluate(12.0); // |2| <= 5 - no fire
493 EXPECT_FALSE(trigger->evaluate(14.0)); // |2| <= 5
494 EXPECT_TRUE(trigger->evaluate(25.0)); // |11| > 5
495 EXPECT_FALSE(trigger->evaluate(27.0)); // |2| <= 5 (relative to 25)
496}
497
499 auto trigger = std::make_shared<delta_trigger>(5.0, true);
500 trigger->evaluate(10.0);
501 trigger->evaluate(20.0);
502 trigger->reset();
503 // After reset, first evaluation again
504 EXPECT_FALSE(trigger->evaluate(100.0));
505}
506
507TEST_F(DeltaTriggerTest, TypeNameAndDescription) {
508 auto trigger = std::make_shared<delta_trigger>(10.0, true);
509 EXPECT_EQ(trigger->type_name(), "delta");
510 auto desc = trigger->description();
511 EXPECT_NE(desc.find("delta"), std::string::npos);
512}
513
514TEST_F(DeltaTriggerTest, SignedDescription) {
515 auto trigger = std::make_shared<delta_trigger>(10.0, false);
516 auto desc = trigger->description();
517 // Should NOT contain "|delta|" for signed mode
518 EXPECT_EQ(desc.find("|delta|"), std::string::npos);
519}
520
521// =============================================================================
522// comparison_operator string conversion Tests
523// =============================================================================
524
525TEST(ComparisonOperatorTest, AllOperatorsHaveStrings) {
526 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::greater_than), ">");
527 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::greater_or_equal), ">=");
528 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::less_than), "<");
529 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::less_or_equal), "<=");
530 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::equal), "==");
531 EXPECT_STREQ(comparison_operator_to_string(comparison_operator::not_equal), "!=");
532}
Alert trigger implementations for various condition types.
std::shared_ptr< threshold_trigger > high_
std::shared_ptr< threshold_trigger > low_
std::shared_ptr< threshold_trigger > trigger_
static std::shared_ptr< composite_trigger > all_of(std::vector< std::shared_ptr< alert_trigger > > triggers)
Create AND composite.
static std::shared_ptr< composite_trigger > any_of(std::vector< std::shared_ptr< alert_trigger > > triggers)
Create OR composite.
static std::shared_ptr< composite_trigger > invert(std::shared_ptr< alert_trigger > trigger)
Create NOT composite.
static std::shared_ptr< threshold_trigger > below_or_equal(double threshold)
Create trigger for value <= threshold.
static std::shared_ptr< threshold_trigger > below(double threshold)
Create trigger for value < threshold.
static std::shared_ptr< class range_trigger > in_range(double min_val, double max_val)
Create trigger for value within range (inclusive)
static std::shared_ptr< threshold_trigger > above_or_equal(double threshold)
Create trigger for value >= threshold.
static std::shared_ptr< class range_trigger > out_of_range(double min_val, double max_val)
Create trigger for value outside range (exclusive)
static std::shared_ptr< threshold_trigger > above(double threshold)
Create trigger for value > threshold.
constexpr const char * comparison_operator_to_string(comparison_operator op) noexcept
Convert comparison operator to string.
TEST(ComparisonOperatorTest, AllOperatorsHaveStrings)
TEST_F(ThresholdTriggerTest, GreaterThan)