test(alert): add WiFi fault policy host tests

This commit is contained in:
agessaman committed 2026-08-20 19:46:58 -07:00
1 parent e3ecae8a94
commit 822020b339
2 files changed
+666

No files matched your search

+277
View File
@@ -0,0 +1,277 @@
#pragma once
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
// 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
@@ -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 <gtest/gtest.h>
#include <atomic>
#include <limits>
#include <stdint.h>
#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<uint64_t> 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<uint32_t>::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<uint32_t>::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();
}