Files
meshcore-bot/tests/test_graph_trace_helper.py
T
agessaman 0ad9f4e69a fix(transmission_tracker): read the bot's prefix from the radio's reported key
bot_prefix and graph_trace_helper's bot key read meshcore.device, which meshcore_py doesn't have, so both were always None and update_mesh_graph_from_trace_data skipped every trace. Both now come from meshcore.self_info's public_key (device stays a fallback), and bot_prefix is read lazily because the tracker is built before the radio connects. The helper names the bot at the trace's hash width, so the last hop's edge matches the hops' own width, and marks 2-byte edges with prefix_bytes=2.

trace_command read update_graph_two_byte but never used it; 2-byte traces now follow it instead of update_graph_one_byte.
2026-10-02 18:32:54 -07:00

342 lines
15 KiB
Python

"""Tests for modules.graph_trace_helper — update_mesh_graph_from_trace_data."""
import configparser
from unittest.mock import MagicMock, Mock
import pytest
from modules.graph_trace_helper import update_mesh_graph_from_trace_data
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_bot(bot_prefix="aa", has_mesh_graph=True, has_transmission_tracker=True):
"""Create a minimal mock bot for graph_trace_helper tests."""
bot = MagicMock()
bot.logger = Mock()
config = configparser.ConfigParser()
config.add_section("Path_Command")
config.set("Path_Command", "graph_edge_expiration_days", "7")
bot.config = config
if has_mesh_graph:
bot.mesh_graph = MagicMock()
else:
bot.mesh_graph = None
if has_transmission_tracker:
bot.transmission_tracker = MagicMock()
bot.transmission_tracker.bot_prefix = bot_prefix
bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
else:
bot.transmission_tracker = None
# DB manager returns empty results by default
bot.db_manager = MagicMock()
bot.db_manager.execute_query = Mock(return_value=[])
# The radio's key, consistent with bot_prefix (none when bot_prefix is empty)
bot.meshcore = MagicMock()
bot.meshcore.self_info = {"public_key": (bot_prefix.lower() * 64)[:64]} if bot_prefix else {}
bot.meshcore.device = None
return bot
# ---------------------------------------------------------------------------
# Early exit / guard cases
# ---------------------------------------------------------------------------
class TestEarlyExits:
def test_empty_path_hashes_returns_immediately(self):
bot = _make_bot()
update_mesh_graph_from_trace_data(bot, [], {})
bot.mesh_graph.add_edge.assert_not_called()
def test_none_path_hashes_treated_as_empty(self):
bot = _make_bot()
# empty list is falsy, so this is covered by the guard
update_mesh_graph_from_trace_data(bot, [], {})
bot.mesh_graph.add_edge.assert_not_called()
def test_no_mesh_graph_returns_immediately(self):
bot = _make_bot(has_mesh_graph=False)
update_mesh_graph_from_trace_data(bot, ["ab"], {})
# Should log and return without crash
bot.logger.debug.assert_called()
def test_no_transmission_tracker_returns_immediately(self):
bot = _make_bot(has_transmission_tracker=False)
update_mesh_graph_from_trace_data(bot, ["ab"], {})
bot.logger.debug.assert_called()
def test_missing_mesh_graph_attribute(self):
bot = _make_bot()
del bot.mesh_graph
update_mesh_graph_from_trace_data(bot, ["ab"], {})
# No crash expected
def test_missing_transmission_tracker_attribute(self):
bot = _make_bot()
del bot.transmission_tracker
update_mesh_graph_from_trace_data(bot, ["ab"], {})
# No crash expected
def test_empty_bot_prefix_returns_immediately(self):
bot = _make_bot(bot_prefix=None)
update_mesh_graph_from_trace_data(bot, ["ab"], {})
bot.mesh_graph.add_edge.assert_not_called()
def test_empty_string_bot_prefix_returns_immediately(self):
bot = _make_bot(bot_prefix="")
update_mesh_graph_from_trace_data(bot, ["ab"], {})
bot.mesh_graph.add_edge.assert_not_called()
# ---------------------------------------------------------------------------
# is_our_trace resolution
# ---------------------------------------------------------------------------
class TestIsOurTraceResolution:
def test_is_our_trace_none_resolves_false_when_no_match(self):
"""When packet_hash doesn't match, is_our_trace stays False."""
bot = _make_bot(bot_prefix="aa")
bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
update_mesh_graph_from_trace_data(bot, ["bb"], {"packet_hash": "abc123"})
# Not our trace → should add one edge (last_node → bot)
bot.mesh_graph.add_edge.assert_called_once()
def test_is_our_trace_none_resolves_true_when_match(self):
"""When packet_hash matches a transmission record, is_our_trace becomes True."""
bot = _make_bot(bot_prefix="aa")
bot.transmission_tracker.match_packet_hash = Mock(return_value={"matched": True})
# Single hop — should trigger immediate-neighbor path (bidirectional)
update_mesh_graph_from_trace_data(bot, ["bb"], {"packet_hash": "abc123"})
# Bidirectional: 2 add_edge calls
assert bot.mesh_graph.add_edge.call_count == 2
def test_is_our_trace_explicit_true(self):
"""Explicit is_our_trace=True skips transmission tracker lookup."""
bot = _make_bot(bot_prefix="aa")
bot.transmission_tracker.match_packet_hash = Mock(return_value=None)
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
# Single hop + explicit True → immediate neighbor → bidirectional
assert bot.mesh_graph.add_edge.call_count == 2
def test_is_our_trace_explicit_false(self):
"""Explicit is_our_trace=False skips transmission tracker lookup."""
bot = _make_bot(bot_prefix="aa")
bot.transmission_tracker.match_packet_hash = Mock(return_value={"matched": True})
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=False)
# Not our trace even though match_packet_hash would return a record
bot.mesh_graph.add_edge.assert_called_once()
def test_is_our_trace_true_no_packet_hash(self):
"""is_our_trace None with no packet_hash defaults to False."""
bot = _make_bot(bot_prefix="aa")
update_mesh_graph_from_trace_data(bot, ["bb"], {})
# No packet_hash → is_our_trace stays False → one edge
bot.mesh_graph.add_edge.assert_called_once()
# ---------------------------------------------------------------------------
# Immediate neighbor (single hop, is_our_trace=True)
# ---------------------------------------------------------------------------
class TestImmediateNeighbor:
def test_single_hop_creates_bidirectional_edges(self):
bot = _make_bot(bot_prefix="aa")
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_hop_edge_directions(self):
bot = _make_bot(bot_prefix="aa")
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
calls = bot.mesh_graph.add_edge.call_args_list
from_prefixes = {c.kwargs.get("from_prefix") or c[1].get("from_prefix") for c in calls}
to_prefixes = {c.kwargs.get("to_prefix") or c[1].get("to_prefix") for c in calls}
# Both directions: aa↔bb
assert "aa" in from_prefixes or "aa" in to_prefixes
assert "bb" in from_prefixes or "bb" in to_prefixes
def test_single_hop_lowercase_normalization(self):
"""Path hashes are lowercased before use."""
bot = _make_bot(bot_prefix="AA")
update_mesh_graph_from_trace_data(bot, ["BB"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
calls = bot.mesh_graph.add_edge.call_args_list
# Check lowercase normalization
all_prefixes = set()
for c in calls:
all_prefixes.add(c.kwargs.get("from_prefix") or (c[0][0] if c[0] else None))
all_prefixes.add(c.kwargs.get("to_prefix") or (c[0][1] if len(c[0]) > 1 else None))
# Should contain lowercase versions
assert "aa" in all_prefixes or "bb" in all_prefixes
def test_single_hop_with_unique_db_key(self):
"""When DB returns exactly one public_key for the neighbor, it's used."""
bot = _make_bot(bot_prefix="aa")
# First query returns count=1, second returns the key
bot.db_manager.execute_query = Mock(side_effect=[
[{"count": 1}],
[{"public_key": "bb" * 32}],
[], # bot location query
])
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_hop_with_ambiguous_db_results(self):
"""When DB returns count != 1, no key is resolved but edges still added."""
bot = _make_bot(bot_prefix="aa")
bot.db_manager.execute_query = Mock(return_value=[{"count": 2}])
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_hop_db_exception_handled(self):
"""DB exceptions during key lookup are swallowed; edges still added."""
bot = _make_bot(bot_prefix="aa")
bot.db_manager.execute_query = Mock(side_effect=Exception("DB error"))
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_hop_no_meshcore_device(self):
"""No meshcore device → bot_key is None, edges still added."""
bot = _make_bot(bot_prefix="aa")
bot.meshcore = None
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_hop_device_pubkey_bytes(self):
"""Device public key as bytes is hex-encoded."""
bot = _make_bot(bot_prefix="aa")
bot.meshcore.self_info = {}
bot.meshcore.device = MagicMock(public_key=b"\xaa\xbb")
update_mesh_graph_from_trace_data(bot, ["bb"], {}, is_our_trace=True)
assert bot.mesh_graph.add_edge.call_count == 2
assert bot.mesh_graph.add_edge.call_args_list[0].kwargs["from_public_key"] == "aabb"
# ---------------------------------------------------------------------------
# Regular trace (bot is destination, not immediate neighbor)
# ---------------------------------------------------------------------------
class TestRegularTrace:
def test_two_hop_creates_two_edges(self):
"""[a, b] path (bot receives from b via a): two edges expected."""
bot = _make_bot(bot_prefix="cc")
update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
# 1 edge: last_node(bb) → bot(cc)
# 1 edge: aa → bb
assert bot.mesh_graph.add_edge.call_count == 2
def test_single_node_path_creates_one_edge(self):
"""Single-node path: last_node → bot."""
bot = _make_bot(bot_prefix="cc")
update_mesh_graph_from_trace_data(bot, ["aa"], {})
assert bot.mesh_graph.add_edge.call_count == 1
def test_three_hop_creates_three_edges(self):
"""[a, b, c] path creates 3 edges."""
bot = _make_bot(bot_prefix="dd")
update_mesh_graph_from_trace_data(bot, ["aa", "bb", "cc"], {})
assert bot.mesh_graph.add_edge.call_count == 3
def test_path_hashes_lowercased(self):
"""Uppercase path hashes are normalized to lowercase."""
bot = _make_bot(bot_prefix="cc")
update_mesh_graph_from_trace_data(bot, ["AA", "BB"], {})
assert bot.mesh_graph.add_edge.call_count == 2
calls = bot.mesh_graph.add_edge.call_args_list
# All prefixes should be lowercase
for c in calls:
kw = c.kwargs
if "from_prefix" in kw:
assert kw["from_prefix"] == kw["from_prefix"].lower()
if "to_prefix" in kw:
assert kw["to_prefix"] == kw["to_prefix"].lower()
def test_last_edge_points_to_bot(self):
"""The last_node→bot edge has to_prefix equal to bot_prefix."""
bot = _make_bot(bot_prefix="cc")
update_mesh_graph_from_trace_data(bot, ["aa"], {})
call_kwargs = bot.mesh_graph.add_edge.call_args.kwargs
assert call_kwargs.get("to_prefix") == "cc"
assert call_kwargs.get("from_prefix") == "aa"
def test_db_exception_in_regular_path_handled(self):
"""DB exceptions don't prevent edges from being added."""
bot = _make_bot(bot_prefix="cc")
bot.db_manager.execute_query = Mock(side_effect=Exception("DB error"))
update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
assert bot.mesh_graph.add_edge.call_count == 2
def test_unique_key_resolved_for_last_node(self):
"""When exactly one key matches the last_node prefix, it's used."""
bot = _make_bot(bot_prefix="cc")
bot.db_manager.execute_query = Mock(side_effect=[
[{"count": 1}],
[{"public_key": "aa" * 32}],
])
update_mesh_graph_from_trace_data(bot, ["aa"], {})
assert bot.mesh_graph.add_edge.call_count == 1
def test_mesh_graph_add_edge_exception_propagates(self):
"""Exception from add_edge is not caught (it's a programming error)."""
bot = _make_bot(bot_prefix="cc")
bot.mesh_graph.add_edge = Mock(side_effect=RuntimeError("mesh error"))
with pytest.raises(RuntimeError):
update_mesh_graph_from_trace_data(bot, ["aa"], {})
# ---------------------------------------------------------------------------
# Multi-hop intermediate edges
# ---------------------------------------------------------------------------
class TestMultiHopEdges:
def test_hop_positions_decrease_towards_bot(self):
"""Hop positions are assigned based on distance from bot."""
bot = _make_bot(bot_prefix="dd")
update_mesh_graph_from_trace_data(bot, ["aa", "bb", "cc"], {})
calls = bot.mesh_graph.add_edge.call_args_list
# Collect hop_position values from kwargs
hop_positions = [c.kwargs.get("hop_position") for c in calls]
assert all(h is not None for h in hop_positions)
def test_single_hop_position_is_1(self):
"""Single-hop path has hop_position=1."""
bot = _make_bot(bot_prefix="bb")
update_mesh_graph_from_trace_data(bot, ["aa"], {})
call_kwargs = bot.mesh_graph.add_edge.call_args.kwargs
assert call_kwargs.get("hop_position") == 1
def test_no_meshcore_in_regular_path(self):
"""No meshcore still creates edges."""
bot = _make_bot(bot_prefix="cc")
bot.meshcore = None
update_mesh_graph_from_trace_data(bot, ["aa", "bb"], {})
assert bot.mesh_graph.add_edge.call_count == 2
class TestBotIdentityFromRadio:
"""The bot's node comes from the radio's key (meshcore self_info), at the trace's hash width."""
def test_one_byte_trace_names_the_bot_by_its_first_byte(self):
bot = _make_bot(bot_prefix=None)
bot.meshcore.self_info = {"public_key": "c3d4" + "00" * 30}
update_mesh_graph_from_trace_data(bot, ["aa"], {})
kwargs = bot.mesh_graph.add_edge.call_args.kwargs
assert kwargs["to_prefix"] == "c3"
assert kwargs["to_public_key"] == "c3d4" + "00" * 30
assert "prefix_bytes" not in kwargs
def test_two_byte_trace_names_the_bot_by_two_bytes_and_flags_the_edge(self):
bot = _make_bot(bot_prefix=None)
bot.meshcore.self_info = {"public_key": "c3d4" + "00" * 30}
update_mesh_graph_from_trace_data(bot, ["a1b2", "e5f6"], {})
last = bot.mesh_graph.add_edge.call_args_list[0].kwargs
assert (last["from_prefix"], last["to_prefix"]) == ("e5f6", "c3d4")
assert all(c.kwargs.get("prefix_bytes") == 2 for c in bot.mesh_graph.add_edge.call_args_list)