Files
MeshCore/test/test_forwarding_profile_timing.py
T

207 lines
9.8 KiB
Python

#!/usr/bin/env python3
"""Run actual role forwarding delays against packet-specific radio metadata."""
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import unittest
from test_replay_reset_integration import extract_braced
ROOT = Path(__file__).resolve().parents[1]
ROLES = (
("companion_radio", "MyMesh", None),
("simple_repeater", "MyMesh", "getSlowScopeRetransmitDelay"),
("simple_room_server", "MyMesh", "getSlowFloodRuleRetransmitDelay"),
("simple_sensor", "SensorMesh", None),
)
HARNESS = r'''
#include <cassert>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <initializer_list>
#include <Packet.h>
#include <helpers/FloodFilterPolicy.h>
struct Radio {
uint8_t active = 0, observed_profile = 255, observed_cr = 255;
int observed_length = -1;
unsigned legacy_calls = 0, profile_calls = 0;
static uint32_t airtime(uint8_t profile, int length, uint8_t cr) {
return (17U + 113U * profile) * uint32_t(length) + 7U * cr + 1;
}
uint32_t getEstAirtimeFor(int length) {
++legacy_calls;
return airtime(active, length, 5);
}
uint32_t getProfileAirtime(uint8_t profile, int length, uint8_t cr = 0) {
++profile_calls;
observed_profile = profile; observed_length = length; observed_cr = cr;
return airtime(profile, length, cr);
}
};
struct Rng {
unsigned choice = 0, calls = 0;
uint32_t lower = 0, upper = 0;
uint32_t nextInt(uint32_t minimum, uint32_t maximum) {
assert(maximum > minimum);
++calls; lower = minimum; upper = maximum;
return minimum + (choice == 0 ? 0 : choice == 1
? (maximum - minimum - 1) / 2 : maximum - minimum - 1);
}
};
struct Prefs { float tx_delay_factor = 0, direct_tx_delay_factor = 0; };
template<class Node>
unsigned exercise(uint32_t (Node::*method)(const mesh::Packet*), bool sensor, bool slow) {
unsigned cases = 0;
for (uint8_t profile : {1, 0}) for (uint8_t active : {0, 1})
for (uint8_t route : {ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_FLOOD,
ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT})
for (uint8_t hash_size : {3, 1, 2}) for (uint8_t hops : {7, 0})
for (uint16_t payload : {77, 0, 184}) for (uint8_t cr : {8, 0, 4, 5})
for (float factor : {1.5f, 0.25f, 0.0f}) for (unsigned choice : {2, 1, 0}) {
Node node; Radio radio; radio.active = active; node._radio = &radio;
node._prefs.tx_delay_factor = node._prefs.direct_tx_delay_factor = factor;
node.rng.choice = choice;
mesh::Packet packet;
packet.header = uint8_t(route | (PAYLOAD_TYPE_TXT_MSG << PH_TYPE_SHIFT));
packet.setPathHashSizeAndCount(hash_size, hops);
packet.payload_len = payload; packet.radio_profile = profile; packet.tx_cr = cr;
packet.transport_codes[0] = 0x1234; packet.transport_codes[1] = 0x5678;
memset(packet.path, 0x21, sizeof(packet.path));
memset(packet.payload, 0x43, sizeof(packet.payload));
// Serialization is an independent length boundary, including transport codes.
uint8_t wire[MAX_TRANS_UNIT];
const unsigned wire_length = packet.writeTo(wire);
const uint32_t air = Radio::airtime(profile, wire_length, cr);
const uint32_t scaled = uint32_t(air * factor);
const uint32_t maximum = slow ? 10U * air : 5U * scaled;
const uint32_t upper = sensor ? 6 : maximum + 1;
const uint32_t picked = choice == 0 ? 0 : choice == 1 ? (upper - 1) / 2 : upper - 1;
const uint32_t expected = sensor ? picked * scaled : picked;
const uint32_t delay = (node.*method)(&packet);
if (!cases && delay != expected) fprintf(stderr,
"timing mismatch: observed=%u expected=%u profile=%u active=%u bytes=%u CR=%u\n",
delay, expected, profile, active, wire_length, cr);
assert(radio.profile_calls == 1 && radio.legacy_calls == 0
&& "forwarding must use packet-aware airtime");
assert(radio.observed_profile == profile && "timing must follow packet profile");
assert(radio.observed_length == int(wire_length) && "timing must include complete wire frame");
assert(radio.observed_cr == cr && "timing must include packet coding rate");
assert(node.rng.calls == 1 && node.rng.lower == 0 && node.rng.upper == upper);
assert(delay == expected && "existing role delay distribution must be preserved");
++cases;
}
return cases;
}
'''
class ForwardingProfileTimingTests(unittest.TestCase):
def run_fixture(self, mutation=None):
source, calls = HARNESS, []
for index, (role, owner, slow) in enumerate(ROLES):
methods = ["getRetransmitDelay", "getDirectRetransmitDelay"] + ([slow] if slow else [])
production = (ROOT / f"examples/{role}/{owner}.cpp").read_text()
source += f"\nnamespace role{index} {{\nclass {owner} {{\npublic:\n"
source += "Radio* _radio = nullptr; Prefs _prefs; Rng rng; Rng* getRNG(){return &rng;}\n"
source += "\n".join(f"uint32_t {name}(const mesh::Packet*);" for name in methods)
source += "\n};\n"
for name in methods:
body = extract_braced(production, f"uint32_t {owner}::{name}(")
if mutation:
body = mutation(body)
source += body + "\n"
calls.append(f"cases += exercise(&role{index}::{owner}::{name}, "
f"{'true' if role == 'simple_sensor' else 'false'}, "
f"{'true' if name == slow else 'false'});")
source += "}\n"
source += "int main(){unsigned cases=0;\n" + "\n".join(calls)
source += '\nprintf("PASS: %u actual-role profile timing cases\\n", cases);}\n'
return self.compile_fixture(source)
def compile_fixture(self, source):
compiler = shutil.which("g++") or shutil.which("clang++")
self.assertIsNotNone(compiler, "a host C++17 compiler is required")
with tempfile.TemporaryDirectory(prefix="forwarding-profile-timing-") as directory:
work = Path(directory)
cpp, binary = work / "timing.cpp", work / "timing"
cpp.write_text(source, encoding="ascii")
sanitize = (["-fsanitize=address,undefined", "-fno-sanitize-recover=all",
"-fno-pie", "-no-pie"] if sys.platform.startswith("linux") else [])
built = subprocess.run([compiler, "-std=c++17", "-Wall", "-Wextra", "-Werror",
"-Wno-unused-parameter",
*sanitize, "-I" + str(ROOT / "src"), "-I" + str(ROOT / "test/mocks"),
str(cpp), str(ROOT / "src/Packet.cpp"), "-o", str(binary)],
capture_output=True, text=True, timeout=60)
self.assertEqual(built.returncode, 0, built.stdout + built.stderr)
return subprocess.run([str(binary)], capture_output=True, text=True, timeout=20)
def test_actual_role_delays_use_packet_profile_wire_length_and_coding_rate(self):
result = self.run_fixture()
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS: 103680 actual-role profile timing cases", result.stdout)
def test_suite_is_explicitly_wired_in_ci(self):
workflow = (ROOT / ".github/workflows/run-unit-tests.yml").read_text()
self.assertIn("python3 -B test/test_forwarding_profile_timing.py -v", workflow)
def test_actual_driver_preserves_single_profile_default_cr_airtime(self):
driver = (ROOT / "src/helpers/radiolib/RadioLibWrappers.cpp").read_text()
method = extract_braced(driver, "uint32_t RadioLibWrapper::getProfileAirtime(")
harness = r'''
#include <cassert>
#include <cstdio>
#include <RadioProfiles.h>
#include <Packet.h>
class RadioLibWrapper {
public:
mesh::RadioProfiles _profiles;
bool validateProfile(const mesh::RadioProfileParams& p) const { return mesh::RadioProfiles::valid(p); }
unsigned legacy_calls = 0;
uint32_t getEstAirtimeFor(int length) { ++legacy_calls; return uint32_t(17 * length + 3); }
uint16_t profilePreamble(uint8_t profile) { return _profiles.params(profile).preamble; }
uint32_t getProfileAirtime(uint8_t profile, int length, uint8_t cr);
};
'''
harness += method + r'''
int main() {
RadioLibWrapper radio;
assert(!radio._profiles.enabled());
for (int length = 0; length <= 255; ++length) {
radio.legacy_calls = 0;
assert(radio.getProfileAirtime(0, length, 0) == uint32_t(17 * length + 3));
assert(radio.legacy_calls == 1 && "single-profile default-CR must use existing driver estimate");
}
puts("PASS: 256 actual-driver legacy airtime cases");
}
'''
result = self.compile_fixture(harness)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn("PASS: 256 actual-driver legacy airtime cases", result.stdout)
def test_metadata_regressions_are_detected(self):
def replace(body, pattern, replacement):
changed, count = re.subn(pattern, replacement, body)
self.assertEqual(count, 1, "production timing boundary changed")
return changed
mutations = (
(r"_radio->getProfileAirtime\(\s*packet->radio_profile,\s*packet->getRawLength\(\),\s*packet->tx_cr\)",
"_radio->getEstAirtimeFor(packet->getRawLength())", "forwarding must use packet-aware airtime"),
(r"packet->radio_profile,", "_radio->active,", "timing must follow packet profile"),
(r"packet->getRawLength\(\)", "packet->getPathByteLen() + packet->payload_len + 2",
"timing must include complete wire frame"),
(r"packet->tx_cr", "0", "timing must include packet coding rate"),
)
for pattern, replacement, oracle in mutations:
with self.subTest(oracle=oracle):
result = self.run_fixture(lambda body: replace(body, pattern, replacement))
self.assertNotEqual(result.returncode, 0, "timing regression escaped its oracle")
self.assertIn(oracle, result.stderr)
if __name__ == "__main__":
unittest.main()