Files
meshcore-bot/tests/unit/test_path_command_distance.py
T
agessaman fc63039a48 fix: address Codex review of tonight's work
Correctness:

- {path_distance} was always blank in production. The resolution code that
  builds repeater_info dropped latitude/longitude in every branch, so the
  calculator could never find a coordinate. My tests passed hand-built
  dicts straight to the calculator and never exercised the builder, which
  is why they stayed green. Coordinates are now carried through all four
  construction sites, and the new tests drive _lookup_repeater_names via
  its lookup_func hook so the real builder runs.

- The #80 route guard was defeated two ways in the channel handler. When
  the RF data was an uncorrelated fallback, control fell through to the
  raw-hex and routing_info fallbacks below, which took the route from the
  unrelated packet anyway; the guard had actually made that path
  reachable. message.routing_info was also assigned unconditionally, and
  the path command reads it. "Not attributable" is now a terminal branch
  and the routing_info hand-off checks provenance.

- Same fix was incomplete for DMs: routing_info was captured and turned
  into path_info before the provenance check ran, so the later check only
  declined to overwrite an already-wrong value. Guarded at the source.

- Rendering could transmit for real. Capture only intercepts
  send_response, but advert calls send_advert() directly and
  send_response_chunked never checked capture_sink. Chunked sends are now
  captured, and rendering is opt-in via BaseCommand.render_safe (default
  False) instead of a denylist that cannot be complete. This also closes
  the DM-only leak: schedule is not marked safe, so {cmd:schedule} can no
  longer broadcast configuration to a channel.

- Multi-part rendered output was rejoined into one oversized send.
  Scheduled messages are now split to the RF body budget and sent through
  send_channel_messages_chunked, on character boundaries so multi-byte
  text is not corrupted.

- _last_path_distance_km is instance state that was only set on success,
  so an invalid path request could show the previous request's distance.
  Reset at the start of every execute().

- Stale-contact retries were only counted on non-OK results, so timeouts
  and exceptions left a contact eligible forever and could recreate the
  storm. All failed attempts count now.

Web viewer:

- A failed config reload was reported as a successful save. The API and
  UI now distinguish "saved and active" from "saved, restart needed".

- Preview count was unbounded; clamped to 1-20.

- update_ini_values is a read-modify-replace with no locking, so two
  concurrent viewer saves could lose one. Serialised behind a lock.
2026-08-22 00:16:03 -07:00

148 lines
5.9 KiB
Python

#!/usr/bin/env python3
"""
Unit tests for the PathCommand {path_distance} placeholder.
Distance is summed sender -> each resolved hop -> bot, and is deliberately blank
whenever any node in that chain lacks usable coordinates, so a partial sum never
gets reported as the real distance travelled.
"""
import pytest
from modules.commands.path_command import PathCommand
@pytest.mark.unit
class TestPathCommandDistance:
"""_calculate_path_distance_km and its {path_distance} rendering."""
@pytest.fixture
def path_command(self, mock_bot):
cmd = PathCommand(mock_bot)
# Bot sits at the origin; sender and hops are placed east of it.
cmd.bot_latitude = 47.0
cmd.bot_longitude = -122.0
cmd._get_sender_location = lambda: (47.0, -122.5)
return cmd
@staticmethod
def _info(lat, lon, **over):
base = {'found': True, 'collision': False, 'latitude': lat, 'longitude': lon}
base.update(over)
return base
def test_sums_sender_through_hops_to_bot(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2)}
km = path_command._calculate_path_distance_km(['AA', 'BB'], info)
assert km is not None
# Three legs spanning 0.5 deg of longitude at 47N (~75.9 km/deg) => ~38 km.
assert 37.0 < km < 39.0
def test_renders_with_km_suffix(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2)}
path_command._last_path_distance_km = path_command._calculate_path_distance_km(
['AA', 'BB'], info
)
rendered = path_command._format_path_distance()
assert rendered.endswith("km")
assert rendered[0].isdigit()
def test_blank_when_a_hop_has_no_coordinates(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(None, None)}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_a_hop_is_a_prefix_collision(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': self._info(47.0, -122.2, collision=True)}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_a_hop_is_unresolved(self, path_command):
info = {'AA': self._info(47.0, -122.4), 'BB': {'found': False}}
assert path_command._calculate_path_distance_km(['AA', 'BB'], info) is None
def test_blank_when_hop_coordinates_are_null_island(self, path_command):
"""0,0 in the DB means 'unset', not a real position in the Gulf of Guinea."""
info = {'AA': self._info(0, 0)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_blank_when_sender_location_unknown(self, path_command):
path_command._get_sender_location = lambda: None
info = {'AA': self._info(47.0, -122.4)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_blank_when_bot_has_no_configured_position(self, path_command):
path_command.bot_latitude = None
info = {'AA': self._info(47.0, -122.4)}
assert path_command._calculate_path_distance_km(['AA'], info) is None
def test_placeholder_is_empty_string_when_unmeasurable(self, path_command):
path_command._last_path_distance_km = None
assert path_command._format_path_distance() == ""
@pytest.mark.unit
class TestDistanceThroughTheRealLookup:
"""Regression for a gap the unit tests above could not see.
Those pass hand-built repeater_info dicts straight to the calculator. The
resolution code that actually *builds* repeater_info was dropping latitude and
longitude, so {path_distance} was always blank in production while the tests
stayed green. These feed raw DB-shaped rows through _lookup_repeater_names via
its lookup_func hook, so the real construction code runs.
"""
@pytest.fixture
def path_command(self, mock_bot):
from modules.commands.path_command import PathCommand
cmd = PathCommand(mock_bot)
cmd.bot_latitude = 47.0
cmd.bot_longitude = -122.0
cmd._get_sender_location = lambda: (47.0, -122.5)
return cmd
@staticmethod
def _row(node_id, lat, lon):
"""A row shaped like the repeater query's output."""
return {
'name': f'Hop {node_id}',
'public_key': node_id.lower() * 32,
'device_type': 'Repeater',
'last_seen': '2026-08-21 12:00:00',
'last_heard': '2026-08-21 12:00:00',
'last_advert_timestamp': None,
'is_active': True,
'latitude': lat,
'longitude': lon,
'city': 'Seattle',
'state': 'WA',
'country': 'US',
'snr': 5.0,
'is_starred': False,
}
@pytest.mark.asyncio
async def test_coordinates_survive_the_real_repeater_info_builder(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, 47.0, -122.3)]
)
assert info['AA']['found'] is True
# The bug: these were dropped when repeater_info was constructed.
assert info['AA']['latitude'] == 47.0
assert info['AA']['longitude'] == -122.3
@pytest.mark.asyncio
async def test_distance_is_computed_end_to_end(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, 47.0, -122.3)]
)
km = path_command._calculate_path_distance_km(['AA'], info)
assert km is not None and km > 0
@pytest.mark.asyncio
async def test_row_without_coordinates_still_yields_no_distance(self, path_command):
info = await path_command._lookup_repeater_names(
['AA'], lookup_func=lambda node_id: [self._row(node_id, None, None)]
)
assert path_command._calculate_path_distance_km(['AA'], info) is None