""" 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()