mirror of https://github.com/meshcore-dev/MeshCore
committed by
GitHub
13 changed files with 508 additions and 14 deletions
@ -0,0 +1,58 @@ |
|||
#pragma once |
|||
|
|||
// Minimal Arduino.h shim for native (host) unit tests.
|
|||
// Only provides what the headers pulled in by RegionMap / TransportKeyStore /
|
|||
// IdentityStore need to compile off-device. File / FILESYSTEM are inert stubs:
|
|||
// the region-gating tests exercise in-memory logic and never touch the filesystem.
|
|||
|
|||
#include <cstdint> |
|||
#include <cstddef> |
|||
#include <cstring> |
|||
#include <cstdio> |
|||
#include <cstdlib> |
|||
#include <cmath> |
|||
#include <string> |
|||
|
|||
#include "Stream.h" |
|||
|
|||
// ---- Arduino string helpers not present in the host libc --------------------
|
|||
static inline char* ltoa(long value, char* result, int base) { |
|||
if (base < 2 || base > 36) { *result = '\0'; return result; } |
|||
char* ptr = result; |
|||
char* low = result; |
|||
if (value < 0 && base == 10) { *ptr++ = '-'; low = ptr; } |
|||
unsigned long uval = (value < 0 && base == 10) ? (unsigned long)(-value) : (unsigned long)value; |
|||
do { |
|||
int digit = (int)(uval % (unsigned long)base); |
|||
*ptr++ = (char)(digit < 10 ? '0' + digit : 'a' + digit - 10); |
|||
uval /= (unsigned long)base; |
|||
} while (uval); |
|||
*ptr-- = '\0'; |
|||
while (low < ptr) { char t = *low; *low++ = *ptr; *ptr-- = t; } |
|||
return result; |
|||
} |
|||
|
|||
// ---- filesystem stubs -------------------------------------------------------
|
|||
class File { |
|||
public: |
|||
operator bool() const { return false; } |
|||
int read(uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; } |
|||
size_t write(const uint8_t* buf, size_t len) { (void)buf; (void)len; return 0; } |
|||
void close() {} |
|||
}; |
|||
|
|||
class FsMock { |
|||
public: |
|||
bool exists(const char* path) { (void)path; return false; } |
|||
bool remove(const char* path) { (void)path; return false; } |
|||
bool mkdir(const char* path) { (void)path; return false; } |
|||
File open(const char* path) { (void)path; return File(); } |
|||
File open(const char* path, const char* mode) { (void)path; (void)mode; return File(); } |
|||
File open(const char* path, const char* mode, bool create) { |
|||
(void)path; (void)mode; (void)create; return File(); |
|||
} |
|||
}; |
|||
|
|||
#ifndef FILESYSTEM |
|||
#define FILESYSTEM FsMock |
|||
#endif |
|||
@ -1,10 +1,43 @@ |
|||
#pragma once |
|||
|
|||
// Mock Stream class for native testing
|
|||
// Provides minimal interface needed by Utils.h
|
|||
// Mock Stream class for native testing.
|
|||
// Provides the Print/Stream surface used by Utils.cpp and RegionMap.cpp (BufStream).
|
|||
|
|||
#include <cstdint> |
|||
#include <cstddef> |
|||
#include <cstdio> |
|||
#include <cstdarg> |
|||
#include <cstring> |
|||
|
|||
class Stream { |
|||
public: |
|||
virtual void print(char c) {} |
|||
virtual void print(const char* str) {} |
|||
virtual ~Stream() {} |
|||
|
|||
virtual size_t write(uint8_t c) { (void)c; return 1; } |
|||
virtual size_t write(const uint8_t* buffer, size_t size) { |
|||
size_t written = 0; |
|||
while (written < size) { |
|||
if (!write(buffer[written])) break; |
|||
written++; |
|||
} |
|||
return written; |
|||
} |
|||
|
|||
virtual int available() { return 0; } |
|||
virtual int read() { return -1; } |
|||
virtual int peek() { return -1; } |
|||
virtual void flush() {} |
|||
|
|||
size_t print(char c) { return write((uint8_t)c); } |
|||
size_t print(const char* str) { return write((const uint8_t*)str, strlen(str)); } |
|||
size_t printf(const char* fmt, ...) { |
|||
char buf[256]; |
|||
va_list ap; |
|||
va_start(ap, fmt); |
|||
int n = vsnprintf(buf, sizeof(buf), fmt, ap); |
|||
va_end(ap); |
|||
if (n < 0) return 0; |
|||
if (n > (int)sizeof(buf) - 1) n = sizeof(buf) - 1; |
|||
return write((const uint8_t*)buf, (size_t)n); |
|||
} |
|||
}; |
|||
|
|||
@ -0,0 +1,229 @@ |
|||
#include <gtest/gtest.h> |
|||
#include "helpers/RegionMap.h" |
|||
|
|||
// Unit tests for duty-cycle region gating (issue #2747).
|
|||
// These exercise the pure hierarchy/level logic; no crypto or filesystem is touched.
|
|||
// Each test covers one gating scenario (one level, one transition, or one invariant)
|
|||
// so a failure points directly at the behaviour that broke.
|
|||
|
|||
static bool gated(RegionMap& rm, const char* name) { |
|||
RegionEntry* r = rm.findByName(name); // findByName("*") returns the wildcard
|
|||
return r && (r->rt_flags & REGION_DENY_FLOOD); |
|||
} |
|||
|
|||
// Fixture: * > eu > nl > nl-ge > nl-ge-nij (a single deep chain)
|
|||
class NlChainGating : public ::testing::Test { |
|||
protected: |
|||
TransportKeyStore ks; |
|||
RegionMap rm{ks}; |
|||
|
|||
void SetUp() override { |
|||
RegionEntry* eu = rm.putRegion("eu", 0); |
|||
RegionEntry* nl = rm.putRegion("nl", eu->id); |
|||
RegionEntry* nlge = rm.putRegion("nl-ge", nl->id); |
|||
rm.putRegion("nl-ge-nij", nlge->id); |
|||
} |
|||
}; |
|||
|
|||
// Fixture: * > cz > { cz-ulk, cz-stc, cz-lbk } (three regions on the same level)
|
|||
class CzFlatGating : public ::testing::Test { |
|||
protected: |
|||
TransportKeyStore ks; |
|||
RegionMap rm{ks}; |
|||
|
|||
void SetUp() override { |
|||
RegionEntry* cz = rm.putRegion("cz", 0); |
|||
rm.putRegion("cz-ulk", cz->id); |
|||
rm.putRegion("cz-stc", cz->id); |
|||
rm.putRegion("cz-lbk", cz->id); |
|||
} |
|||
}; |
|||
|
|||
// ---------- depth model ----------
|
|||
|
|||
TEST_F(NlChainGating, MaxDepthCountsChainDepth) { |
|||
EXPECT_EQ(rm.getMaxDepth(), 4); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, MaxGateLevelEqualsMaxDepth) { |
|||
EXPECT_EQ(rm.getMaxGateLevel(), 4); |
|||
} |
|||
|
|||
// ---------- gating, level by level ----------
|
|||
|
|||
TEST_F(NlChainGating, LevelZeroGatesNothing) { |
|||
rm.applyDutyGate(0); |
|||
EXPECT_FALSE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "eu")); |
|||
EXPECT_FALSE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, LevelOneGatesOnlyWildcard) { |
|||
rm.applyDutyGate(1); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "eu")); |
|||
EXPECT_FALSE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, LevelTwoAddsBroadestNamedRegion) { |
|||
rm.applyDutyGate(2); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "eu")); |
|||
EXPECT_FALSE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, LevelThreeGatesDownToSecondDeepest) { |
|||
rm.applyDutyGate(3); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "eu")); |
|||
EXPECT_TRUE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, MaxLevelProtectsOnlyInnermostCluster) { |
|||
rm.applyDutyGate(4); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "eu")); |
|||
EXPECT_TRUE(gated(rm, "nl")); |
|||
EXPECT_TRUE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, LevelAboveMaxStillProtectsInnermostCluster) { |
|||
rm.applyDutyGate(99); |
|||
EXPECT_TRUE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
// ---------- recovery ----------
|
|||
|
|||
TEST_F(NlChainGating, RecoveryStepDownReenablesInsideOut) { |
|||
rm.applyDutyGate(4); |
|||
rm.applyDutyGate(2); // step back down: nl and nl-ge come back first
|
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "eu")); |
|||
EXPECT_FALSE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, RecoveryToZeroClearsAllGates) { |
|||
rm.applyDutyGate(4); |
|||
rm.applyDutyGate(0); |
|||
EXPECT_FALSE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "eu")); |
|||
EXPECT_FALSE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
// ---------- home region exemption ----------
|
|||
|
|||
TEST_F(NlChainGating, HomeRegionIsNeverGated) { |
|||
rm.setHomeRegion(rm.findByName("nl-ge")); // home NOT at the deepest level
|
|||
rm.applyDutyGate(4); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "eu")); |
|||
EXPECT_TRUE(gated(rm, "nl")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge")); // exempt: it's the home region
|
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); // exempt: innermost layer
|
|||
} |
|||
|
|||
TEST_F(NlChainGating, BroadHomeRegionIsAlsoExempt) { |
|||
rm.setHomeRegion(rm.findByName("eu")); // operator chose a broad home region
|
|||
rm.applyDutyGate(4); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "eu")); // exempt, even though it is depth 1
|
|||
EXPECT_TRUE(gated(rm, "nl")); |
|||
EXPECT_TRUE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, ClearingHomeRegionRestoresDepthOnlyGating) { |
|||
rm.setHomeRegion(rm.findByName("nl-ge")); |
|||
rm.applyDutyGate(4); |
|||
rm.setHomeRegion(NULL); |
|||
rm.applyDutyGate(4); // gate state is recomputed on every application
|
|||
EXPECT_TRUE(gated(rm, "nl-ge")); |
|||
EXPECT_FALSE(gated(rm, "nl-ge-nij")); |
|||
} |
|||
|
|||
// ---------- multiple regions on the same level ----------
|
|||
|
|||
TEST_F(CzFlatGating, MaxGateLevelForTwoLevelTree) { |
|||
EXPECT_EQ(rm.getMaxDepth(), 2); |
|||
EXPECT_EQ(rm.getMaxGateLevel(), 2); |
|||
} |
|||
|
|||
TEST_F(CzFlatGating, LevelOneGatesOnlyWildcard) { |
|||
rm.applyDutyGate(1); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "cz")); |
|||
EXPECT_FALSE(gated(rm, "cz-ulk")); |
|||
EXPECT_FALSE(gated(rm, "cz-stc")); |
|||
EXPECT_FALSE(gated(rm, "cz-lbk")); |
|||
} |
|||
|
|||
TEST_F(CzFlatGating, MaxLevelKeepsAllLeavesOnSameLevel) { |
|||
rm.applyDutyGate(2); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_TRUE(gated(rm, "cz")); |
|||
EXPECT_FALSE(gated(rm, "cz-ulk")); |
|||
EXPECT_FALSE(gated(rm, "cz-stc")); |
|||
EXPECT_FALSE(gated(rm, "cz-lbk")); |
|||
} |
|||
|
|||
// ---------- degenerate configurations ----------
|
|||
|
|||
TEST(RegionGatingEdgeCases, LoneWildcardIsNeverGated) { |
|||
TransportKeyStore ks; |
|||
RegionMap rm(ks); // no named regions: the wildcard IS the local cluster
|
|||
EXPECT_EQ(rm.getMaxDepth(), 0); |
|||
EXPECT_EQ(rm.getMaxGateLevel(), 0); |
|||
rm.applyDutyGate(1); |
|||
EXPECT_FALSE(gated(rm, "*")); |
|||
} |
|||
|
|||
TEST(RegionGatingEdgeCases, SingleNamedRegionGatesWildcardButKeepsRegion) { |
|||
TransportKeyStore ks; |
|||
RegionMap rm(ks); |
|||
rm.putRegion("cz", 0); |
|||
EXPECT_EQ(rm.getMaxGateLevel(), 1); |
|||
rm.applyDutyGate(1); |
|||
EXPECT_TRUE(gated(rm, "*")); |
|||
EXPECT_FALSE(gated(rm, "cz")); |
|||
} |
|||
|
|||
// ---------- config flags vs runtime gate ----------
|
|||
|
|||
TEST_F(NlChainGating, RuntimeGateDoesNotTouchConfigFlags) { |
|||
RegionEntry* eu = rm.findByName("eu"); |
|||
eu->flags &= (uint8_t)~REGION_DENY_FLOOD; // admin: this region allows flood
|
|||
|
|||
rm.applyDutyGate(2); // transiently gate 'eu'
|
|||
EXPECT_TRUE(eu->rt_flags & REGION_DENY_FLOOD); |
|||
EXPECT_FALSE(eu->flags & REGION_DENY_FLOOD); // config untouched
|
|||
EXPECT_TRUE(eu->effectiveFlags() & REGION_DENY_FLOOD); |
|||
} |
|||
|
|||
TEST_F(NlChainGating, RecoveryRestoresConfiguredBehaviour) { |
|||
RegionEntry* eu = rm.findByName("eu"); |
|||
eu->flags &= (uint8_t)~REGION_DENY_FLOOD; |
|||
|
|||
rm.applyDutyGate(2); |
|||
rm.applyDutyGate(0); |
|||
EXPECT_FALSE(eu->rt_flags & REGION_DENY_FLOOD); |
|||
EXPECT_FALSE(eu->effectiveFlags() & REGION_DENY_FLOOD); // flood allowed again
|
|||
} |
|||
|
|||
int main(int argc, char** argv) { |
|||
::testing::InitGoogleTest(&argc, argv); |
|||
return RUN_ALL_TESTS(); |
|||
} |
|||
Loading…
Reference in new issue