Files
pyMC_Repeater/tests/test_engine.py
T
Lloyd 37ee0e892a Add more unit tests for handler helpers, identity manager, CLI, key generation, and main functionality
- Introduced tests for TraceHelper and DiscoveryHelper to validate packet forwarding and discovery request handling.
- Implemented tests for LoginHelper to ensure identity registration and login packet processing.
- Added tests for IdentityManager to cover identity registration, lookup, and filtering.
- Created tests for MeshCLI to verify command handling, configuration setting, and error paths.
2026-05-26 13:01:38 +01:00

2001 lines
80 KiB
Python

"""
tests for pyMC_Repeater engine.py — RepeaterHandler.
Covers: flood_forward, direct_forward, process_packet, duplicate detection,
mark_seen, validate_packet, packet scoring, TX delay, cache management,
airtime duty-cycle, TX mode (forward/monitor/no_tx), and config reloading.
"""
import asyncio
import base64
import copy
import math
import time
from collections import OrderedDict
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pymc_core.protocol import Packet, PacketBuilder
from pymc_core.protocol.constants import (
MAX_PATH_SIZE,
PH_ROUTE_MASK,
PH_TYPE_SHIFT,
ROUTE_TYPE_DIRECT,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_DIRECT,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
# ---------------------------------------------------------------------------
# Helpers — build minimal config / mocks needed by RepeaterHandler.__init__
# ---------------------------------------------------------------------------
LOCAL_HASH = 0xAB # repeater's own 1-byte path hash
def _make_config(**overrides) -> dict:
"""Return a minimal valid config dict for RepeaterHandler."""
cfg = {
"repeater": {
"mode": "forward",
"cache_ttl": 3600,
"use_score_for_tx": False,
"score_threshold": 0.3,
"send_advert_interval_hours": 0, # off in tests
"node_name": "test-node",
},
"mesh": {
"unscoped_flood_allow": True,
"loop_detect": "off",
},
"delays": {
"tx_delay_factor": 1.0,
"direct_tx_delay_factor": 0.5,
},
"duty_cycle": {
"max_airtime_per_minute": 3600,
"enforcement_enabled": True,
},
"radio": {
"spreading_factor": 8,
"bandwidth": 125000,
"coding_rate": 8,
"preamble_length": 17,
},
}
# Merge overrides
for key, val in overrides.items():
if isinstance(val, dict) and key in cfg:
cfg[key].update(val)
else:
cfg[key] = val
return cfg
def _make_radio():
"""Return a mock radio with sensible defaults."""
radio = MagicMock()
radio.spreading_factor = 8
radio.bandwidth = 125000
radio.coding_rate = 8
radio.preamble_length = 17
radio.frequency = 915000000
radio.tx_power = 14
return radio
def _make_dispatcher(radio=None):
"""Return a mock dispatcher with a radio and local_identity."""
dispatcher = MagicMock()
dispatcher.radio = radio or _make_radio()
dispatcher.local_identity = MagicMock()
dispatcher.send_packet = AsyncMock()
return dispatcher
@pytest.fixture()
def handler():
"""Create a RepeaterHandler with mocked external dependencies."""
config = _make_config()
dispatcher = _make_dispatcher()
with (
patch("repeater.engine.StorageCollector"),
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
):
from repeater.engine import RepeaterHandler
h = RepeaterHandler(config, dispatcher, LOCAL_HASH)
return h
def _make_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
path: bytes = b"",
payload_type: int = 0x01) -> Packet:
"""Build a FLOOD-routed packet."""
pkt = Packet()
# header: route=FLOOD(0x01), payload_type shifted, version=0
pkt.header = ROUTE_TYPE_FLOOD | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
return pkt
def _make_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
path: bytes = None,
payload_type: int = 0x01) -> Packet:
"""Build a DIRECT-routed packet with path[0] == LOCAL_HASH by default."""
if path is None:
path = bytes([LOCAL_HASH, 0xCC, 0xDD])
pkt = Packet()
pkt.header = ROUTE_TYPE_DIRECT | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
return pkt
def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
path: bytes = b"",
payload_type: int = 0x01,
transport_codes=(0x1234, 0x5678)) -> Packet:
"""Build a TRANSPORT_FLOOD-routed packet."""
pkt = Packet()
pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
pkt.transport_codes = list(transport_codes)
return pkt
def _make_transport_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
path: bytes = None,
payload_type: int = 0x01,
transport_codes=(0x1234, 0x5678)) -> Packet:
"""Build a TRANSPORT_DIRECT-routed packet with path[0] == LOCAL_HASH."""
if path is None:
path = bytes([LOCAL_HASH, 0xCC])
pkt = Packet()
pkt.header = ROUTE_TYPE_TRANSPORT_DIRECT | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
pkt.path_len = len(path)
pkt.transport_codes = list(transport_codes)
return pkt
# ===================================================================
# 1. flood_forward
# ===================================================================
class TestFloodForward:
"""flood_forward: validation, duplicate suppression, path append."""
def test_valid_flood_returns_packet(self, handler):
pkt = _make_flood_packet()
result = handler.flood_forward(pkt)
assert result is not None
assert result is pkt # mutated in-place
def test_local_hash_appended_to_path(self, handler):
pkt = _make_flood_packet(path=b"\x11\x22")
result = handler.flood_forward(pkt)
assert result.path[-1] == LOCAL_HASH
assert list(result.path) == [0x11, 0x22, LOCAL_HASH]
assert result.path_len == 3
def test_empty_path_gets_local_hash(self, handler):
pkt = _make_flood_packet(path=b"")
result = handler.flood_forward(pkt)
assert list(result.path) == [LOCAL_HASH]
assert result.path_len == 1
def test_duplicate_flood_dropped(self, handler):
pkt = _make_flood_packet()
handler.flood_forward(pkt)
pkt2 = _make_flood_packet() # same payload → same hash
result = handler.flood_forward(pkt2)
assert result is None
assert pkt2.drop_reason == "Duplicate"
def test_empty_payload_rejected(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
result = handler.flood_forward(pkt)
assert result is None
assert "Empty payload" in pkt.drop_reason
def test_none_payload_rejected(self, handler):
pkt = _make_flood_packet()
pkt.payload = None
result = handler.flood_forward(pkt)
assert result is None
def test_path_at_max_rejected(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE)))
result = handler.flood_forward(pkt)
assert result is None
assert "Path length" in pkt.drop_reason
def test_do_not_retransmit_dropped(self, handler):
pkt = _make_flood_packet()
pkt.mark_do_not_retransmit()
result = handler.flood_forward(pkt)
assert result is None
assert "do not retransmit" in pkt.drop_reason.lower()
def test_unscoped_flood_deny_plain_flood(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
# When unscoped_flood_allow=False, flood_forward should fail on a packet type without a transport code defined
result = handler.flood_forward(pkt)
assert result is None
def test_hash_computed_before_path_append(self, handler):
"""mark_seen must use the pre-append hash so duplicate detection works
when another node sends the same packet with or without our hash."""
pkt1 = _make_flood_packet(payload=b"\xAA\xBB")
hash_before = pkt1.calculate_packet_hash().hex().upper()
handler.flood_forward(pkt1)
# The hash stored in seen_packets should be the PRE-append hash
assert hash_before in handler.seen_packets
def test_path_len_updated_after_append(self, handler):
pkt = _make_flood_packet(path=b"\x11")
handler.flood_forward(pkt)
assert pkt.path_len == len(pkt.path) == 2
def test_different_payloads_not_duplicate(self, handler):
pkt1 = _make_flood_packet(payload=b"\x01")
pkt2 = _make_flood_packet(payload=b"\x02")
r1 = handler.flood_forward(pkt1)
r2 = handler.flood_forward(pkt2)
assert r1 is not None
assert r2 is not None
def test_path_none_is_handled(self, handler):
pkt = _make_flood_packet()
pkt.path = None
pkt.path_len = 0
result = handler.flood_forward(pkt)
assert result is not None
assert list(result.path) == [LOCAL_HASH]
# ===================================================================
# 2. direct_forward
# ===================================================================
class TestDirectForward:
"""direct_forward: next-hop check, path consumption, duplicate suppression."""
def test_valid_direct_returns_packet(self, handler):
pkt = _make_direct_packet()
result = handler.direct_forward(pkt)
assert result is not None
def test_path_consumed(self, handler):
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC, 0xDD]))
result = handler.direct_forward(pkt)
assert list(result.path) == [0xCC, 0xDD]
assert result.path_len == 2
def test_single_hop_path_consumed(self, handler):
"""Single hop to us: we strip and return packet with empty path (forward so it can reach destination)."""
pkt = _make_direct_packet(path=bytes([LOCAL_HASH]))
result = handler.direct_forward(pkt)
assert result is not None
assert list(result.path) == []
assert result.path_len == 0
def test_wrong_next_hop_dropped(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF, 0xCC]))
result = handler.direct_forward(pkt)
assert result is None
assert "not for us" in pkt.drop_reason
def test_empty_path_dropped(self, handler):
pkt = _make_direct_packet(path=b"")
pkt.path_len = 0
result = handler.direct_forward(pkt)
assert result is None
assert "no path" in pkt.drop_reason
def test_none_path_dropped(self, handler):
pkt = Packet()
pkt.header = ROUTE_TYPE_DIRECT | (0x01 << PH_TYPE_SHIFT)
pkt.payload = bytearray(b"\x01\x02")
pkt.payload_len = 2
pkt.path = None
pkt.path_len = 0
result = handler.direct_forward(pkt)
assert result is None
def test_duplicate_direct_dropped(self, handler):
pkt = _make_direct_packet()
handler.direct_forward(pkt)
pkt2 = _make_direct_packet() # same payload → same hash
result = handler.direct_forward(pkt2)
assert result is None
assert pkt2.drop_reason == "Duplicate"
def test_hash_computed_before_path_consume(self, handler):
"""mark_seen hash must match the packet as received, before path[0] removal."""
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC]))
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.direct_forward(pkt)
assert hash_before in handler.seen_packets
def test_path_len_updated_after_consume(self, handler):
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0xCC, 0xDD]))
handler.direct_forward(pkt)
assert pkt.path_len == len(pkt.path) == 2
# ===================================================================
# 3. process_packet — route dispatch
# ===================================================================
class TestProcessPacket:
"""process_packet routes to flood_forward or direct_forward."""
def test_flood_route_dispatched(self, handler):
pkt = _make_flood_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, delay = result
# Flood appends local hash
assert fwd_pkt.path[-1] == LOCAL_HASH
def test_direct_route_dispatched(self, handler):
pkt = _make_direct_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, delay = result
# Direct consumed first hop
assert LOCAL_HASH not in fwd_pkt.path
def test_transport_flood_dispatched(self, handler):
pkt = _make_transport_flood_packet()
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.path[-1] == LOCAL_HASH
def test_transport_direct_dispatched(self, handler):
pkt = _make_transport_direct_packet()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
def test_unknown_route_type_dropped(self, handler):
"""A header with route bits = 0x03 is TRANSPORT_DIRECT which IS handled.
We hackily create a truly unknown route by patching both masks."""
pkt = _make_flood_packet()
# All 4 route types are handled. We can verify the fallback by
# ensuring flood forward fails, e.g. empty payload → returns None.
pkt.payload = None
result = handler.process_packet(pkt, snr=0.0)
assert result is None
def test_returns_tuple_with_delay(self, handler):
pkt = _make_flood_packet()
result = handler.process_packet(pkt, snr=5.0)
assert isinstance(result, tuple)
assert len(result) == 2
fwd_pkt, delay = result
assert isinstance(delay, float)
assert delay >= 0.0
def test_flood_forward_failure_returns_none(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
result = handler.process_packet(pkt, snr=0.0)
assert result is None
def test_direct_forward_failure_returns_none(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF])) # wrong hop
result = handler.process_packet(pkt, snr=0.0)
assert result is None
# ===================================================================
# 4. is_duplicate / mark_seen / cache management
# ===================================================================
class TestDuplicateDetection:
"""Duplicate tracking, TTL clean-up, and cache eviction."""
def test_unseen_packet_not_duplicate(self, handler):
pkt = _make_flood_packet()
assert handler.is_duplicate(pkt) is False
def test_after_mark_seen_is_duplicate(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
assert handler.is_duplicate(pkt) is True
def test_different_packets_independent(self, handler):
pkt1 = _make_flood_packet(payload=b"\x01")
pkt2 = _make_flood_packet(payload=b"\x02")
handler.mark_seen(pkt1)
assert handler.is_duplicate(pkt1) is True
assert handler.is_duplicate(pkt2) is False
def test_cache_eviction_at_max_size(self, handler):
handler.max_cache_size = 5
packets = [_make_flood_packet(payload=bytes([i])) for i in range(6)]
for p in packets:
handler.mark_seen(p)
# Oldest (packets[0]) should have been evicted
assert handler.is_duplicate(packets[0]) is False
assert handler.is_duplicate(packets[5]) is True
def test_cleanup_cache_removes_expired(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
pkt_hash = pkt.calculate_packet_hash().hex().upper()
# Manually expire it
handler.seen_packets[pkt_hash] = time.time() - handler.cache_ttl - 1
handler.cleanup_cache()
assert handler.is_duplicate(pkt) is False
def test_cleanup_cache_keeps_fresh(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
handler.cleanup_cache()
assert handler.is_duplicate(pkt) is True
def test_mark_seen_stores_hex_upper_key(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
for key in handler.seen_packets:
assert key == key.upper()
# ===================================================================
# 5. validate_packet
# ===================================================================
class TestValidatePacket:
"""validate_packet: empty payload, oversized path."""
def test_valid_packet(self, handler):
pkt = _make_flood_packet()
valid, reason = handler.validate_packet(pkt)
assert valid is True
assert reason == ""
def test_empty_payload_fails(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload = None
valid, reason = handler.validate_packet(pkt)
assert valid is False
assert "Empty" in reason
def test_path_at_max_fails(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE)))
valid, reason = handler.validate_packet(pkt)
assert valid is False
assert "MAX_PATH_SIZE" in reason
def test_path_one_below_max_passes(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE - 1)))
valid, reason = handler.validate_packet(pkt)
assert valid is True
def test_none_packet(self, handler):
valid, reason = handler.validate_packet(None)
assert valid is False
# ===================================================================
# 6. calculate_packet_score — static method
# ===================================================================
class TestPacketScore:
"""Score: SNR thresholds, collision penalty, clamping."""
def test_below_threshold_returns_zero(self):
from repeater.engine import RepeaterHandler
# SF8 threshold is -10.0
score = RepeaterHandler.calculate_packet_score(snr=-15.0, packet_len=50, spreading_factor=8)
assert score == 0.0
def test_at_threshold_returns_zero(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=-10.0, packet_len=50, spreading_factor=8)
assert score == 0.0
def test_above_threshold_positive(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=0.0, packet_len=50, spreading_factor=8)
assert score > 0.0
def test_high_snr_high_score(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=10, spreading_factor=8)
assert score > 0.5
def test_long_packet_collision_penalty(self):
from repeater.engine import RepeaterHandler
short = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=10, spreading_factor=8)
long_ = RepeaterHandler.calculate_packet_score(snr=5.0, packet_len=250, spreading_factor=8)
assert short > long_
def test_score_clamped_to_0_1(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=50.0, packet_len=1, spreading_factor=8)
assert 0.0 <= score <= 1.0
def test_sf_below_7_returns_zero(self):
from repeater.engine import RepeaterHandler
score = RepeaterHandler.calculate_packet_score(snr=10.0, packet_len=50, spreading_factor=6)
assert score == 0.0
def test_each_sf_has_different_threshold(self):
from repeater.engine import RepeaterHandler
scores = {}
for sf in (7, 8, 9, 10, 11, 12):
scores[sf] = RepeaterHandler.calculate_packet_score(snr=-5.0, packet_len=50, spreading_factor=sf)
# Higher SF → lower threshold → better reception at same SNR
# At SNR=-5, SF7 (threshold -7.5) should be worse than SF12 (threshold -20)
assert scores[12] > scores[7]
# ===================================================================
# 7. _calculate_tx_delay
# ===================================================================
class TestTxDelay:
"""TX delay: flood random, direct fixed, score adjustment, cap."""
def test_flood_delay_non_negative(self, handler):
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay >= 0.0
def test_flood_delay_capped_at_5s(self, handler):
handler.tx_delay_factor = 1000.0 # extreme multiplier
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay <= 5.0
def test_direct_delay_uses_factor(self, handler):
handler.direct_tx_delay_factor = 1.23
pkt = _make_direct_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
# Direct packets use direct_tx_delay_factor directly (in seconds)
# Score adjustment may change it, but base should be 1.23 when score is off
assert delay == pytest.approx(1.23, abs=0.01)
def test_score_adjustment_reduces_delay(self, handler):
handler.use_score_for_tx = True
pkt = _make_flood_packet(payload=b"\x01" * 50)
# High SNR → high score → shorter delay
delays = []
for _ in range(50):
d = handler._calculate_tx_delay(pkt, snr=10.0)
delays.append(d)
avg_high_snr = sum(delays) / len(delays)
# Low SNR → low score → longer delay
delays_low = []
for _ in range(50):
d = handler._calculate_tx_delay(pkt, snr=-5.0)
delays_low.append(d)
avg_low_snr = sum(delays_low) / len(delays_low)
# Using statistical comparison — average high-SNR delay should be lower
# (Both use random, but multiplier differs)
# This is non-deterministic; we just check score path doesn't crash
assert avg_high_snr >= 0.0
assert avg_low_snr >= 0.0
def test_zero_tx_delay_factor(self, handler):
handler.tx_delay_factor = 0.0
pkt = _make_flood_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == 0.0
def test_transport_direct_uses_direct_delay(self, handler):
handler.direct_tx_delay_factor = 0.77
pkt = _make_transport_direct_packet()
delay = handler._calculate_tx_delay(pkt, snr=0.0)
assert delay == pytest.approx(0.77, abs=0.01)
# ===================================================================
# 8. Hash stability through forwarding operations
# ===================================================================
class TestHashStabilityThroughForwarding:
"""Verify hash is computed on original packet (before path mutation)."""
def test_flood_hash_unchanged_after_forward(self, handler):
pkt = _make_flood_packet(payload=b"\xDE\xAD")
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.flood_forward(pkt)
# The hash stored should be the pre-modification hash
assert hash_before in handler.seen_packets
def test_direct_hash_unchanged_after_forward(self, handler):
pkt = _make_direct_packet(payload=b"\xBE\xEF",
path=bytes([LOCAL_HASH, 0xCC]))
hash_before = pkt.calculate_packet_hash().hex().upper()
handler.direct_forward(pkt)
assert hash_before in handler.seen_packets
def test_flood_second_identical_detected_as_duplicate(self, handler):
"""Two identical packets with the same payload (but path not yet modified)
should be correctly detected as duplicates."""
p1 = _make_flood_packet(payload=b"\xCA\xFE")
p2 = _make_flood_packet(payload=b"\xCA\xFE")
handler.flood_forward(p1)
result = handler.flood_forward(p2)
assert result is None
def test_direct_second_identical_detected_as_duplicate(self, handler):
p1 = _make_direct_packet(payload=b"\xCA\xFE",
path=bytes([LOCAL_HASH, 0x11]))
p2 = _make_direct_packet(payload=b"\xCA\xFE",
path=bytes([LOCAL_HASH, 0x11]))
handler.direct_forward(p1)
result = handler.direct_forward(p2)
assert result is None
# ===================================================================
# 9. unscoped flood policy
# ===================================================================
class TestUnscopedFloodPolicy:
"""unscoped_flood_allow=False blocks plain flood, transport checked."""
def test_flood_blocked_by_policy(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
result = handler.flood_forward(pkt)
assert result is None
def test_direct_unaffected_by_flood_policy(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_direct_packet()
result = handler.direct_forward(pkt)
assert result is not None # direct is not blocked by flood policy
def test_transport_flood_unaffected_by_unscoped_policy(self, handler):
# unscoped_flood_allow controls only plain FLOOD packets.
# Transport floods are validated via _check_transport_codes regardless.
# With a configured scope that allows, transport flood passes even when
# unscoped traffic is denied — the two settings are fully independent.
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_transport_flood_packet()
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
result = handler.flood_forward(pkt)
assert result is not None # transport flood passes; unscoped=False did not block it
def test_transport_flood_denied_with_no_keys(self, handler):
# Scope Not Configured = denied, regardless of unscoped_flood_allow.
pkt = _make_transport_flood_packet()
result = handler.flood_forward(pkt) # no mocking — real _check_transport_codes
assert result is None # denied because no transport keys configured
class TestFloodLoopDetection:
"""MeshCore-style loop detection for flood forwarding."""
def test_loop_detect_off_allows_looped_path(self, handler):
handler.config["mesh"]["loop_detect"] = "off"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, 0x11, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is not None
def test_loop_detect_minimal_drops_at_four(self, handler):
handler.config["mesh"]["loop_detect"] = "minimal"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, LOCAL_HASH, LOCAL_HASH, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is None
assert "loop detected" in (pkt.drop_reason or "").lower()
def test_loop_detect_minimal_allows_below_threshold(self, handler):
handler.config["mesh"]["loop_detect"] = "minimal"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, LOCAL_HASH, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is not None
def test_loop_detect_moderate_drops_at_two(self, handler):
handler.config["mesh"]["loop_detect"] = "moderate"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([LOCAL_HASH, 0x22, LOCAL_HASH]))
result = handler.flood_forward(pkt)
assert result is None
def test_loop_detect_strict_drops_at_one(self, handler):
handler.config["mesh"]["loop_detect"] = "strict"
handler.reload_runtime_config()
pkt = _make_flood_packet(path=bytes([0x33, LOCAL_HASH, 0x44]))
result = handler.flood_forward(pkt)
assert result is None
# ===================================================================
# 10. Airtime / duty-cycle integration
# ===================================================================
class TestAirtimeIntegration:
"""Airtime calculation and duty-cycle enforcement."""
def test_airtime_positive(self, handler):
airtime = handler.airtime_mgr.calculate_airtime(50)
assert airtime > 0.0
def test_can_transmit_fresh(self, handler):
can_tx, wait = handler.airtime_mgr.can_transmit(100.0)
assert can_tx is True
assert wait == 0.0
def test_cannot_transmit_after_exhaustion(self, handler):
# Fill up the budget
handler.airtime_mgr.record_tx(handler.airtime_mgr.max_airtime_per_minute)
can_tx, wait = handler.airtime_mgr.can_transmit(1.0)
assert can_tx is False
assert wait > 0.0
def test_duty_cycle_disabled(self, handler):
handler.config["duty_cycle"]["enforcement_enabled"] = False
handler.airtime_mgr.config = handler.config
handler.airtime_mgr.record_tx(999999)
can_tx, wait = handler.airtime_mgr.can_transmit(999999)
assert can_tx is True
def test_airtime_increases_with_packet_size(self, handler):
short = handler.airtime_mgr.calculate_airtime(10)
long_ = handler.airtime_mgr.calculate_airtime(200)
assert long_ > short
# ===================================================================
# 11. Config reload
# ===================================================================
class TestConfigReload:
"""reload_runtime_config updates in-memory state."""
def test_tx_delay_factor_reloaded(self, handler):
handler.config["delays"]["tx_delay_factor"] = 3.14
handler.reload_runtime_config()
assert handler.tx_delay_factor == 3.14
def test_direct_tx_delay_factor_reloaded(self, handler):
handler.config["delays"]["direct_tx_delay_factor"] = 2.5
handler.reload_runtime_config()
assert handler.direct_tx_delay_factor == 2.5
def test_use_score_for_tx_reloaded(self, handler):
handler.config["repeater"]["use_score_for_tx"] = True
handler.reload_runtime_config()
assert handler.use_score_for_tx is True
def test_cache_ttl_reloaded(self, handler):
handler.config["repeater"]["cache_ttl"] = 120
handler.reload_runtime_config()
assert handler.cache_ttl == 120
# ===================================================================
# 12. _get_drop_reason
# ===================================================================
class TestGetDropReason:
"""_get_drop_reason: determine why a packet was not forwarded."""
def test_duplicate_reason(self, handler):
pkt = _make_flood_packet()
handler.mark_seen(pkt)
reason = handler._get_drop_reason(pkt)
assert reason == "Duplicate"
def test_empty_payload_reason(self, handler):
pkt = _make_flood_packet(payload=b"")
pkt.payload_len = 0
pkt.payload = bytearray()
reason = handler._get_drop_reason(pkt)
assert "Empty" in reason
def test_path_too_long_reason(self, handler):
pkt = _make_flood_packet(path=bytes(range(MAX_PATH_SIZE)))
reason = handler._get_drop_reason(pkt)
assert "Path too long" in reason
def test_flood_policy_reason(self, handler):
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = _make_flood_packet()
reason = handler._get_drop_reason(pkt)
assert "flood" in reason.lower()
def test_direct_not_for_us_reason(self, handler):
pkt = _make_direct_packet(path=bytes([0xFF, 0xCC]))
reason = handler._get_drop_reason(pkt)
assert "not for us" in reason
def test_direct_no_path_reason(self, handler):
pkt = _make_direct_packet(path=b"")
pkt.path_len = 0
reason = handler._get_drop_reason(pkt)
assert "no path" in reason
# ===================================================================
# 13. Transport route forwarding
# ===================================================================
class TestTransportForwarding:
"""TRANSPORT_FLOOD and TRANSPORT_DIRECT: packet routing through process_packet."""
def test_transport_flood_appends_path(self, handler):
pkt = _make_transport_flood_packet(path=b"\x11")
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.path[-1] == LOCAL_HASH
assert len(fwd_pkt.path) == 2
def test_transport_direct_consumes_path(self, handler):
pkt = _make_transport_direct_packet(path=bytes([LOCAL_HASH, 0xCC]))
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert list(fwd_pkt.path) == [0xCC]
def test_transport_codes_preserved_after_flood(self, handler):
pkt = _make_transport_flood_packet(transport_codes=(0xAAAA, 0xBBBB))
with patch.object(handler, '_check_transport_codes', return_value=(True, "")):
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.transport_codes == [0xAAAA, 0xBBBB]
def test_transport_codes_preserved_after_direct(self, handler):
pkt = _make_transport_direct_packet(transport_codes=(0x1111, 0x2222))
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
assert fwd_pkt.transport_codes == [0x1111, 0x2222]
# ===================================================================
# 14. Statistics tracking
# ===================================================================
class TestStatistics:
"""RX/TX/dropped counters and recent_packets list."""
def test_initial_counters_zero(self, handler):
assert handler.rx_count == 0
assert handler.forwarded_count == 0
assert handler.dropped_count == 0
def test_get_stats_returns_dict(self, handler):
with patch.object(handler, "storage", None):
stats = handler.get_stats()
assert "rx_count" in stats
assert "forwarded_count" in stats
assert "dropped_count" in stats
assert "local_hash" in stats
assert "uptime_seconds" in stats
def test_get_stats_local_hash_format(self, handler):
with patch.object(handler, "storage", None):
stats = handler.get_stats()
assert stats["local_hash"] == f"0x{LOCAL_HASH:02x}"
# ===================================================================
# 15. Edge cases and regression tests
# ===================================================================
class TestEdgeCases:
"""Miscellaneous edge cases and regressions."""
def test_path_as_list_converted_to_bytearray(self, handler):
"""flood_forward should handle path being a list (not bytearray)."""
pkt = _make_flood_packet()
pkt.path = [0x11, 0x22]
pkt.path_len = 2
result = handler.flood_forward(pkt)
assert result is not None
assert isinstance(result.path, bytearray)
assert list(result.path) == [0x11, 0x22, LOCAL_HASH]
def test_flood_forward_idempotent_on_second_call(self, handler):
"""Calling flood_forward again with the SAME packet object should
detect as duplicate (the first call already mark_seen'd it)."""
pkt = _make_flood_packet(payload=b"\xFF" * 10)
r1 = handler.flood_forward(pkt)
assert r1 is not None
# Now pkt has local_hash appended, but hash was computed pre-append.
# A new packet with same original payload should be duplicate.
pkt2 = _make_flood_packet(payload=b"\xFF" * 10)
r2 = handler.flood_forward(pkt2)
assert r2 is None
def test_large_payload_still_forwarded(self, handler):
pkt = _make_flood_packet(payload=bytes(range(256)) * 4)
result = handler.flood_forward(pkt)
assert result is not None
def test_payload_type_encoding_in_header(self, handler):
"""Verify header construction encodes payload_type correctly."""
for pt in range(16):
pkt = _make_flood_packet(payload=bytes([pt, 0xFF]), payload_type=pt)
assert pkt.get_payload_type() == pt
result = handler.flood_forward(pkt)
assert result is not None
def test_many_distinct_packets_all_forwarded(self, handler):
"""100 unique packets should all be forwarded (no false duplicates)."""
for i in range(100):
pkt = _make_flood_packet(payload=i.to_bytes(4, "big"))
result = handler.flood_forward(pkt)
assert result is not None, f"Packet {i} incorrectly detected as duplicate"
def test_cache_eviction_order_is_fifo(self, handler):
handler.max_cache_size = 3
pkts = [_make_flood_packet(payload=bytes([i])) for i in range(4)]
for p in pkts:
handler.mark_seen(p)
# First one evicted
assert handler.is_duplicate(pkts[0]) is False
# Last three still present
for p in pkts[1:]:
assert handler.is_duplicate(p) is True
def test_do_not_retransmit_with_custom_drop_reason(self, handler):
pkt = _make_flood_packet()
pkt.mark_do_not_retransmit()
pkt.drop_reason = "Custom reason"
result = handler.flood_forward(pkt)
assert result is None
# Custom reason should be preserved (not overwritten)
assert pkt.drop_reason == "Custom reason"
def test_monitor_mode_skips_processing(self, handler):
"""In monitor mode, process_packet is not called at all in __call__,
but process_packet itself doesn't check mode — that's done in __call__.
We test that process_packet works irrespective of mode."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
# process_packet doesn't check mode — should still work
result = handler.process_packet(pkt, snr=0.0)
assert result is not None
# ===================================================================
# 15b. TX mode: forward, monitor, no_tx
# ===================================================================
@pytest.mark.asyncio
class TestTxMode:
"""forward = repeat on; monitor = no repeat, local TX allowed; no_tx = all TX off."""
async def test_forward_mode_calls_process_packet_for_rx(self, handler):
"""In forward mode, a received packet (not local) triggers process_packet."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet", wraps=handler.process_packet) as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_called_once()
async def test_monitor_mode_does_not_call_process_packet_for_rx(self, handler):
"""In monitor mode, a received packet does not trigger process_packet."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet") as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_not_called()
async def test_no_tx_mode_does_not_call_process_packet_for_rx(self, handler):
"""In no_tx mode, a received packet does not trigger process_packet."""
handler.config["repeater"]["mode"] = "no_tx"
pkt = _make_flood_packet()
with patch.object(handler, "process_packet") as m:
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=False)
m.assert_not_called()
async def test_monitor_mode_allows_local_tx(self, handler):
"""In monitor mode, local_transmission=True still schedules send_packet."""
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0) # flush scheduled task
handler.dispatcher.send_packet.assert_called_once()
async def test_no_tx_mode_blocks_local_tx(self, handler):
"""In no_tx mode, local_transmission=True does not schedule send_packet."""
handler.config["repeater"]["mode"] = "no_tx"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0)
handler.dispatcher.send_packet.assert_not_called()
async def test_forward_mode_allows_local_tx(self, handler):
"""In forward mode, local_transmission=True schedules send_packet."""
handler.config["repeater"]["mode"] = "forward"
pkt = _make_flood_packet()
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 0.0, "rssi": -80}, local_transmission=True)
await asyncio.sleep(0)
handler.dispatcher.send_packet.assert_called_once()
# ===================================================================
# 16. Airtime calculation correctness
# ===================================================================
class TestAirtimeCalculation:
"""Semtech LoRa airtime formula validation."""
def test_known_airtime_sf7_125khz(self, handler):
"""SF7, 125kHz, CR4/5, 10-byte payload — well-known reference value."""
mgr = handler.airtime_mgr
# Override to known settings
at = mgr.calculate_airtime(10, spreading_factor=7, bandwidth_hz=125000,
coding_rate=5, preamble_len=8)
# Semtech calculator: ~36ms for these params
assert 30.0 < at < 50.0
def test_sf12_much_slower_than_sf7(self, handler):
mgr = handler.airtime_mgr
at7 = mgr.calculate_airtime(50, spreading_factor=7)
at12 = mgr.calculate_airtime(50, spreading_factor=12)
# SF12 is roughly 32x slower than SF7 per symbol
assert at12 > at7 * 10
def test_zero_payload_still_has_preamble(self, handler):
mgr = handler.airtime_mgr
at = mgr.calculate_airtime(0)
assert at > 0.0
# ===================================================================
# 17. Curated good-packet and bad-packet arrays
# ===================================================================
# ---- 20 GOOD packets: all should be forwarded by process_packet ----
GOOD_PACKETS = [
# (id, description, builder)
("good_flood_minimal",
"Flood, 1-byte payload, empty path",
lambda: _make_flood_packet(payload=b"\x01")),
("good_flood_typical",
"Flood, 10-byte payload, 2-hop path",
lambda: _make_flood_packet(payload=bytes(range(10)), path=b"\x11\x22")),
("good_flood_max_payload_type",
"Flood, payload_type=15 (max 4-bit)",
lambda: _make_flood_packet(payload=b"\xAA\xBB", payload_type=15)),
("good_flood_payload_type_0",
"Flood, payload_type=0 (plain text)",
lambda: _make_flood_packet(payload=b"\x01\x02\x03", payload_type=0)),
("good_flood_long_payload",
"Flood, 200-byte payload",
lambda: _make_flood_packet(payload=bytes(range(200)))),
("good_flood_single_byte_path",
"Flood, path has 1 prior hop",
lambda: _make_flood_packet(payload=b"\xDE\xAD", path=b"\x42")),
("good_flood_binary_payload",
"Flood, all-zero payload",
lambda: _make_flood_packet(payload=b"\x00" * 16)),
("good_flood_high_entropy",
"Flood, high-entropy random-looking payload",
lambda: _make_flood_packet(payload=bytes(i ^ 0xA5 for i in range(64)))),
("good_flood_advert_type",
"Flood, payload_type=4 (ADVERT)",
lambda: _make_flood_packet(payload=b"\xAB\x01\x02\x03", payload_type=4)),
("good_direct_minimal",
"Direct, 1-byte payload, single hop to us (forward with empty path)",
lambda: _make_direct_packet(payload=b"\x01", path=bytes([LOCAL_HASH]))),
("good_direct_multihop",
"Direct, 3-hop remaining path (us + 2 more)",
lambda: _make_direct_packet(payload=b"\xCA\xFE", path=bytes([LOCAL_HASH, 0x11, 0x22]))),
("good_direct_long_payload",
"Direct, 150-byte payload",
lambda: _make_direct_packet(payload=bytes(range(150)), path=bytes([LOCAL_HASH, 0xBB]))),
("good_direct_type_2",
"Direct, payload_type=2 (ACK)",
lambda: _make_direct_packet(payload=b"\x01\x02", path=bytes([LOCAL_HASH]),
payload_type=2)),
("good_direct_long_remaining_path",
"Direct, 10 hops remaining after us",
lambda: _make_direct_packet(payload=b"\xFF\xEE",
path=bytes([LOCAL_HASH] + list(range(10))))),
("good_transport_direct_basic",
"Transport direct, basic hop to us",
lambda: _make_transport_direct_packet(payload=b"\x01\x02")),
("good_transport_direct_long_path",
"Transport direct, 5 remaining hops",
lambda: _make_transport_direct_packet(
payload=b"\xDE\xAD\xBE\xEF",
path=bytes([LOCAL_HASH, 0x11, 0x22, 0x33, 0x44]))),
]
# ---- 20 BAD packets: all should be dropped / return None ----
BAD_PACKETS = [
# (id, description, builder)
("bad_empty_payload",
"Empty bytearray payload",
lambda: _make_flood_packet(payload=b""),
"Empty payload"),
("bad_none_payload",
"payload = None",
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(_make_flood_packet()),
"Empty payload"),
("bad_path_at_max",
"Path exactly MAX_PATH_SIZE — no room to append",
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE))),
"Path length"),
("bad_flood_path_near_max",
"Flood, path = MAX_PATH_SIZE - 1 (63 hops; path_len encodes 0-63, cannot append)",
lambda: _make_flood_packet(payload=b"\xFF", path=bytes(range(MAX_PATH_SIZE - 1))),
"cannot append"),
("bad_path_over_max",
"Path exceeds MAX_PATH_SIZE",
lambda: _make_flood_packet(payload=b"\x01", path=bytes(range(MAX_PATH_SIZE + 5))),
"Path length"),
("bad_do_not_retransmit",
"Marked do-not-retransmit",
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(_make_flood_packet()),
"do not retransmit"),
("bad_direct_wrong_hop",
"Direct packet, path[0] != LOCAL_HASH",
lambda: _make_direct_packet(path=bytes([0xFF, 0xCC])),
"not for us"),
("bad_direct_empty_path",
"Direct packet with empty path",
lambda: _make_direct_packet(path=b""),
"no path"),
("bad_direct_none_path",
"Direct packet with path = None",
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
_make_direct_packet()),
"no path"),
("bad_flood_policy_off",
"Plain flood when unscoped_flood_allow=False (needs config override)",
lambda: _make_flood_packet(payload=b"\x01\x02"),
"unscoped flood"),
("bad_transport_flood_no_keys",
"Transport flood with no configured transport keys — always denied",
lambda: _make_transport_flood_packet(payload=b"\x01\x02"),
"transport"),
("bad_direct_empty_payload",
"Direct with empty payload (now caught by validate_packet)",
lambda: (lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1])(
_make_direct_packet(path=bytes([LOCAL_HASH]))),
"Empty payload"),
("bad_flood_zero_len_payload",
"Flood with payload_len forced to 0",
lambda: (lambda p: (setattr(p, "payload_len", 0), setattr(p, "payload", bytearray()), p)[-1])(
_make_flood_packet(payload=b"\x01")),
"Empty payload"),
("bad_direct_only_wrong_hops",
"Direct path of all 0xFF bytes (none match LOCAL_HASH)",
lambda: _make_direct_packet(path=bytes([0xFF, 0xFE, 0xFD])),
"not for us"),
("bad_transport_direct_wrong_hop",
"Transport direct with wrong first hop",
lambda: _make_transport_direct_packet(path=bytes([0x01, 0x02])),
"not for us"),
("bad_transport_direct_empty_path",
"Transport direct with empty path",
lambda: _make_transport_direct_packet(path=b""),
"no path"),
("bad_transport_direct_none_path",
"Transport direct with path = None",
lambda: (lambda p: (setattr(p, "path", None), setattr(p, "path_len", 0), p)[-1])(
_make_transport_direct_packet()),
"no path"),
("bad_flood_payload_255_zeros",
"Flood with payload = bytearray(0) (empty)",
lambda: (lambda p: (setattr(p, "payload", bytearray()), setattr(p, "payload_len", 0), p)[-1])(
_make_flood_packet()),
"Empty payload"),
("bad_direct_none_payload",
"Direct with None payload (now caught by validate_packet)",
lambda: (lambda p: (setattr(p, "payload", None), p)[-1])(
_make_direct_packet(path=bytes([LOCAL_HASH]))),
"Empty payload"),
("bad_flood_do_not_retransmit_custom",
"Flood, do-not-retransmit with custom drop reason",
lambda: (lambda p: (p.mark_do_not_retransmit(), setattr(p, "drop_reason", "Advert consumed"), p)[-1])(
_make_flood_packet(payload=b"\xAB")),
"Advert consumed"),
("bad_direct_do_not_retransmit",
"Direct, marked do-not-retransmit (now caught by direct_forward)",
lambda: (lambda p: (p.mark_do_not_retransmit(), p)[-1])(
_make_direct_packet(payload=b"\x99", path=bytes([LOCAL_HASH, 0x11]))),
"do not retransmit"),
]
# Pytest ids for readable output
_good_ids = [g[0] for g in GOOD_PACKETS]
_bad_ids = [b[0] for b in BAD_PACKETS]
class TestGoodPacketArray:
"""All 20 good packets should be forwarded successfully."""
@pytest.mark.parametrize(
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
)
def test_process_packet_forwards(self, handler, name, desc, builder):
pkt = builder()
result = handler.process_packet(pkt, snr=5.0)
assert result is not None, f"[{name}] {desc} — unexpectedly dropped"
fwd_pkt, delay = result
assert delay >= 0.0
@pytest.mark.parametrize(
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
)
def test_good_packet_not_duplicate_on_first_see(self, handler, name, desc, builder):
pkt = builder()
assert handler.is_duplicate(pkt) is False, f"[{name}] falsely flagged as duplicate"
@pytest.mark.parametrize(
"name, desc, builder", GOOD_PACKETS, ids=_good_ids,
)
def test_good_packet_path_modified(self, handler, name, desc, builder):
pkt = builder()
route = pkt.header & PH_ROUTE_MASK
original_path = list(pkt.path) if pkt.path else []
result = handler.process_packet(pkt, snr=5.0)
assert result is not None
fwd_pkt, _ = result
if route in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
assert fwd_pkt.path[-1] == LOCAL_HASH, f"[{name}] local hash not appended"
assert len(fwd_pkt.path) == len(original_path) + 1
else:
# Direct: first hop consumed
assert len(fwd_pkt.path) == len(original_path) - 1
class TestBadPacketArray:
"""All 20 bad packets should be dropped by the engine."""
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS, ids=_bad_ids,
)
def test_process_packet_drops(self, handler, name, desc, builder, expected_reason):
# Two entries need unscoped_flood_allow=False
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
result = handler.process_packet(pkt, snr=5.0)
assert result is None, f"[{name}] {desc} — should have been dropped"
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS, ids=_bad_ids,
)
def test_drop_reason_set(self, handler, name, desc, builder, expected_reason):
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
handler.process_packet(pkt, snr=5.0)
assert pkt.drop_reason is not None, f"[{name}] drop_reason not set"
assert expected_reason.lower() in pkt.drop_reason.lower(), (
f"[{name}] expected '{expected_reason}' in drop_reason, got '{pkt.drop_reason}'"
)
@pytest.mark.parametrize(
"name, desc, builder, expected_reason",
BAD_PACKETS, ids=_bad_ids,
)
def test_bad_packet_not_marked_seen(self, handler, name, desc, builder, expected_reason):
"""Dropped packets must NOT pollute the seen cache."""
if "policy_off" in name:
handler.config["mesh"]["unscoped_flood_allow"] = False
pkt = builder()
handler.process_packet(pkt, snr=5.0)
# Should not be in seen_packets (except do-not-retransmit and policy
# packets which fail AFTER validation but BEFORE mark_seen)
# Actually none of these should be marked seen — the engine only
# calls mark_seen on packets that pass all checks.
pkt_hash = pkt.calculate_packet_hash().hex().upper() if pkt.payload else None
if pkt_hash:
assert pkt_hash not in handler.seen_packets, (
f"[{name}] bad packet was incorrectly added to seen cache"
)
class TestRecordPacketOnlyTrace:
"""record_packet_only must not log TRACE: TraceHelper owns trace path; packet.path is SNR."""
def test_record_packet_only_skips_trace(self, handler):
storage = handler.storage
storage.record_packet.reset_mock()
pkt = PacketBuilder.create_trace(tag=1, auth_code=2, flags=0, path=[0xAB, 0xCD])
n_before = len(handler.recent_packets)
handler.record_packet_only(pkt, {"rssi": -80, "snr": 10.0})
storage.record_packet.assert_not_called()
assert len(handler.recent_packets) == n_before
# ===================================================================
# 18. Real packet injection through __call__
# ===================================================================
def _inject_from_wire(pkt: Packet) -> Packet:
"""Serialize and deserialize a packet to simulate a real RF wire packet."""
wire = pkt.write_to()
injected = Packet()
injected.read_from(wire)
return injected
@pytest.mark.asyncio
class TestPacketInjectionRouting:
"""Inject real serialized packets through __call__ and assert routing outcomes."""
@staticmethod
def _prepare_fast_tx(handler):
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=20.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0))
handler.airtime_mgr.record_tx = MagicMock()
handler.airtime_mgr.record_rx = MagicMock()
async def test_injected_flood_forwards_and_appends_path(self, handler):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_flood_packet(payload=b"\x10\x20\x30", path=b"\x11")
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 7.0, "rssi": -70}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert bytes(sent_pkt.path) == bytes([0x11, LOCAL_HASH])
assert handler.rx_count == 1
assert handler.forwarded_count == 1
assert handler.dropped_count == 0
async def test_injected_direct_forwards_and_consumes_hop(self, handler):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_direct_packet(payload=b"\xAA\xBB", path=bytes([LOCAL_HASH, 0x44, 0x55]))
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 3.0, "rssi": -82}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert bytes(sent_pkt.path) == b"\x44\x55"
async def test_direct_for_other_node_is_dropped(self, handler):
pkt = _inject_from_wire(
_make_direct_packet(payload=b"\xAA\xBB", path=b"\xFE\x44")
)
with patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock):
await handler(pkt, {"snr": 2.0, "rssi": -90}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 0
assert handler.dropped_count == 1
assert "not for us" in (handler.recent_packets[-1]["drop_reason"] or "")
async def test_duplicate_wire_packet_not_retransmitted(self, handler):
self._prepare_fast_tx(handler)
incoming = _make_flood_packet(payload=b"\x99\x88\x77", path=b"\x01")
pkt1 = _inject_from_wire(incoming)
pkt2 = _inject_from_wire(incoming)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt1, {"snr": 6.0, "rssi": -75}, local_transmission=False)
await handler(pkt2, {"snr": 5.5, "rssi": -76}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
assert handler.dropped_count == 1
assert handler.flood_dup_count == 1
original = handler.recent_packets[-1]
assert "duplicates" in original
assert len(original["duplicates"]) == 1
assert original["duplicates"][0]["drop_reason"] == "Duplicate"
async def test_transport_flood_injection_honors_transport_key_decision(self, handler):
pkt = _inject_from_wire(
_make_transport_flood_packet(payload=b"\x01\x02\x03\x04", path=b"")
)
with (
patch.object(handler, "_check_transport_codes", return_value=(False, "denied")),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 0.0, "rssi": -92}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 0
assert "transport" in (handler.recent_packets[-1]["drop_reason"] or "").lower()
async def test_local_tx_then_rf_echo_is_duplicate(self, handler):
self._prepare_fast_tx(handler)
local_pkt = _make_flood_packet(payload=b"\x0A\x0B\x0C", path=b"")
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(local_pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True)
rf_echo = _inject_from_wire(
_make_flood_packet(payload=b"\x0A\x0B\x0C", path=b"")
)
await handler(rf_echo, {"snr": 0.0, "rssi": -70}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
assert handler.dropped_count == 1
assert handler.flood_dup_count == 1
original = handler.recent_packets[-1]
assert "duplicates" in original
assert len(original["duplicates"]) == 1
assert original["duplicates"][0]["drop_reason"] == "Duplicate"
@pytest.mark.parametrize("payload_type", range(16))
async def test_all_payload_types_flood_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_flood_packet(
payload=bytes([payload_type, 0xA5, 0x5A]),
path=b"\x11",
payload_type=payload_type,
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 4.0, "rssi": -78}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert sent_pkt.path[-1] == LOCAL_HASH
@pytest.mark.parametrize("payload_type", range(16))
async def test_all_payload_types_direct_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_direct_packet(
payload=bytes([payload_type, 0x01, 0x02]),
path=bytes([LOCAL_HASH, 0x44, 0x55]),
payload_type=payload_type,
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 2.5, "rssi": -84}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert bytes(sent_pkt.path) == b"\x44\x55"
@pytest.mark.parametrize("payload_type", range(16))
async def test_all_payload_types_transport_flood_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_transport_flood_packet(
payload=bytes([payload_type, 0x33, 0x44]),
path=b"",
payload_type=payload_type,
transport_codes=(0x1111, 0x2222),
)
)
with (
patch.object(handler, "_check_transport_codes", return_value=(True, "")),
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 1.0, "rssi": -88}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert sent_pkt.transport_codes == [0x1111, 0x2222]
@pytest.mark.parametrize("payload_type", range(16))
async def test_all_payload_types_transport_direct_injection_forwards(self, handler, payload_type):
self._prepare_fast_tx(handler)
pkt = _inject_from_wire(
_make_transport_direct_packet(
payload=bytes([payload_type, 0x66, 0x77]),
path=bytes([LOCAL_HASH, 0x22]),
payload_type=payload_type,
transport_codes=(0x3333, 0x4444),
)
)
with (
patch.object(handler, "_calculate_tx_delay", return_value=0.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock),
):
await handler(pkt, {"snr": 3.0, "rssi": -83}, local_transmission=False)
assert handler.dispatcher.send_packet.call_count == 1
sent_pkt = handler.dispatcher.send_packet.call_args.args[0]
assert sent_pkt.get_payload_type() == payload_type
assert bytes(sent_pkt.path) == b"\x22"
assert sent_pkt.transport_codes == [0x3333, 0x4444]
# ===================================================================
# 19. Missed branch coverage (background/transport helpers)
# ===================================================================
class TestMissedEngineBranches:
"""Target previously untested helper/lifecycle branches in RepeaterHandler."""
def test_check_transport_codes_accepts_matching_key_and_uses_cache(self, handler):
key_raw = b"0123456789ABCDEF"
key_b64 = base64.b64encode(key_raw).decode("ascii")
handler.storage.get_transport_keys.return_value = [
{
"id": 7,
"name": "primary",
"transport_key": key_b64,
"flood_policy": "allow",
}
]
pkt = _make_transport_flood_packet(payload=b"\x01\x02", path=b"")
pkt.transport_codes = [0xCAFE, 0xBEEF]
with patch("pymc_core.protocol.transport_keys.calc_transport_code", return_value=0xCAFE):
allowed_1, reason_1 = handler._check_transport_codes(pkt)
allowed_2, reason_2 = handler._check_transport_codes(pkt)
assert allowed_1 is True and reason_1 == ""
assert allowed_2 is True and reason_2 == ""
assert handler.storage.get_transport_keys.call_count == 1
assert handler.storage.update_transport_key.call_count == 2
def test_record_duplicate_groups_under_original(self, handler):
pkt = _make_flood_packet(payload=b"\x12\x34")
original_hash = pkt.calculate_packet_hash().hex().upper()[:16]
original_record = {
"timestamp": time.time(),
"packet_hash": original_hash,
"transmitted": True,
}
handler._append_recent_packet(original_record)
handler.record_duplicate(pkt, rssi=-90, snr=1.5)
assert handler.flood_dup_count == 1
assert "duplicates" in original_record
assert len(original_record["duplicates"]) == 1
assert original_record["duplicates"][0]["drop_reason"] == "Duplicate"
@pytest.mark.asyncio
async def test_record_crc_errors_async_records_positive_delta(self, handler):
handler.dispatcher.radio.crc_error_count = 9
handler._last_crc_error_count = 4
await handler._record_crc_errors_async()
handler.storage.record_crc_errors.assert_called_once_with(5)
assert handler._last_crc_error_count == 9
@pytest.mark.asyncio
async def test_record_noise_floor_async_caches_and_persists(self, handler):
with patch.object(handler, "get_noise_floor", return_value=-117.5):
await handler._record_noise_floor_async()
assert handler._cached_noise_floor == -117.5
handler.storage.record_noise_floor.assert_called_once_with(-117.5)
@pytest.mark.asyncio
async def test_send_periodic_advert_async_success_and_failure(self, handler):
handler.send_advert_func = AsyncMock(side_effect=[True, False])
await handler._send_periodic_advert_async()
await handler._send_periodic_advert_async()
assert handler.send_advert_func.await_count == 2
def test_cleanup_cancels_background_task_and_closes_storage(self, handler):
fake_task = MagicMock()
fake_task.done.return_value = False
handler._background_task = fake_task
handler.cleanup()
fake_task.cancel.assert_called_once()
handler.storage.close.assert_called_once()
# ===================================================================
# 20. Transmission and Background Lifecycle Branches
# ===================================================================
class TestEngineTransmissionAndBackgroundLifecycle:
"""Cover duty-cycle outcomes, packet-record robustness, and background timer lifecycle."""
@pytest.mark.asyncio
async def test_local_tx_defers_when_duty_cycle_blocked(self, handler):
pkt = _make_flood_packet(payload=b"\x21\x22")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=120.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 2.0))
with patch.object(handler, "_calculate_tx_delay", return_value=0.5):
loop = asyncio.get_running_loop()
completed = loop.create_future()
completed.set_result(None)
async def _fake_schedule(packet, delay, airtime_ms, local_transmission=False):
packet._tx_metadata = {
"lbt_attempts": 2,
"lbt_backoff_delays_ms": [10, 20],
"lbt_channel_busy": True,
}
return completed
handler.schedule_retransmit = AsyncMock(side_effect=_fake_schedule)
await handler(pkt, {"snr": 0.0, "rssi": -50}, local_transmission=True)
handler.schedule_retransmit.assert_awaited_once()
args = handler.schedule_retransmit.await_args.args
assert args[0] is pkt
assert args[1] == pytest.approx(2.5) # original delay + duty-cycle wait
assert args[2] == 120.0
assert handler.forwarded_count == 1
assert handler.dropped_count == 0
assert handler.recent_packets[-1]["lbt_attempts"] == 2
@pytest.mark.asyncio
async def test_local_deferred_tx_failure_decrements_forwarded_counter(self, handler):
pkt = _make_flood_packet(payload=b"\x23\x24")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=55.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.0))
loop = asyncio.get_running_loop()
failing = loop.create_future()
failing.set_exception(RuntimeError("deferred tx failed"))
handler.schedule_retransmit = AsyncMock(return_value=failing)
with patch.object(handler, "_calculate_tx_delay", return_value=0.2):
with pytest.raises(RuntimeError, match="deferred tx failed"):
await handler(pkt, {"snr": 0.0, "rssi": -52}, local_transmission=True)
assert handler.forwarded_count == 0
@pytest.mark.asyncio
async def test_non_local_drop_when_duty_cycle_blocked(self, handler):
pkt = _make_flood_packet(payload=b"\x31\x32")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=80.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(False, 1.25))
handler.process_packet = MagicMock(return_value=(pkt, 0.1))
handler.schedule_retransmit = AsyncMock()
await handler(pkt, {"snr": 5.0, "rssi": -75}, local_transmission=False)
handler.schedule_retransmit.assert_not_awaited()
assert handler.dropped_count == 1
assert handler.forwarded_count == 0
assert handler.recent_packets[-1]["drop_reason"] == "Duty cycle limit"
@pytest.mark.asyncio
async def test_tx_failure_rolls_back_forwarded_counter(self, handler):
pkt = _make_flood_packet(payload=b"\x41\x42")
handler.airtime_mgr.calculate_airtime = MagicMock(return_value=40.0)
handler.airtime_mgr.can_transmit = MagicMock(return_value=(True, 0.0))
loop = asyncio.get_running_loop()
failing = loop.create_future()
failing.set_exception(RuntimeError("radio busy"))
handler.schedule_retransmit = AsyncMock(return_value=failing)
with patch.object(handler, "_calculate_tx_delay", return_value=0.0):
with pytest.raises(RuntimeError, match="radio busy"):
await handler(pkt, {"snr": 0.0, "rssi": -40}, local_transmission=True)
# Incremented before scheduling, decremented on failure path.
assert handler.forwarded_count == 0
def test_record_packet_only_missing_header_and_storage_failure(self, handler):
pkt = _make_flood_packet(payload=b"\x51\x52")
n_before = len(handler.recent_packets)
pkt.header = None
handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0})
assert len(handler.recent_packets) == n_before
pkt.header = ROUTE_TYPE_FLOOD | (0x01 << PH_TYPE_SHIFT)
handler.storage.record_packet.side_effect = RuntimeError("db down")
handler.record_packet_only(pkt, {"rssi": -70, "snr": 2.0})
# Storage failure should not append to recent list.
assert len(handler.recent_packets) == n_before
def test_log_trace_record_updates_counters_even_if_storage_fails(self, handler):
base_rx = handler.rx_count
base_fwd = handler.forwarded_count
base_drop = handler.dropped_count
handler.storage.record_packet.side_effect = RuntimeError("write fail")
handler.log_trace_record({"packet_hash": "ABC123", "transmitted": True})
handler.log_trace_record({"packet_hash": "DEF456", "transmitted": False})
assert handler.rx_count == base_rx + 2
assert handler.forwarded_count == base_fwd + 1
assert handler.dropped_count == base_drop + 1
def test_record_duplicate_direct_route_updates_duplicate_counters(self, handler):
pkt = _make_direct_packet(payload=b"\x61\x62", path=bytes([LOCAL_HASH, 0xAA]))
handler.record_duplicate(pkt, rssi=-88, snr=1.2)
assert handler.recv_direct_count == 1
assert handler.direct_dup_count == 1
def test_start_background_tasks_only_starts_once(self, handler):
marker_task = MagicMock(name="bg_task")
def _fake_create_task(coro):
coro.close()
return marker_task
with patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task) as mk_task:
handler._background_task = None
handler._start_background_tasks()
handler._start_background_tasks()
mk_task.assert_called_once()
assert handler._background_task is marker_task
@pytest.mark.asyncio
async def test_background_timer_loop_runs_tasks_and_handles_cancel(self, handler):
handler.last_noise_measurement = 0
handler.noise_floor_interval = 1
handler.send_advert_interval_hours = 1
handler.send_advert_func = AsyncMock()
handler.last_advert_time = 0
handler.last_cache_cleanup = 0
handler.last_db_cleanup = 0
handler.cleanup_cache = MagicMock()
handler._record_noise_floor_async = AsyncMock()
handler._record_crc_errors_async = AsyncMock()
handler._send_periodic_advert_async = AsyncMock()
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError),
):
with pytest.raises(asyncio.CancelledError):
await handler._background_timer_loop()
handler._record_noise_floor_async.assert_awaited_once()
handler._record_crc_errors_async.assert_awaited_once()
handler._send_periodic_advert_async.assert_awaited_once()
handler.cleanup_cache.assert_called_once()
handler.storage.cleanup_old_data.assert_called_once()
@pytest.mark.asyncio
async def test_background_timer_loop_continues_when_db_cleanup_fails(self, handler):
handler.last_noise_measurement = 0
handler.noise_floor_interval = 999999
handler.send_advert_interval_hours = 0
handler.last_cache_cleanup = 0
handler.last_db_cleanup = 0
handler.cleanup_cache = MagicMock()
handler._record_noise_floor_async = AsyncMock()
handler._record_crc_errors_async = AsyncMock()
handler.storage.cleanup_old_data.side_effect = RuntimeError("cleanup error")
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, side_effect=asyncio.CancelledError),
):
with pytest.raises(asyncio.CancelledError):
await handler._background_timer_loop()
handler.storage.cleanup_old_data.assert_called_once()
@pytest.mark.asyncio
async def test_background_timer_loop_exception_restarts_task(self, handler):
handler._record_noise_floor_async = AsyncMock(side_effect=RuntimeError("boom"))
handler.last_noise_measurement = 0
handler.noise_floor_interval = 1
created = {}
def _fake_create_task(coro):
created["called"] = True
# Avoid leaking an un-awaited coroutine in the test process.
coro.close()
return "restarted-task"
with (
patch("repeater.engine.time.time", return_value=100000.0),
patch("repeater.engine.asyncio.sleep", new_callable=AsyncMock, return_value=None) as sleep_mock,
patch("repeater.engine.asyncio.create_task", side_effect=_fake_create_task),
):
await handler._background_timer_loop()
sleep_mock.assert_awaited_once_with(30)
assert created.get("called") is True
assert handler._background_task == "restarted-task"
@pytest.mark.asyncio
async def test_record_noise_floor_handles_none_and_exceptions(self, handler):
with patch.object(handler, "get_noise_floor", return_value=None):
await handler._record_noise_floor_async()
handler.storage.record_noise_floor.assert_not_called()
with patch.object(handler, "get_noise_floor", side_effect=RuntimeError("noise fail")):
await handler._record_noise_floor_async()
@pytest.mark.asyncio
async def test_record_crc_errors_returns_without_storage_and_handles_storage_exception(self, handler):
# No storage configured: should return early.
handler.storage = None
await handler._record_crc_errors_async()
# Restore storage and force write error on positive delta.
handler.storage = MagicMock()
handler._last_crc_error_count = 1
handler.dispatcher.radio.crc_error_count = 3
handler.storage.record_crc_errors.side_effect = RuntimeError("crc write fail")
await handler._record_crc_errors_async()
@pytest.mark.asyncio
async def test_send_periodic_advert_handles_missing_handler_and_handler_exception(self, handler):
handler.send_advert_func = None
await handler._send_periodic_advert_async()
handler.send_advert_func = AsyncMock(side_effect=RuntimeError("advert fail"))
await handler._send_periodic_advert_async()
class TestEngineRecordAndCleanupHelpers:
"""Cover helper fallbacks that protect UI visibility and in-memory index integrity."""
def test_record_duplicate_appends_when_original_not_found(self, handler):
# Keep recent non-empty but ensure duplicate hash is not indexed.
handler._append_recent_packet({"packet_hash": "OTHERHASH", "transmitted": True})
pkt = _make_flood_packet(payload=b"\x71\x72")
handler.record_duplicate(pkt, rssi=-85, snr=1.0)
assert handler.recent_packets[-1]["drop_reason"] == "Duplicate"
assert handler.recent_packets[-1]["packet_hash"] == pkt.calculate_packet_hash().hex().upper()[:16]
def test_record_duplicate_appends_when_recent_packets_empty(self, handler):
handler.recent_packets.clear()
handler._recent_hash_index.clear()
pkt = _make_flood_packet(payload=b"\x73\x74")
handler.record_duplicate(pkt, rssi=-82, snr=1.1)
assert len(handler.recent_packets) == 1
assert handler.recent_packets[0]["drop_reason"] == "Duplicate"
def test_record_duplicate_route_zero_maps_to_flood_counters(self, handler):
pkt = _make_flood_packet(payload=b"\x75\x76")
# Route nibble 0 is parsed as FLOOD in current protocol constants.
pkt.header = (0x00 << PH_TYPE_SHIFT)
handler.record_duplicate(pkt, rssi=-90, snr=0.5)
assert handler.flood_dup_count == 1
assert handler.direct_dup_count == 0
def test_append_recent_packet_eviction_removes_matching_index_entry(self, handler):
handler.max_recent_packets = 1
old = {"packet_hash": "OLDHASH"}
handler.recent_packets.append(old)
handler._recent_hash_index["OLDHASH"] = old
handler._append_recent_packet({"packet_hash": "NEWHASH"})
assert "OLDHASH" not in handler._recent_hash_index
assert handler.recent_packets[-1]["packet_hash"] == "NEWHASH"
assert handler._recent_hash_index["NEWHASH"] is handler.recent_packets[-1]
def test_append_recent_packet_without_hash_skips_index_update(self, handler):
base_index = dict(handler._recent_hash_index)
handler._append_recent_packet({"timestamp": time.time()})
assert dict(handler._recent_hash_index) == base_index
def test_cleanup_handles_storage_close_exception(self, handler):
fake_task = MagicMock()
fake_task.done.return_value = False
handler._background_task = fake_task
handler.storage.close.side_effect = RuntimeError("close failed")
# cleanup should swallow close errors and not raise.
handler.cleanup()
fake_task.cancel.assert_called_once()