From 822020b339551fc8ac9605818f59debfebd69f52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 20 Aug 2026 19:46:58 -0700 Subject: [PATCH] test(alert): add WiFi fault policy host tests --- src/helpers/AlertFaultPolicy.h | 277 +++++++++++++ .../test_alert_fault_policy.cpp | 389 ++++++++++++++++++ 2 files changed, 666 insertions(+) create mode 100644 src/helpers/AlertFaultPolicy.h create mode 100644 test/test_alert_fault_policy/test_alert_fault_policy.cpp diff --git a/src/helpers/AlertFaultPolicy.h b/src/helpers/AlertFaultPolicy.h new file mode 100644 index 000000000..d15aaf1f6 --- /dev/null +++ b/src/helpers/AlertFaultPolicy.h @@ -0,0 +1,277 @@ +#pragma once + +#include +#include +#include + +// Pure WiFi/MQTT fault-alert policy used by AlertReporter. Keeping the edge +// detector, duration math, rate-limit floor, and message formatting here lets +// host tests drive a fake clock without Mesh, MQTTBridge, or a radio. +// +// WiFi outage state is an OutageSnapshot: down, started_ms, and the initiating +// disconnect reason. That is the only data AlertReporter passes into tick() +// and formatWifiAlert(). Last ESP-IDF DISCONNECTED event time/reason are not +// inputs — using them made "WiFi down" quote uptime and reason 8 (ASSOC_LEAVE +// from STA-backoff WiFi.disconnect()). +namespace AlertFaultPolicy { + +static const uint32_t kCheckIntervalMs = 5000UL; +static const uint16_t kMinIntervalMinutes = 60; +static const uint32_t kMsPerMinute = 60000UL; + +enum class State : uint8_t { OK, FIRING }; + +struct Fault { + State state; + uint32_t fired_at_ms; // millis() of last successful "down" send; 0 = never + uint32_t last_outage_started_ms; // remembered so recovered can quote duration +}; + +// Current-outage view consumed by tick() and formatWifiAlert(). +// `down` is the authority: started_ms may be 0 when the STA dropped at +// millis()==0. While !down, packOutageSnapshot canonicalizes start/reason to 0. +struct OutageSnapshot { + bool down; + uint32_t started_ms; + uint8_t reason; // initiating reason; 0 = omit from the down message +}; + +enum class Action : uint8_t { None, FireDown, FireRecovered }; + +struct TickResult { + Action action; + uint32_t duration_ms; +}; + +static inline OutageSnapshot fromStartMs(uint32_t started_ms) { + OutageSnapshot snap{}; + snap.down = started_ms != 0; + snap.started_ms = started_ms; + return snap; +} + +// Single published word for the Core 0 writers (WiFi event / MQTT task) and +// the Core 1 reader (AlertReporter). 32-bit started_ms + 8-bit reason + down +// fit in 64 bits; atomic load/store of this word is the cross-task boundary. +static const uint64_t kOutageDownBit = 1ULL << 40; + +static inline uint64_t packOutageSnapshot(OutageSnapshot s) { + if (!s.down) { + s.started_ms = 0; + s.reason = 0; + } + uint64_t v = (uint64_t)s.started_ms; + v |= (uint64_t)s.reason << 32; + if (s.down) v |= kOutageDownBit; + return v; +} + +static inline OutageSnapshot unpackOutageSnapshot(uint64_t v) { + OutageSnapshot s{}; + s.started_ms = (uint32_t)v; + s.reason = (uint8_t)(v >> 32); + s.down = (v & kOutageDownBit) != 0; + if (!s.down) { + s.started_ms = 0; + s.reason = 0; + } + return s; +} + +// Status poll (handleWiFiConnection). `down` is independent of started_ms so a +// drop at millis()==0 is still down. Uses snapshot.down — not a separate +// last_connected flag — so a DISCONNECTED event that already opened the +// outage is not treated as a fresh connected→down edge on the next poll. +static inline OutageSnapshot applyWifiStatus(uint32_t now, bool connected, + OutageSnapshot cur, + bool initialized) { + if (connected) { + if (!initialized || cur.down) { + return OutageSnapshot{}; + } + return cur; + } + if (!initialized || !cur.down) { + OutageSnapshot snap{}; + snap.down = true; + snap.started_ms = now; // 0 is a legal start + snap.reason = cur.reason; + return snap; + } + return cur; +} + +// STA_GOT_IP (and any observe-connected between 10 s polls). A disconnect + +// reconnect that never shares a status poll with the drop must still close +// the outage, or the next DISCONNECTED event keeps the first start/reason. +static inline OutageSnapshot applyWifiGotIp(OutageSnapshot /*cur*/) { + return OutageSnapshot{}; +} + +// ESP-IDF STA_DISCONNECTED event. Opens the outage if needed. While already +// down, the start is frozen and the first non-zero reason is kept — later +// reason 8 (ASSOC_LEAVE from WiFi.disconnect()) cannot replace it. +static inline OutageSnapshot applyWifiDisconnectEvent(uint32_t now, uint8_t reason, + OutageSnapshot cur) { + if (!cur.down) { + OutageSnapshot snap{}; + snap.down = true; + snap.started_ms = now; + snap.reason = reason; + return snap; + } + if (cur.reason == 0 && reason != 0) { + cur.reason = reason; + } + return cur; +} + +// Unsigned subtraction is the standard millis() idiom and remains correct +// across one 32-bit counter rollover. +static inline uint32_t elapsedMs(uint32_t now, uint32_t then) { + return now - then; +} + +// Construction leaves next_check_ms at 0, so the first poll is always due. +// The signed delta matches AlertReporter's original (long)(now - next) < 0 +// skip, which is wrap-safe for intervals well under 2^31 ms. +static inline bool checkDue(uint32_t now, uint32_t next_check_ms) { + return (int32_t)(now - next_check_ms) >= 0; +} + +static inline uint32_t nextCheckMs(uint32_t now) { + return now + kCheckIntervalMs; +} + +// Stale prefs or a future field tweak cannot drag the floor below 1 hour. +static inline uint32_t minIntervalMs(uint16_t cfg_minutes) { + uint16_t minutes = cfg_minutes < kMinIntervalMinutes ? kMinIntervalMinutes + : cfg_minutes; + return (uint32_t)minutes * kMsPerMinute; +} + +static inline uint32_t thresholdMs(uint16_t minutes) { + return (uint32_t)minutes * kMsPerMinute; +} + +static inline uint32_t downDurationMs(uint32_t now, const OutageSnapshot& snap) { + return snap.down ? elapsedMs(now, snap.started_ms) : 0; +} + +static inline bool rateLimitAllows(uint32_t now, uint32_t fired_at_ms, + uint32_t min_interval_ms) { + // fired_at_ms == 0 means never fired since boot/config change. Treating it + // as a send at millis()==0 would suppress the first alert until uptime + // reached min_interval. + return fired_at_ms == 0 || elapsedMs(now, fired_at_ms) >= min_interval_ms; +} + +// Decide whether to emit a down/recovered message. Does not mutate `f`: +// FireDown is committed only after a successful send; FireRecovered is +// committed after the send attempt (success or not), matching production. +static inline TickResult tick(const Fault& f, uint32_t now, + const OutageSnapshot& snap, uint32_t thresh_ms, + uint32_t min_interval_ms) { + TickResult result = {Action::None, 0}; + if (f.state == State::OK) { + const uint32_t down_ms = downDurationMs(now, snap); + if (snap.down && down_ms >= thresh_ms && + rateLimitAllows(now, f.fired_at_ms, min_interval_ms)) { + result.action = Action::FireDown; + result.duration_ms = down_ms; + } + } else if (!snap.down) { + result.action = Action::FireRecovered; + // FIRING always went through commitDown, so last_outage_started_ms is a + // real start — including 0 when the outage began at millis()==0. + result.duration_ms = elapsedMs(now, f.last_outage_started_ms); + } + return result; +} + +static inline void commitDown(Fault& f, uint32_t now, uint32_t outage_start_ms) { + f.state = State::FIRING; + f.fired_at_ms = now; + f.last_outage_started_ms = outage_start_ms; +} + +static inline void commitRecovered(Fault& f) { + f.state = State::OK; +} + +static inline void reset(Fault& f) { + f.state = State::OK; + f.fired_at_ms = 0; +} + +static inline void rearmIfDisabled(Fault& f) { + if (f.state == State::FIRING) f.state = State::OK; +} + +static inline void formatAge(uint32_t age_ms, char* out, size_t out_size) { + if (!out || out_size == 0) return; + uint32_t secs = age_ms / 1000U; + uint32_t h = secs / 3600U; + uint32_t m = (secs % 3600U) / 60U; + if (h > 0) { + snprintf(out, out_size, "%uh%um", (unsigned)h, (unsigned)m); + } else { + snprintf(out, out_size, "%um", (unsigned)m); + } +} + +static inline void formatWifiDown(char* out, size_t out_size, uint32_t duration_ms, + uint8_t reason) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + if (reason != 0) { + snprintf(out, out_size, "WiFi down %s (reason %u)", age, (unsigned)reason); + } else { + snprintf(out, out_size, "WiFi down %s", age); + } +} + +static inline void formatWifiRecovered(char* out, size_t out_size, + uint32_t duration_ms) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + snprintf(out, out_size, "WiFi recovered after %s", age); +} + +// Production formatting entry: the same (TickResult, OutageSnapshot) pair +// AlertReporter feeds after tick(). Returns false when there is no message. +static inline bool formatWifiAlert(char* out, size_t out_size, const TickResult& r, + const OutageSnapshot& snap) { + if (r.action == Action::FireDown) { + formatWifiDown(out, out_size, r.duration_ms, snap.reason); + return true; + } + if (r.action == Action::FireRecovered) { + formatWifiRecovered(out, out_size, r.duration_ms); + return true; + } + return false; +} + +static inline void formatMqttDown(char* out, size_t out_size, int slot_1based, + const char* preset_name, uint32_t duration_ms) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + snprintf(out, out_size, "MQTT slot %d (%s) down %s", slot_1based, + preset_name ? preset_name : "?", age); +} + +static inline void formatMqttRecovered(char* out, size_t out_size, int slot_1based, + const char* preset_name, + uint32_t duration_ms) { + if (!out || out_size == 0) return; + char age[16]; + formatAge(duration_ms, age, sizeof(age)); + snprintf(out, out_size, "MQTT slot %d (%s) recovered after %s", slot_1based, + preset_name ? preset_name : "?", age); +} + +} // namespace AlertFaultPolicy diff --git a/test/test_alert_fault_policy/test_alert_fault_policy.cpp b/test/test_alert_fault_policy/test_alert_fault_policy.cpp new file mode 100644 index 000000000..42ba27d7f --- /dev/null +++ b/test/test_alert_fault_policy/test_alert_fault_policy.cpp @@ -0,0 +1,389 @@ +// Host tests for AlertReporter's fault edge detector, duration math, and +// message formatting (src/helpers/AlertFaultPolicy.h). Tick and formatWifiAlert +// share an OutageSnapshot (down, started_ms, initiating reason) — the same +// data MQTTBridge feeds production. +#include +#include +#include +#include + +#include "helpers/AlertFaultPolicy.h" + +namespace Alert = AlertFaultPolicy; + +namespace { + +const uint32_t kWifiThresh = Alert::thresholdMs(30); // default alert.wifi +const uint32_t kMinInterval = Alert::minIntervalMs(60); +const uint8_t kBeaconTimeout = 200; // WIFI_REASON_BEACON_TIMEOUT +const uint8_t kAssocLeave = 8; // WIFI_REASON_ASSOC_LEAVE (WiFi.disconnect()) + +Alert::Fault OkFault() { + Alert::Fault f{}; + f.state = Alert::State::OK; + return f; +} + +Alert::OutageSnapshot Down(uint32_t started_ms, uint8_t reason = 0) { + Alert::OutageSnapshot snap{}; + snap.down = true; + snap.started_ms = started_ms; + snap.reason = reason; + return snap; +} + +Alert::OutageSnapshot Up() { return {}; } + +// Same sequence AlertReporter uses on the WiFi path: tick, format from the +// snapshot, commit on FireDown / FireRecovered. +bool ProductionWifiAlert(Alert::Fault& f, uint32_t now, + const Alert::OutageSnapshot& snap, char* text, + size_t text_size) { + Alert::TickResult r = Alert::tick(f, now, snap, kWifiThresh, kMinInterval); + if (!Alert::formatWifiAlert(text, text_size, r, snap)) return false; + if (r.action == Alert::Action::FireDown) { + Alert::commitDown(f, now, snap.started_ms); + } else { + Alert::commitRecovered(f); + } + return true; +} + +} // namespace + +TEST(AlertFaultPolicy, DownDurationUsesCurrentOutageStartNotLastEvent) { + Alert::Fault f = OkFault(); + const uint32_t boot_event = 1; + const uint32_t outage_start = 10 * 60000; + const uint32_t now = outage_start + kWifiThresh; + const uint32_t last_reconnect_event = now - 5000; + + Alert::TickResult r = + Alert::tick(f, now, Down(outage_start, kBeaconTimeout), kWifiThresh, + kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); + EXPECT_EQ(kWifiThresh, r.duration_ms); + EXPECT_NE(now - boot_event, r.duration_ms); + EXPECT_NE(now - last_reconnect_event, r.duration_ms); +} + +TEST(AlertFaultPolicy, + ProductionFlowReason8ReconnectsPreserveDurationAndInitiatingReason) { + Alert::OutageSnapshot snap{}; + snap = Alert::applyWifiStatus(1000, true, snap, false); + EXPECT_FALSE(snap.down); + EXPECT_EQ(0U, snap.started_ms); + EXPECT_EQ(0, snap.reason); + + const uint32_t drop = 10 * 60000U; + snap = Alert::applyWifiDisconnectEvent(drop, kBeaconTimeout, snap); + snap = Alert::applyWifiStatus(drop + 100, false, snap, true); + EXPECT_TRUE(snap.down); + EXPECT_EQ(drop, snap.started_ms); + EXPECT_EQ(kBeaconTimeout, snap.reason); + + for (int i = 1; i <= 24; ++i) { + const uint32_t t = drop + (uint32_t)i * 300000U; + snap = Alert::applyWifiDisconnectEvent(t, kAssocLeave, snap); + snap = Alert::applyWifiStatus(t + 1, false, snap, true); + } + EXPECT_EQ(drop, snap.started_ms); + EXPECT_EQ(kBeaconTimeout, snap.reason); + + const uint32_t two_h_two_m = (2U * 3600U + 2U * 60U) * 1000U; + const uint32_t now = drop + two_h_two_m; + Alert::Fault f = OkFault(); + char text[80]; + ASSERT_TRUE(ProductionWifiAlert(f, now, snap, text, sizeof(text))); + EXPECT_STREQ("WiFi down 2h2m (reason 200)", text); + EXPECT_EQ(Alert::State::FIRING, f.state); + EXPECT_EQ(drop, f.last_outage_started_ms); +} + +TEST(AlertFaultPolicy, PollFirstEventFillsReasonThenReason8DoesNotOverwrite) { + Alert::OutageSnapshot snap{}; + snap = Alert::applyWifiStatus(1000, true, snap, false); + const uint32_t drop = 5000; + snap = Alert::applyWifiStatus(drop, false, snap, true); + EXPECT_TRUE(snap.down); + EXPECT_EQ(drop, snap.started_ms); + EXPECT_EQ(0, snap.reason); + + snap = Alert::applyWifiDisconnectEvent(drop + 20, kBeaconTimeout, snap); + EXPECT_EQ(kBeaconTimeout, snap.reason); + EXPECT_EQ(drop, snap.started_ms); + + snap = Alert::applyWifiDisconnectEvent(drop + 15000, kAssocLeave, snap); + EXPECT_EQ(kBeaconTimeout, snap.reason); + EXPECT_EQ(drop, snap.started_ms); +} + +TEST(AlertFaultPolicy, RecoverClearsSnapshotReasonAndStart) { + Alert::OutageSnapshot snap = Down(5000, kBeaconTimeout); + snap = Alert::applyWifiStatus(8000, true, snap, true); + EXPECT_FALSE(snap.down); + EXPECT_EQ(0U, snap.started_ms); + EXPECT_EQ(0, snap.reason); +} + +TEST(AlertFaultPolicy, DisconnectAndReconnectBetweenStatusPollsIsANewOutage) { + Alert::OutageSnapshot snap{}; + snap = Alert::applyWifiStatus(1000, true, snap, false); + + snap = Alert::applyWifiDisconnectEvent(2000, kBeaconTimeout, snap); + EXPECT_TRUE(snap.down); + EXPECT_EQ(2000U, snap.started_ms); + EXPECT_EQ(kBeaconTimeout, snap.reason); + + // GOT_IP with no status poll in between — the flap is fully between polls. + snap = Alert::applyWifiGotIp(snap); + EXPECT_FALSE(snap.down); + EXPECT_EQ(0U, snap.started_ms); + EXPECT_EQ(0, snap.reason); + + const uint8_t kAssocExpire = 4; + snap = Alert::applyWifiDisconnectEvent(3500, kAssocExpire, snap); + EXPECT_TRUE(snap.down); + EXPECT_EQ(3500U, snap.started_ms); + EXPECT_EQ(kAssocExpire, snap.reason); + EXPECT_NE(kBeaconTimeout, snap.reason); +} + +TEST(AlertFaultPolicy, StatusDetectedDownAtMillisZeroIsStillDown) { + Alert::OutageSnapshot snap{}; + snap = Alert::applyWifiStatus(0, false, snap, false); + EXPECT_TRUE(snap.down); + EXPECT_EQ(0U, snap.started_ms); + + Alert::Fault f = OkFault(); + Alert::TickResult r = + Alert::tick(f, kWifiThresh, snap, kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); + EXPECT_EQ(kWifiThresh, r.duration_ms); +} + +TEST(AlertFaultPolicy, RecoveryAfterDownCommittedAtMillisZeroQuotesElapsedTime) { + Alert::Fault f = OkFault(); + Alert::OutageSnapshot snap = Down(0, kBeaconTimeout); + const uint32_t down_at = kWifiThresh; + Alert::TickResult down = + Alert::tick(f, down_at, snap, kWifiThresh, kMinInterval); + ASSERT_EQ(Alert::Action::FireDown, down.action); + Alert::commitDown(f, down_at, snap.started_ms); + EXPECT_EQ(0U, f.last_outage_started_ms); + + const uint32_t recovered_at = (2U * 3600U + 5U * 60U) * 1000U; + Alert::TickResult rec = + Alert::tick(f, recovered_at, Up(), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireRecovered, rec.action); + EXPECT_EQ(recovered_at, rec.duration_ms); + + char text[80]; + ASSERT_TRUE(Alert::formatWifiAlert(text, sizeof(text), rec, Up())); + EXPECT_STREQ("WiFi recovered after 2h5m", text); +} + +TEST(AlertFaultPolicy, PackedSnapshotIsTheCoherentCrossTaskWord) { + Alert::OutageSnapshot down_at_zero = Down(0, kBeaconTimeout); + std::atomic cell{0}; + cell.store(Alert::packOutageSnapshot(down_at_zero), std::memory_order_release); + Alert::OutageSnapshot loaded = Alert::unpackOutageSnapshot( + cell.load(std::memory_order_acquire)); + EXPECT_TRUE(loaded.down); + EXPECT_EQ(0U, loaded.started_ms); + EXPECT_EQ(kBeaconTimeout, loaded.reason); + + cell.store(Alert::packOutageSnapshot(Up()), std::memory_order_release); + loaded = Alert::unpackOutageSnapshot(cell.load(std::memory_order_acquire)); + EXPECT_FALSE(loaded.down); + EXPECT_EQ(0U, loaded.started_ms); + EXPECT_EQ(0, loaded.reason); + + Alert::OutageSnapshot dirty_up{}; + dirty_up.down = false; + dirty_up.started_ms = 12345; + dirty_up.reason = kAssocLeave; + loaded = Alert::unpackOutageSnapshot(Alert::packOutageSnapshot(dirty_up)); + EXPECT_FALSE(loaded.down); + EXPECT_EQ(0U, loaded.started_ms); + EXPECT_EQ(0, loaded.reason); +} + +TEST(AlertFaultPolicy, DoesNotFireBelowThreshold) { + Alert::Fault f = OkFault(); + Alert::TickResult r = + Alert::tick(f, 1000 + kWifiThresh - 1, Down(1000), kWifiThresh, + kMinInterval); + EXPECT_EQ(Alert::Action::None, r.action); +} + +TEST(AlertFaultPolicy, FiresAtExactThreshold) { + Alert::Fault f = OkFault(); + Alert::TickResult r = + Alert::tick(f, 1000 + kWifiThresh, Down(1000), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); + EXPECT_EQ(kWifiThresh, r.duration_ms); +} + +TEST(AlertFaultPolicy, FirstFireIgnoresMinIntervalUptime) { + Alert::Fault f = OkFault(); + EXPECT_EQ(0U, f.fired_at_ms); + const uint32_t start = 1000; + const uint32_t now = start + kWifiThresh; + EXPECT_LT(now, kMinInterval); + Alert::TickResult r = + Alert::tick(f, now, Down(start), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); +} + +TEST(AlertFaultPolicy, RateLimitBlocksRepeatUntilFloorElapses) { + Alert::Fault f = OkFault(); + const uint32_t start = 1000; + const uint32_t first = start + kWifiThresh; + Alert::commitDown(f, first, start); + + Alert::commitRecovered(f); + Alert::TickResult r = Alert::tick(f, first + kMinInterval - 1, Down(start), + kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::None, r.action); + + Alert::TickResult due = Alert::tick(f, first + kMinInterval, Down(start), + kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, due.action); +} + +TEST(AlertFaultPolicy, MinIntervalClampsBelowOneHour) { + EXPECT_EQ(60U * 60000U, Alert::minIntervalMs(0)); + EXPECT_EQ(60U * 60000U, Alert::minIntervalMs(30)); + EXPECT_EQ(60U * 60000U, Alert::minIntervalMs(59)); + EXPECT_EQ(60U * 60000U, Alert::minIntervalMs(60)); + EXPECT_EQ(120U * 60000U, Alert::minIntervalMs(120)); +} + +TEST(AlertFaultPolicy, RecoveredDurationUsesRememberedOutageStart) { + Alert::Fault f = OkFault(); + const uint32_t start = 10 * 60000; + const uint32_t down_at = start + kWifiThresh; + Alert::commitDown(f, down_at, start); + + const uint32_t recovered_at = start + (2U * 3600U + 5U * 60U) * 1000U; + Alert::TickResult r = + Alert::tick(f, recovered_at, Up(), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireRecovered, r.action); + EXPECT_EQ(recovered_at - start, r.duration_ms); + + char text[80]; + ASSERT_TRUE(Alert::formatWifiAlert(text, sizeof(text), r, Up())); + EXPECT_STREQ("WiFi recovered after 2h5m", text); +} + +TEST(AlertFaultPolicy, SecondOutageUsesNewStartNotTheFirst) { + Alert::Fault f = OkFault(); + const uint32_t first_start = 1000; + Alert::commitDown(f, first_start + kWifiThresh, first_start); + Alert::TickResult recovered = Alert::tick( + f, first_start + kWifiThresh + 1000, Up(), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireRecovered, recovered.action); + Alert::commitRecovered(f); + + const uint32_t second_start = first_start + kWifiThresh + 5000; + const uint32_t second_now = second_start + kWifiThresh + kMinInterval; + Alert::TickResult r = Alert::tick(f, second_now, Down(second_start), + kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); + EXPECT_EQ(second_now - second_start, r.duration_ms); + EXPECT_NE(second_now - first_start, r.duration_ms); +} + +TEST(AlertFaultPolicy, TickDoesNotMutateUntilCommit) { + Alert::Fault f = OkFault(); + const uint32_t start = 1000; + Alert::tick(f, start + kWifiThresh, Down(start), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::State::OK, f.state); + EXPECT_EQ(0U, f.fired_at_ms); + + Alert::commitDown(f, start + kWifiThresh, start); + Alert::TickResult recovered = + Alert::tick(f, start + kWifiThresh + 1, Up(), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireRecovered, recovered.action); + EXPECT_EQ(Alert::State::FIRING, f.state); + Alert::commitRecovered(f); + EXPECT_EQ(Alert::State::OK, f.state); +} + +TEST(AlertFaultPolicy, RearmClearsFiringWhenThresholdDisabled) { + Alert::Fault f = OkFault(); + Alert::commitDown(f, 1000, 1); + Alert::rearmIfDisabled(f); + EXPECT_EQ(Alert::State::OK, f.state); +} + +TEST(AlertFaultPolicy, ResetRearmsWithoutForcingASend) { + Alert::Fault f = OkFault(); + Alert::commitDown(f, 1000, 1); + Alert::reset(f); + EXPECT_EQ(Alert::State::OK, f.state); + EXPECT_EQ(0U, f.fired_at_ms); +} + +TEST(AlertFaultPolicy, FormatAgeMinutesAndHours) { + char buf[16]; + Alert::formatAge(0, buf, sizeof(buf)); + EXPECT_STREQ("0m", buf); + Alert::formatAge(47U * 60000U, buf, sizeof(buf)); + EXPECT_STREQ("47m", buf); + Alert::formatAge((1U * 3600U + 3U * 60U) * 1000U, buf, sizeof(buf)); + EXPECT_STREQ("1h3m", buf); +} + +TEST(AlertFaultPolicy, FormatWifiAlertUsesSnapshotReason) { + Alert::Fault f = OkFault(); + Alert::TickResult r = Alert::tick(f, 1000 + kWifiThresh, + Down(1000, kBeaconTimeout), kWifiThresh, + kMinInterval); + char text[80]; + ASSERT_TRUE(Alert::formatWifiAlert(text, sizeof(text), r, + Down(1000, kBeaconTimeout))); + EXPECT_STREQ("WiFi down 30m (reason 200)", text); + + Alert::formatWifiDown(text, sizeof(text), 47U * 60000U, 0); + EXPECT_STREQ("WiFi down 47m", text); +} + +TEST(AlertFaultPolicy, FormatMqttSlotMessages) { + char text[100]; + Alert::formatMqttDown(text, sizeof(text), 1, "analyzer-us", 30U * 60000U); + EXPECT_STREQ("MQTT slot 1 (analyzer-us) down 30m", text); + Alert::formatMqttRecovered(text, sizeof(text), 1, "analyzer-us", + 4U * 3600U * 1000U + 45U * 60000U); + EXPECT_STREQ("MQTT slot 1 (analyzer-us) recovered after 4h45m", text); +} + +TEST(AlertFaultPolicy, CheckDueMatchesFiveSecondCadenceAndWrap) { + EXPECT_TRUE(Alert::checkDue(0, 0)); + EXPECT_TRUE(Alert::checkDue(1000, 0)); + const uint32_t next = Alert::nextCheckMs(1000); + EXPECT_EQ(6000U, next); + EXPECT_FALSE(Alert::checkDue(5999, next)); + EXPECT_TRUE(Alert::checkDue(6000, next)); + + const uint32_t before_wrap = std::numeric_limits::max() - 1000U; + const uint32_t wrapped_next = Alert::nextCheckMs(before_wrap); + EXPECT_FALSE(Alert::checkDue(before_wrap + 4999U, wrapped_next)); + EXPECT_TRUE(Alert::checkDue(before_wrap + 5000U, wrapped_next)); +} + +TEST(AlertFaultPolicy, DownDurationSurvivesMillisRollover) { + Alert::Fault f = OkFault(); + const uint32_t start = std::numeric_limits::max() - 1000U; + const uint32_t now = start + kWifiThresh; + Alert::TickResult r = + Alert::tick(f, now, Down(start), kWifiThresh, kMinInterval); + EXPECT_EQ(Alert::Action::FireDown, r.action); + EXPECT_EQ(kWifiThresh, r.duration_ms); +} + +int main(int argc, char** argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}