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.
This commit is contained in:
Lloyd
2026-05-26 13:01:38 +01:00
parent dc317b6568
commit 37ee0e892a
18 changed files with 4326 additions and 46 deletions
+623 -2
View File
@@ -6,6 +6,7 @@ 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
@@ -139,10 +140,11 @@ def _make_direct_packet(payload: bytes = b"\x01\x02\x03\x04",
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 | (0x01 << PH_TYPE_SHIFT)
pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
@@ -153,12 +155,13 @@ def _make_transport_flood_packet(payload: bytes = b"\x01\x02\x03\x04",
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 | (0x01 << PH_TYPE_SHIFT)
pkt.header = ROUTE_TYPE_TRANSPORT_DIRECT | (payload_type << PH_TYPE_SHIFT)
pkt.payload = bytearray(payload)
pkt.payload_len = len(payload)
pkt.path = bytearray(path)
@@ -1377,3 +1380,621 @@ class TestRecordPacketOnlyTrace:
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()