test(mqtt): extract + test remaining inline MQTT decision points (Phase 6)

Close the host-testable gaps named in Phase 6 of STABILITY_TESTABILITY_HANDOFF.md
by moving the last inline decision logic into the pure, host-tested policy seams:

- WiFi STA reconnect backoff: extract the inline ladder + wrap-safe timing from
  handleWiFiConnection() into MQTTConnectionPolicy::{wifiReconnectBackoffMs,
  wifiReconnectDue,nextWifiBackoffAttempt}. Behavior-preserving (elapsedMs is the
  wrap-safe form of the old ULONG_MAX branch); ladder/clamp/attempt-cap unchanged.
- Publication outcome pairing: name the (packet, raw) -> delivered contract as
  MQTTPacketQueuePolicy::queuedPacketPublished() and wire both queue-drain sites;
  partial success = completed, not retried.
- Freeze MQTTPublicationType enum values in a test (the bridge-side MQTTMessageType
  alignment is already enforced by a compile-time static_assert).

Adds host tests for all three (exact boundaries + millis() rollover). Native suite
green (13 dirs); non-PSRAM observer firmware smoke build compiles.

WebConfig batch/reboot/stop state-machine extraction and queue-orchestration
coverage remain open (tracked in the Phase 6 status).
This commit is contained in:
agessaman
2026-07-19 00:50:15 -07:00
parent 2e1a1410ce
commit b0cf29fb33
7 changed files with 151 additions and 15 deletions
@@ -152,6 +152,49 @@ TEST(MQTTConnectionPolicy, JwtClockNeedsNtpOrAReasonableWallClock) {
EXPECT_TRUE(Policy::jwtClockAvailable(true, 0U));
}
TEST(MQTTConnectionPolicy, WifiBackoffLadderStartsAtFifteenSecondsAndSaturates) {
EXPECT_EQ(15000U, Policy::wifiReconnectBackoffMs(0));
EXPECT_EQ(30000U, Policy::wifiReconnectBackoffMs(1));
EXPECT_EQ(60000U, Policy::wifiReconnectBackoffMs(2));
EXPECT_EQ(120000U, Policy::wifiReconnectBackoffMs(3));
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(4));
// Clamps at the 300 s rung for the saturated attempt count and beyond.
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(5));
EXPECT_EQ(300000U, Policy::wifiReconnectBackoffMs(200));
}
TEST(MQTTConnectionPolicy, WifiBackoffAttemptClimbsThenSaturatesAtFive) {
uint8_t attempt = 0;
for (uint8_t expected = 1; expected <= 5; ++expected) {
attempt = Policy::nextWifiBackoffAttempt(attempt);
EXPECT_EQ(expected, attempt);
}
// Saturated: never advances past 5 (index stays clamped at the 300 s rung).
EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(attempt));
EXPECT_EQ(5U, Policy::nextWifiBackoffAttempt(5));
}
TEST(MQTTConnectionPolicy, WifiReconnectRequiresBothDownAndSinceAttemptToClearRung) {
const uint32_t down_since = 1000U;
const uint32_t last_attempt = 1000U;
const uint8_t attempt = 0; // 15 s rung
// Neither interval has elapsed yet.
EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 14999U, down_since, last_attempt, attempt));
// Down long enough, but an attempt was made only 5 s ago (since-attempt short).
EXPECT_FALSE(Policy::wifiReconnectDue(1000U + 15000U, down_since, 1000U + 10000U, attempt));
// Both cleared at the exact boundary: due.
EXPECT_TRUE(Policy::wifiReconnectDue(1000U + 15000U, down_since, last_attempt, attempt));
}
TEST(MQTTConnectionPolicy, WifiReconnectDueSurvivesMillisRollover) {
const uint32_t down_since = std::numeric_limits<uint32_t>::max() - 100U;
const uint32_t last_attempt = down_since;
const uint8_t attempt = 0; // 15 s rung
const uint32_t now = down_since + 15000U; // wraps past zero
EXPECT_TRUE(Policy::wifiReconnectDue(now, down_since, last_attempt, attempt));
EXPECT_FALSE(Policy::wifiReconnectDue(down_since + 14999U, down_since, last_attempt, attempt));
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -151,6 +151,29 @@ TEST(MQTTPacketQueuePolicy, RetrySchedulingDeadlineMayWrapToZero) {
decision.retry_attempts));
}
TEST(MQTTPacketQueuePolicy, PartialPublishCountsAsDeliveredEitherWay) {
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, true));
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(true, false)); // packet ok, raw failed
EXPECT_TRUE(QueuePolicy::queuedPacketPublished(false, true)); // raw ok, packet failed
EXPECT_FALSE(QueuePolicy::queuedPacketPublished(false, false)); // neither reached a slot
}
TEST(MQTTPacketQueuePolicy, PublishOutcomePairingDrivesRetryDecision) {
// packet succeeds / raw fails -> completed, no retry.
QueuePolicy::RetryDecision d =
QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(true, false), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action);
// raw succeeds / packet fails -> also completed.
d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, true), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Complete, d.action);
// both fail on a fresh packet -> scheduled for a bounded retry.
d = QueuePolicy::retryDecision(QueuePolicy::queuedPacketPublished(false, false), 0, 1234U);
EXPECT_EQ(QueuePolicy::RetryAction::Schedule, d.action);
EXPECT_EQ(1U, d.retry_attempts);
}
int main(int argc, char** argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
@@ -171,6 +171,18 @@ TEST(MQTTTopicRouter, RejectsInvalidStyleTypeSlotAndOutput) {
EXPECT_FALSE(mqttTopicSlotIndexValid(0, 0));
}
TEST(MQTTTopicRouter, PublicationTypeEnumValuesAreFrozen) {
// The bridge passes MQTTBridge::MQTTMessageType to mqttBuildPublicationTopic
// as an int; a compile-time static_assert in the bridge ties the two enums
// together. Freeze the router side here so its values can't drift on their own.
EXPECT_EQ(0, MQTT_PUBLICATION_STATUS);
EXPECT_EQ(1, MQTT_PUBLICATION_PACKETS);
EXPECT_EQ(2, MQTT_PUBLICATION_RAW);
EXPECT_STREQ("status", mqttPublicationTypeName(MQTT_PUBLICATION_STATUS));
EXPECT_STREQ("packets", mqttPublicationTypeName(MQTT_PUBLICATION_PACKETS));
EXPECT_STREQ("raw", mqttPublicationTypeName(MQTT_PUBLICATION_RAW));
}
} // namespace
int main(int argc, char** argv) {