diff --git a/src/configuration.h b/src/configuration.h
index 84dabee4e..efd9ddcf7 100644
--- a/src/configuration.h
+++ b/src/configuration.h
@@ -499,6 +499,7 @@ along with this program. If not, see .
#define MESHTASTIC_EXCLUDE_PKI 1
#define MESHTASTIC_EXCLUDE_POWER_FSM 1
#define MESHTASTIC_EXCLUDE_TZ 1
+#define MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH 1
#endif
// Turn off all optional modules
diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp
index 13f948a7b..e8613d457 100644
--- a/src/mesh/NextHopRouter.cpp
+++ b/src/mesh/NextHopRouter.cpp
@@ -101,9 +101,12 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
if (origTx) {
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
// directly from the destination
- bool wasAlreadyRelayer = wasRelayer(p->relay_node, p->decoded.request_id, p->to);
+ // Single lookup for both relayer checks on the same (request_id, to) pair
+ bool wasAlreadyRelayer = false;
bool weWereSoleRelayer = false;
- bool weWereRelayer = wasRelayer(ourRelayID, p->decoded.request_id, p->to, &weWereSoleRelayer);
+ bool weWereRelayer = false;
+ checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
+ &weWereSoleRelayer);
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
if (origTx->next_hop != p->relay_node) { // Not already set
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp
index 845a936d4..8289f0078 100644
--- a/src/mesh/PacketHistory.cpp
+++ b/src/mesh/PacketHistory.cpp
@@ -1,6 +1,7 @@
#include "PacketHistory.h"
#include "configuration.h"
#include "mesh-pb-constants.h"
+#include "meshUtils.h"
#ifdef ARCH_PORTDUINO
#include "platform/portduino/PortduinoGlue.h"
@@ -23,6 +24,14 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
size = PACKETHISTORY_MAX; // Use default size if invalid
}
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Ensure capacity fits in uint16_t hash index (HASH_EMPTY = 0xFFFF is the sentinel)
+ if (size >= HASH_EMPTY) {
+ LOG_WARN("Packet History - Clamping size %d to %d (hash index limit)", size, HASH_EMPTY - 1);
+ size = HASH_EMPTY - 1;
+ }
+#endif
+
// Allocate memory for the recent packets array
recentPacketsCapacity = size;
recentPackets = new PacketRecord[recentPacketsCapacity];
@@ -35,6 +44,20 @@ PacketHistory::PacketHistory(uint32_t size) : recentPacketsCapacity(0), recentPa
// Initialize the recent packets array to zero
memset(recentPackets, 0, sizeof(PacketRecord) * recentPacketsCapacity);
+
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Allocate hash index with load factor <= 0.5 for short probe chains
+ hashCapacity = nextPowerOf2(recentPacketsCapacity * 2);
+ hashMask = hashCapacity - 1;
+ hashIndex = new uint16_t[hashCapacity];
+ if (!hashIndex) {
+ LOG_ERROR("Packet History - Hash index allocation failed for %d entries", hashCapacity);
+ hashCapacity = 0;
+ hashMask = 0;
+ return;
+ }
+ memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity); // Fill with HASH_EMPTY (0xFFFF)
+#endif
}
PacketHistory::~PacketHistory()
@@ -42,6 +65,12 @@ PacketHistory::~PacketHistory()
recentPacketsCapacity = 0;
delete[] recentPackets;
recentPackets = NULL;
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ delete[] hashIndex;
+ hashIndex = NULL;
+ hashCapacity = 0;
+ hashMask = 0;
+#endif
}
/** Update recentPackets and return true if we have already seen this packet */
@@ -194,7 +223,78 @@ bool PacketHistory::wasSeenRecently(const meshtastic_MeshPacket *p, bool withUpd
return seenRecently;
}
-/** Find a packet record in history.
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+// Hash function for (sender, id) pairs. Uses xor-shift mixing for good distribution.
+uint32_t PacketHistory::hashSlot(NodeNum sender, PacketId id) const
+{
+ uint32_t h = sender ^ (id * 0x9E3779B9); // Fibonacci hashing constant
+ h ^= h >> 16;
+ h *= 0x45d9f3b;
+ h ^= h >> 16;
+ return h & hashMask;
+}
+
+void PacketHistory::hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx)
+{
+ if (!hashIndex)
+ return;
+ uint32_t bucket = hashSlot(sender, id);
+ // Guard against infinite loop if hash table is corrupted (no HASH_EMPTY slots)
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY) {
+ hashIndex[bucket] = slotIdx;
+ return;
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+ LOG_ERROR("Packet History - hashInsert: table full or corrupted, rebuilding");
+ hashRebuild();
+}
+
+void PacketHistory::hashRemove(NodeNum sender, PacketId id)
+{
+ if (!hashIndex)
+ return;
+ uint32_t bucket = hashSlot(sender, id);
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY)
+ return;
+ uint16_t idx = hashIndex[bucket];
+ if (idx < recentPacketsCapacity && recentPackets[idx].sender == sender && recentPackets[idx].id == id) {
+ // Found it — delete and re-insert subsequent entries to maintain probe chain integrity
+ hashIndex[bucket] = HASH_EMPTY;
+ uint32_t next = (bucket + 1) & hashMask;
+ for (uint32_t j = 0; j < hashCapacity; j++) {
+ if (hashIndex[next] == HASH_EMPTY)
+ break;
+ uint16_t displaced = hashIndex[next];
+ hashIndex[next] = HASH_EMPTY;
+ if (displaced < recentPacketsCapacity) {
+ const auto &rec = recentPackets[displaced];
+ hashInsert(rec.sender, rec.id, displaced);
+ }
+ next = (next + 1) & hashMask;
+ }
+ return;
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+}
+
+void PacketHistory::hashRebuild()
+{
+ if (!hashIndex)
+ return;
+ memset(hashIndex, 0xFF, sizeof(uint16_t) * hashCapacity);
+ for (uint32_t i = 0; i < recentPacketsCapacity; i++) {
+ if (recentPackets[i].rxTimeMsec != 0)
+ hashInsert(recentPackets[i].sender, recentPackets[i].id, (uint16_t)i);
+ }
+}
+#endif
+
+/** Find a packet record in history using the hash index for O(1) average lookup.
+ * Falls back to linear scan if hash index is unavailable.
* @return pointer to PacketRecord if found, NULL if not found */
PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
{
@@ -205,23 +305,40 @@ PacketHistory::PacketRecord *PacketHistory::find(NodeNum sender, PacketId id)
return NULL;
}
- PacketRecord *it = NULL;
- for (it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
- if (it->id == id && it->sender == sender) {
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Use hash index for O(1) lookup when available
+ if (hashIndex) {
+ uint32_t bucket = hashSlot(sender, id);
+ for (uint32_t i = 0; i < hashCapacity; i++) {
+ if (hashIndex[bucket] == HASH_EMPTY)
+ break;
+ uint16_t idx = hashIndex[bucket];
+ if (idx < recentPacketsCapacity && recentPackets[idx].id == id && recentPackets[idx].sender == sender) {
#if VERBOSE_PACKET_HISTORY
- LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d", it->sender,
- it->id, it->next_hop, it->relayed_by[0], it->relayed_by[1], it->relayed_by[2], millis() - (it->rxTimeMsec),
- it - recentPackets, recentPacketsCapacity);
+ LOG_DEBUG("Packet History - find: s=%08x id=%08x FOUND nh=%02x rby=%02x %02x %02x age=%d slot=%d/%d",
+ recentPackets[idx].sender, recentPackets[idx].id, recentPackets[idx].next_hop,
+ recentPackets[idx].relayed_by[0], recentPackets[idx].relayed_by[1], recentPackets[idx].relayed_by[2],
+ millis() - (recentPackets[idx].rxTimeMsec), idx, recentPacketsCapacity);
#endif
- // only the first match is returned, so be careful not to create duplicate entries
- return it; // Return pointer to the found record
+ return &recentPackets[idx];
+ }
+ bucket = (bucket + 1) & hashMask;
+ }
+#if VERBOSE_PACKET_HISTORY
+ LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id);
+#endif
+ return NULL;
+ }
+#endif
+
+ // Linear scan (sole path when hash excluded, fallback when hash allocation failed)
+ for (PacketRecord *it = recentPackets; it < (recentPackets + recentPacketsCapacity); ++it) {
+ if (it->id == id && it->sender == sender) {
+ return it;
}
}
-#if VERBOSE_PACKET_HISTORY
- LOG_DEBUG("Packet History - find: s=%08x id=%08x NOT FOUND", sender, id);
-#endif
- return NULL; // Not found
+ return NULL;
}
/** Insert/Replace oldest PacketRecord in recentPackets. */
@@ -327,8 +444,22 @@ void PacketHistory::insert(const PacketRecord &r)
return; // Return early if we can't update the history
}
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Maintain hash index: remove old entry if evicting a different packet, then insert new entry
+ bool isMatchingSlot = (tu->id == r.id && tu->sender == r.sender);
+ if (!isMatchingSlot && tu->rxTimeMsec != 0) {
+ hashRemove(tu->sender, tu->id);
+ }
+
*tu = r; // store the packet
+ if (!isMatchingSlot) {
+ hashInsert(r.sender, r.id, (uint16_t)(tu - recentPackets));
+ }
+#else
+ *tu = r; // store the packet
+#endif
+
#if VERBOSE_PACKET_HISTORY
LOG_DEBUG("Packet History - insert: Store slot@ %d/%d s=%08x id=%08x nh=%02x rby=%02x %02x %02x rxT=%d AFTER",
tu - recentPackets, recentPacketsCapacity, tu->sender, tu->id, tu->next_hop, tu->relayed_by[0], tu->relayed_by[1],
@@ -396,6 +527,31 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, boo
return found;
}
+// Check two relayers against the same packet record with a single find() call,
+// avoiding redundant O(N) lookups when both are checked for the same (id, sender) pair.
+void PacketHistory::checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result,
+ bool *r2WasSole)
+{
+ *r1Result = false;
+ *r2Result = false;
+ if (r2WasSole)
+ *r2WasSole = false;
+
+ if (!initOk()) {
+ LOG_ERROR("PacketHistory - checkRelayers: NOT INITIALIZED!");
+ return;
+ }
+
+ const PacketRecord *found = find(sender, id);
+ if (!found)
+ return;
+
+ if (relayer1 != 0)
+ *r1Result = wasRelayer(relayer1, *found);
+ if (relayer2 != 0)
+ *r2Result = wasRelayer(relayer2, *found, r2WasSole);
+}
+
// Remove a relayer from the list of relayers of a packet in the history given an ID and sender
void PacketHistory::removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender)
{
diff --git a/src/mesh/PacketHistory.h b/src/mesh/PacketHistory.h
index 9b6a93280..a11e2d038 100644
--- a/src/mesh/PacketHistory.h
+++ b/src/mesh/PacketHistory.h
@@ -28,6 +28,22 @@ class PacketHistory
0; // Can be set in constructor, no need to recompile. Used to allocate memory for mx_recentPackets.
PacketRecord *recentPackets = NULL; // Simple and fixed in size. Debloat.
+#if !MESHTASTIC_EXCLUDE_PKT_HISTORY_HASH
+ // Open-addressing hash table for O(1) lookup in find(), replacing the O(N) linear scan.
+ // Maps (sender, id) -> index into recentPackets[]. Uses linear probing with a load factor <= 0.5.
+ // The load factor invariant holds permanently: hashCapacity = 2 * nextPowerOf2(recentPacketsCapacity),
+ // and at most recentPacketsCapacity entries can ever be live (one per recentPackets[] slot).
+ static constexpr uint16_t HASH_EMPTY = 0xFFFF;
+ uint16_t *hashIndex = NULL;
+ uint32_t hashCapacity = 0; // Always a power of 2
+ uint32_t hashMask = 0; // hashCapacity - 1, for fast modular indexing
+
+ uint32_t hashSlot(NodeNum sender, PacketId id) const;
+ void hashInsert(NodeNum sender, PacketId id, uint16_t slotIdx);
+ void hashRemove(NodeNum sender, PacketId id);
+ void hashRebuild();
+#endif
+
/** Find a packet record in history.
* @param sender NodeNum
* @param id PacketId
@@ -70,6 +86,16 @@ class PacketHistory
* @return true if node was indeed a relayer, false if not */
bool wasRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender, bool *wasSole = nullptr);
+ /**
+ * Check two relayers against the same packet record with a single lookup.
+ * Avoids redundant find() calls when checking multiple relayers for the same (id, sender) pair.
+ * @param r1Result set to true if relayer1 was a relayer
+ * @param r2Result set to true if relayer2 was a relayer
+ * @param r2WasSole if not nullptr, set to true if relayer2 was the sole relayer
+ */
+ void checkRelayers(uint8_t relayer1, uint8_t relayer2, uint32_t id, NodeNum sender, bool *r1Result, bool *r2Result,
+ bool *r2WasSole = nullptr);
+
// Remove a relayer from the list of relayers of a packet in the history given an ID and sender
void removeRelayer(const uint8_t relayer, const uint32_t id, const NodeNum sender);
diff --git a/src/meshUtils.h b/src/meshUtils.h
index da3a4593b..fe94ead2f 100644
--- a/src/meshUtils.h
+++ b/src/meshUtils.h
@@ -11,6 +11,24 @@ template constexpr const T &clamp(const T &v, const T &lo, const T &hi
return (v < lo) ? lo : (hi < v) ? hi : v;
}
+/// Return the smallest power of 2 >= n (undefined for n > 2^31)
+static inline uint32_t nextPowerOf2(uint32_t n)
+{
+ if (n <= 1)
+ return 1;
+#if defined(__GNUC__)
+ return 1U << (32 - __builtin_clz(n - 1));
+#else
+ n--;
+ n |= n >> 1;
+ n |= n >> 2;
+ n |= n >> 4;
+ n |= n >> 8;
+ n |= n >> 16;
+ return n + 1;
+#endif
+}
+
#if HAS_SCREEN
#define IF_SCREEN(X) \
if (screen) { \
diff --git a/test/test_packet_history/test_main.cpp b/test/test_packet_history/test_main.cpp
new file mode 100644
index 000000000..2453956c5
--- /dev/null
+++ b/test/test_packet_history/test_main.cpp
@@ -0,0 +1,834 @@
+/*
+ * Unit tests for PacketHistory — the packet deduplication engine
+ * used by the mesh routing stack.
+ *
+ * PacketHistory maintains a fixed-size array of PacketRecords with an
+ * optional hash table for O(1) lookup. It tracks which nodes relayed
+ * each packet, supports LRU-style eviction, and detects fallback-to-
+ * flooding and hop-limit upgrades.
+ */
+
+#include "PacketHistory.h"
+
+#include "TestUtil.h"
+#include
+
+// ---------------------------------------------------------------------------
+// Constants
+// ---------------------------------------------------------------------------
+static constexpr uint32_t OUR_NODE_NUM = 0xDEAD1234;
+static constexpr uint8_t OUR_RELAY_ID = 0x34; // getLastByteOfNodeNum(OUR_NODE_NUM)
+static constexpr uint32_t SMALL_CAPACITY = 8;
+
+// ---------------------------------------------------------------------------
+// Per-test state
+// ---------------------------------------------------------------------------
+static PacketHistory *ph = nullptr;
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+static meshtastic_MeshPacket makePacket(uint32_t from, uint32_t id, uint8_t hop_limit = 3,
+ uint8_t next_hop = NO_NEXT_HOP_PREFERENCE, uint8_t relay_node = 0)
+{
+ meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
+ p.from = from;
+ p.id = id;
+ p.hop_limit = hop_limit;
+ p.next_hop = next_hop;
+ p.relay_node = relay_node;
+ return p;
+}
+
+// ---------------------------------------------------------------------------
+// setUp / tearDown — called before and after every test
+// ---------------------------------------------------------------------------
+void setUp(void)
+{
+ myNodeInfo.my_node_num = OUR_NODE_NUM;
+ ph = new PacketHistory(SMALL_CAPACITY);
+}
+
+void tearDown(void)
+{
+ delete ph;
+ ph = nullptr;
+}
+
+// ===========================================================================
+// Group 1 — Initialization
+// ===========================================================================
+
+void test_init_valid_size(void)
+{
+ PacketHistory h(8);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+void test_init_minimum_size(void)
+{
+ PacketHistory h(4);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+void test_init_too_small_falls_back(void)
+{
+ // Sizes < 4 or > PACKETHISTORY_MAX are clamped to PACKETHISTORY_MAX inside the constructor
+ PacketHistory h(2);
+ TEST_ASSERT_TRUE(h.initOk());
+}
+
+// ===========================================================================
+// Group 2 — Basic Deduplication
+// ===========================================================================
+
+void test_first_packet_not_seen(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p));
+}
+
+void test_same_packet_seen_twice(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // first time
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p)); // duplicate
+}
+
+void test_different_id_not_confused(void)
+{
+ auto p1 = makePacket(0x1111, 100);
+ auto p2 = makePacket(0x1111, 200);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2));
+}
+
+void test_different_sender_not_confused(void)
+{
+ auto p1 = makePacket(0x1111, 100);
+ auto p2 = makePacket(0x2222, 100);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p2));
+}
+
+void test_withUpdate_false_no_insert(void)
+{
+ auto p = makePacket(0x1111, 100);
+ // First call with withUpdate=false: should not store
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/false));
+ // Second call with withUpdate=true: still not found because first didn't store
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true));
+}
+
+void test_withUpdate_true_inserts(void)
+{
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, /*withUpdate=*/true));
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, /*withUpdate=*/false)); // found without inserting again
+}
+
+// ===========================================================================
+// Group 3 — LRU Eviction
+// ===========================================================================
+
+void test_fill_capacity_all_found(void)
+{
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+ // All 8 should be found
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+void test_eviction_oldest_replaced(void)
+{
+ // Fill all 8 slots
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+
+ // Advance time so the eviction logic can distinguish "oldest" from "newest".
+ // insert() uses (now_millis - rxTimeMsec) > OldtrxTimeMsec with strict >, so
+ // entries with identical timestamps all have age 0 and none gets selected.
+ delay(1);
+
+ // Insert a 9th packet — should evict the oldest
+ auto p9 = makePacket(0xAAAA, 9);
+ ph->wasSeenRecently(&p9);
+
+ // The 9th should be found
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p9, false));
+
+ // At least one of the originals should have been evicted
+ int evicted = 0;
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ if (!ph->wasSeenRecently(&p, false))
+ evicted++;
+ }
+ TEST_ASSERT_TRUE(evicted > 0);
+}
+
+void test_matching_slot_reused(void)
+{
+ // Insert packet, then re-insert same (sender, id) — should reuse slot, not evict others
+ auto p1 = makePacket(0xAAAA, 1);
+ auto p2 = makePacket(0xBBBB, 2);
+ ph->wasSeenRecently(&p1);
+ ph->wasSeenRecently(&p2);
+
+ // Re-observe p1 (triggers merge path)
+ ph->wasSeenRecently(&p1);
+
+ // Both should still be present
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p1, false));
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
+}
+
+void test_free_slot_preferred(void)
+{
+ // Insert 4 packets into capacity-8 history — next insert should use a free slot, not evict
+ for (uint32_t i = 1; i <= 4; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+ auto p5 = makePacket(0xAAAA, 5);
+ ph->wasSeenRecently(&p5);
+
+ // All 5 should be present (no eviction needed)
+ for (uint32_t i = 1; i <= 5; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+void test_evict_all_old_packets(void)
+{
+ // Fill with packets 1..8
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ ph->wasSeenRecently(&p);
+ }
+
+ // Advance time so the replacement batch can evict the originals
+ delay(1);
+
+ // Replace all with packets 101..108
+ for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xBBBB, i);
+ ph->wasSeenRecently(&p);
+ }
+ // None of the originals should be found
+ for (uint32_t i = 1; i <= SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p, false));
+ }
+ // All new ones should be found
+ for (uint32_t i = 101; i <= 100 + SMALL_CAPACITY; i++) {
+ auto p = makePacket(0xBBBB, i);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+ }
+}
+
+// ===========================================================================
+// Group 4 — Relayer Tracking
+// ===========================================================================
+
+void test_wasRelayer_true(void)
+{
+ // Non-us relay_nodes only enter relayed_by[] through the "heard-back" merge path:
+ // we must have relayed first, then observe the packet return at hop_limit-1.
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Heard-back from 0xCC at hop_limit=2 (ourTxHopLimit-1) triggers the merge
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+void test_wasRelayer_false(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ ph->wasSeenRecently(&p);
+ // 0xCC was never a relayer
+ TEST_ASSERT_FALSE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+void test_wasRelayer_zero_returns_false(void)
+{
+ auto p = makePacket(0x1111, 100);
+ ph->wasSeenRecently(&p);
+ TEST_ASSERT_FALSE(ph->wasRelayer(0, 100, 0x1111));
+}
+
+void test_wasRelayer_not_found(void)
+{
+ // Packet not in history at all
+ TEST_ASSERT_FALSE(ph->wasRelayer(0xAA, 999, 0x9999));
+}
+
+void test_wasRelayer_wasSole_true(void)
+{
+ // relay_node = ourRelayID → relayed_by[0] = ourRelayID
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool wasSole = false;
+ bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole);
+ TEST_ASSERT_TRUE(result);
+ TEST_ASSERT_TRUE(wasSole);
+}
+
+void test_wasRelayer_wasSole_false(void)
+{
+ // First observation: we relay
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second observation: different relayer adds to record
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ bool wasSole = true;
+ bool result = ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole);
+ TEST_ASSERT_TRUE(result);
+ TEST_ASSERT_FALSE(wasSole);
+}
+
+void test_wasRelayer_all_six_slots(void)
+{
+ // First observation: we relay with hop_limit=3 (fills slot 0, ourTxHopLimit=3)
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ // Each heard-back must satisfy: hop_limit == ourTxHopLimit OR ourTxHopLimit-1.
+ // Using hop_limit=2 (ourTxHopLimit-1) for all, which triggers the heard-back
+ // merge path each time. Each new relay_node pushes to slot 0 and shifts existing
+ // relayers right, eventually filling all NUM_RELAYERS(6) slots.
+ uint8_t relayers[] = {0x11, 0x22, 0x33, 0x44, 0x55};
+ for (int i = 0; i < 5; i++) {
+ auto pn = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, relayers[i]);
+ ph->wasSeenRecently(&pn);
+ }
+
+ // All 6 should be detected
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ for (int i = 0; i < 5; i++) {
+ TEST_ASSERT_TRUE(ph->wasRelayer(relayers[i], 100, 0x1111));
+ }
+}
+
+// ===========================================================================
+// Group 5 — removeRelayer
+// ===========================================================================
+
+void test_removeRelayer_removes(void)
+{
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+ TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_removeRelayer_compacts(void)
+{
+ // We relay first
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ // Second relayer
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ // Remove us, 0xBB should still be found
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+ TEST_ASSERT_FALSE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111));
+}
+
+void test_removeRelayer_nonexistent_safe(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+ // Removing a relayer that doesn't exist should not crash
+ ph->removeRelayer(0xFF, 100, 0x1111);
+ // Original should still be there
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_removeRelayer_packet_not_found_safe(void)
+{
+ // Packet not in history — should not crash
+ ph->removeRelayer(0xAA, 999, 0x9999);
+}
+
+// ===========================================================================
+// Group 6 — checkRelayers
+// ===========================================================================
+
+void test_checkRelayers_both_found(void)
+{
+ // We relay first
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+ // Second relayer
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ bool r1 = false, r2 = false;
+ ph->checkRelayers(OUR_RELAY_ID, 0xBB, 100, 0x1111, &r1, &r2);
+ TEST_ASSERT_TRUE(r1);
+ TEST_ASSERT_TRUE(r2);
+}
+
+void test_checkRelayers_one_found(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool r1 = false, r2 = false;
+ ph->checkRelayers(OUR_RELAY_ID, 0xCC, 100, 0x1111, &r1, &r2);
+ TEST_ASSERT_TRUE(r1);
+ TEST_ASSERT_FALSE(r2);
+}
+
+void test_checkRelayers_r2WasSole(void)
+{
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ bool r1 = false, r2 = false, r2Sole = false;
+ // relayer1=0xCC (not found), relayer2=OUR_RELAY_ID (sole relayer)
+ ph->checkRelayers(0xCC, OUR_RELAY_ID, 100, 0x1111, &r1, &r2, &r2Sole);
+ TEST_ASSERT_FALSE(r1);
+ TEST_ASSERT_TRUE(r2);
+ TEST_ASSERT_TRUE(r2Sole);
+}
+
+// ===========================================================================
+// Group 7 — wasSeenRecently Merge Logic
+// ===========================================================================
+
+void test_merge_preserves_original_next_hop(void)
+{
+ // First observation with next_hop=0x55
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with different next_hop
+ auto p2 = makePacket(0x1111, 100, 2, 0x77, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ // The stored next_hop should still be 0x55 (the original)
+ // We verify via weWereNextHop: if we set original next_hop to ourRelayID, it should detect it
+ auto p3 = makePacket(0x1111, 200, 3, OUR_RELAY_ID, 0xAA);
+ ph->wasSeenRecently(&p3);
+ auto p4 = makePacket(0x1111, 200, 2, 0x99, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p4, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_TRUE(weWereNextHop);
+}
+
+void test_merge_preserves_highest_hop_limit(void)
+{
+ // First observation with hop_limit=5
+ auto p1 = makePacket(0x1111, 100, 5);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with hop_limit=2 (lower)
+ auto p2 = makePacket(0x1111, 100, 2);
+ ph->wasSeenRecently(&p2);
+
+ // Third observation with hop_limit=3 should not trigger upgrade (highest was 5)
+ bool wasUpgraded = true;
+ auto p3 = makePacket(0x1111, 100, 3);
+ ph->wasSeenRecently(&p3, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_FALSE(wasUpgraded);
+}
+
+void test_merge_no_duplicate_relayers(void)
+{
+ // Observe with relayer 0xAA (stored via relay_node, but only slot 0 for ourRelayID)
+ // We need to use ourRelayID for the first observation to get it into slot 0
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observe with same relay_node=ourRelayID — should not create duplicates
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p2);
+
+ // ourRelayID should appear exactly once — wasSole should still be true
+ bool wasSole = false;
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111, &wasSole));
+ TEST_ASSERT_TRUE(wasSole);
+}
+
+void test_merge_adds_new_relayer(void)
+{
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xBB, 100, 0x1111));
+}
+
+void test_merge_we_relay_sets_slot_zero(void)
+{
+ // When relay_node == ourRelayID, relayed_by[0] should be set to ourRelayID
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+}
+
+void test_merge_heard_back_stores_relay_node(void)
+{
+ // First: we relay (hop_limit=3)
+ auto p1 = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second: we hear the packet back with hop_limit=2 (one less), from relay_node=0xCC
+ // This triggers the "heard back" logic: weWereRelayer && hop_limit == ourTxHopLimit-1
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xCC);
+ ph->wasSeenRecently(&p2);
+
+ TEST_ASSERT_TRUE(ph->wasRelayer(OUR_RELAY_ID, 100, 0x1111));
+ TEST_ASSERT_TRUE(ph->wasRelayer(0xCC, 100, 0x1111));
+}
+
+// ===========================================================================
+// Group 8 — Fallback-to-Flooding Detection
+// ===========================================================================
+
+void test_fallback_detected(void)
+{
+ // The fallback condition requires wasRelayer(relay_node) && !wasRelayer(ourRelayID).
+ // Non-us relayers only enter relayed_by[] via the heard-back merge path, which
+ // also stores ourRelayID. So we must removeRelayer(ourRelayID) to satisfy both.
+ //
+ // Scenario: we relay a directed packet, hear it back from 0xAA, then the router
+ // removes us from the relayer list. Later the sender falls back to flooding.
+
+ // Step 1: We relay (directed to next_hop=0x55)
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Step 2: Heard-back from 0xAA at hop_limit-1 → stores 0xAA in relayed_by
+ auto p2 = makePacket(0x1111, 100, 2, 0x55, 0xAA);
+ ph->wasSeenRecently(&p2);
+
+ // Step 3: Router removes us from the relayer list
+ ph->removeRelayer(OUR_RELAY_ID, 100, 0x1111);
+
+ // Step 4: Sender falls back to flooding — same packet, NO_NEXT_HOP_PREFERENCE, from 0xAA
+ auto p3 = makePacket(0x1111, 100, 1, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p3, true, &wasFallback);
+ TEST_ASSERT_TRUE(wasFallback);
+}
+
+void test_fallback_not_when_we_relayed(void)
+{
+ // First observation: directed, we relayed it
+ auto p1 = makePacket(0x1111, 100, 3, 0x55, OUR_RELAY_ID);
+ ph->wasSeenRecently(&p1);
+
+ // Second observation: fallback to flooding from same relayer (us)
+ // But since we already relayed, wasFallback should be false
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, OUR_RELAY_ID);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p2, true, &wasFallback);
+ TEST_ASSERT_FALSE(wasFallback);
+}
+
+void test_fallback_not_on_first_observation(void)
+{
+ // First time seen — can't be a fallback
+ auto p = makePacket(0x1111, 100, 3, NO_NEXT_HOP_PREFERENCE, 0xAA);
+ bool wasFallback = false;
+ ph->wasSeenRecently(&p, true, &wasFallback);
+ TEST_ASSERT_FALSE(wasFallback);
+}
+
+// ===========================================================================
+// Group 9 — Next-Hop and Upgrade Detection
+// ===========================================================================
+
+void test_weWereNextHop_true(void)
+{
+ // Packet directed to us (next_hop = ourRelayID)
+ auto p1 = makePacket(0x1111, 100, 3, OUR_RELAY_ID, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observe: check if we were the original next_hop
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_TRUE(weWereNextHop);
+}
+
+void test_weWereNextHop_false(void)
+{
+ // Packet directed to someone else
+ auto p1 = makePacket(0x1111, 100, 3, 0x99, 0xAA);
+ ph->wasSeenRecently(&p1);
+
+ auto p2 = makePacket(0x1111, 100, 2, NO_NEXT_HOP_PREFERENCE, 0xBB);
+ bool weWereNextHop = false;
+ ph->wasSeenRecently(&p2, true, nullptr, &weWereNextHop);
+ TEST_ASSERT_FALSE(weWereNextHop);
+}
+
+void test_wasUpgraded_true(void)
+{
+ // First observation with hop_limit=3 → stored as highestHopLimit bits 0-2 = 3
+ auto p1 = makePacket(0x1111, 100, 3);
+ ph->wasSeenRecently(&p1);
+
+ // Re-observation with hop_limit=5
+ // The upgrade check on line 122 compares the raw packed byte found->hop_limit against p->hop_limit.
+ // found->hop_limit has highestHopLimit=3 in bits 0-2 (and possibly ourTxHopLimit in bits 3-5).
+ // So the packed byte value is 3 (or more if ourTxHopLimit was set), and p->hop_limit is 5.
+ // Since 3 < 5 (with no ourTxHopLimit set), this should detect an upgrade.
+ auto p2 = makePacket(0x1111, 100, 5);
+ bool wasUpgraded = false;
+ ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_TRUE(wasUpgraded);
+}
+
+void test_wasUpgraded_false(void)
+{
+ auto p1 = makePacket(0x1111, 100, 5);
+ ph->wasSeenRecently(&p1);
+
+ // Same or lower hop_limit
+ auto p2 = makePacket(0x1111, 100, 3);
+ bool wasUpgraded = false;
+ ph->wasSeenRecently(&p2, true, nullptr, nullptr, &wasUpgraded);
+ TEST_ASSERT_FALSE(wasUpgraded);
+}
+
+// ===========================================================================
+// Group 10 — Edge Cases
+// ===========================================================================
+
+void test_packet_id_zero_not_stored(void)
+{
+ auto p = makePacket(0x1111, 0);
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p));
+ TEST_ASSERT_FALSE(ph->wasSeenRecently(&p)); // still not found
+}
+
+void test_sender_zero_substituted(void)
+{
+ // from=0 means "from us" — getFrom() substitutes nodeDB->getNodeNum()
+ auto p = makePacket(0, 100);
+ ph->wasSeenRecently(&p);
+
+ // Should be stored under our node num, not 0
+ auto p2 = makePacket(OUR_NODE_NUM, 100);
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p2, false));
+}
+
+void test_uninitialized_wasSeenRecently(void)
+{
+ // Simulate uninitialized state — create a PacketHistory that looks uninitialized
+ // We can't easily make allocation fail, but we can test the initOk guard with a destructed one
+ PacketHistory h(4);
+ TEST_ASSERT_TRUE(h.initOk()); // sanity check
+ h.~PacketHistory();
+
+ auto p = makePacket(0x1111, 100);
+ TEST_ASSERT_FALSE(h.wasSeenRecently(&p));
+
+ // Reconstruct in place to allow proper destruction
+ new (&h) PacketHistory(4);
+}
+
+void test_uninitialized_wasRelayer(void)
+{
+ PacketHistory h(4);
+ h.~PacketHistory();
+
+ TEST_ASSERT_FALSE(h.wasRelayer(0xAA, 100, 0x1111));
+
+ new (&h) PacketHistory(4);
+}
+
+void test_multiple_instances_independent(void)
+{
+ PacketHistory h2(SMALL_CAPACITY);
+
+ auto p = makePacket(0x1111, 100);
+ ph->wasSeenRecently(&p);
+
+ // h2 should NOT find it
+ TEST_ASSERT_FALSE(h2.wasSeenRecently(&p, false));
+
+ // ph should still find it
+ TEST_ASSERT_TRUE(ph->wasSeenRecently(&p, false));
+}
+
+// ===========================================================================
+// Group 11 — Hash Table Stress
+// ===========================================================================
+
+void test_many_packets_no_false_negatives(void)
+{
+ PacketHistory big(64);
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ big.wasSeenRecently(&p);
+ }
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "False negative in hash table");
+ }
+}
+
+void test_many_packets_no_false_positives(void)
+{
+ PacketHistory big(64);
+ for (uint32_t i = 1; i <= 64; i++) {
+ auto p = makePacket(0xAAAA, i);
+ big.wasSeenRecently(&p);
+ }
+ // IDs 65..128 were never inserted
+ for (uint32_t i = 65; i <= 128; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_FALSE_MESSAGE(big.wasSeenRecently(&p, false), "False positive in hash table");
+ }
+}
+
+void test_churn_correctness(void)
+{
+ // Insert 3x capacity to force heavy eviction.
+ // Advance time between each generation so eviction can distinguish old from new.
+ PacketHistory big(32);
+ uint32_t capacity = 32;
+ uint32_t generations = 3;
+
+ for (uint32_t gen = 0; gen < generations; gen++) {
+ if (gen > 0)
+ delay(1); // Ensure new generation has a newer timestamp than the old
+ for (uint32_t i = 1; i <= capacity; i++) {
+ auto p = makePacket(0xAAAA, gen * capacity + i);
+ big.wasSeenRecently(&p);
+ }
+ }
+
+ uint32_t total = capacity * generations;
+
+ // Only the most recent 32 should be present (due to LRU eviction)
+ for (uint32_t i = total - 31; i <= total; i++) {
+ auto p = makePacket(0xAAAA, i);
+ TEST_ASSERT_TRUE_MESSAGE(big.wasSeenRecently(&p, false), "Recent packet lost after churn");
+ }
+ // Older packets should be gone
+ int found = 0;
+ for (uint32_t i = 1; i <= total - capacity; i++) {
+ auto p = makePacket(0xAAAA, i);
+ if (big.wasSeenRecently(&p, false))
+ found++;
+ }
+ TEST_ASSERT_EQUAL_INT_MESSAGE(0, found, "Evicted packets should not be found");
+}
+
+// ===========================================================================
+// Test runner
+// ===========================================================================
+
+void setup()
+{
+ delay(10);
+ delay(2000);
+
+ initializeTestEnvironment();
+ UNITY_BEGIN();
+
+ // Group 1 — Initialization
+ RUN_TEST(test_init_valid_size);
+ RUN_TEST(test_init_minimum_size);
+ RUN_TEST(test_init_too_small_falls_back);
+
+ // Group 2 — Basic Deduplication
+ RUN_TEST(test_first_packet_not_seen);
+ RUN_TEST(test_same_packet_seen_twice);
+ RUN_TEST(test_different_id_not_confused);
+ RUN_TEST(test_different_sender_not_confused);
+ RUN_TEST(test_withUpdate_false_no_insert);
+ RUN_TEST(test_withUpdate_true_inserts);
+
+ // Group 3 — LRU Eviction
+ RUN_TEST(test_fill_capacity_all_found);
+ RUN_TEST(test_eviction_oldest_replaced);
+ RUN_TEST(test_matching_slot_reused);
+ RUN_TEST(test_free_slot_preferred);
+ RUN_TEST(test_evict_all_old_packets);
+
+ // Group 4 — Relayer Tracking
+ RUN_TEST(test_wasRelayer_true);
+ RUN_TEST(test_wasRelayer_false);
+ RUN_TEST(test_wasRelayer_zero_returns_false);
+ RUN_TEST(test_wasRelayer_not_found);
+ RUN_TEST(test_wasRelayer_wasSole_true);
+ RUN_TEST(test_wasRelayer_wasSole_false);
+ RUN_TEST(test_wasRelayer_all_six_slots);
+
+ // Group 5 — removeRelayer
+ RUN_TEST(test_removeRelayer_removes);
+ RUN_TEST(test_removeRelayer_compacts);
+ RUN_TEST(test_removeRelayer_nonexistent_safe);
+ RUN_TEST(test_removeRelayer_packet_not_found_safe);
+
+ // Group 6 — checkRelayers
+ RUN_TEST(test_checkRelayers_both_found);
+ RUN_TEST(test_checkRelayers_one_found);
+ RUN_TEST(test_checkRelayers_r2WasSole);
+
+ // Group 7 — Merge Logic
+ RUN_TEST(test_merge_preserves_original_next_hop);
+ RUN_TEST(test_merge_preserves_highest_hop_limit);
+ RUN_TEST(test_merge_no_duplicate_relayers);
+ RUN_TEST(test_merge_adds_new_relayer);
+ RUN_TEST(test_merge_we_relay_sets_slot_zero);
+ RUN_TEST(test_merge_heard_back_stores_relay_node);
+
+ // Group 8 — Fallback-to-Flooding Detection
+ RUN_TEST(test_fallback_detected);
+ RUN_TEST(test_fallback_not_when_we_relayed);
+ RUN_TEST(test_fallback_not_on_first_observation);
+
+ // Group 9 — Next-Hop and Upgrade Detection
+ RUN_TEST(test_weWereNextHop_true);
+ RUN_TEST(test_weWereNextHop_false);
+ RUN_TEST(test_wasUpgraded_true);
+ RUN_TEST(test_wasUpgraded_false);
+
+ // Group 10 — Edge Cases
+ RUN_TEST(test_packet_id_zero_not_stored);
+ RUN_TEST(test_sender_zero_substituted);
+ RUN_TEST(test_uninitialized_wasSeenRecently);
+ RUN_TEST(test_uninitialized_wasRelayer);
+ RUN_TEST(test_multiple_instances_independent);
+
+ // Group 11 — Hash Table Stress
+ RUN_TEST(test_many_packets_no_false_negatives);
+ RUN_TEST(test_many_packets_no_false_positives);
+ RUN_TEST(test_churn_correctness);
+
+ exit(UNITY_END());
+}
+
+void loop() {}