feat(test): localize test-command distance units (#275)

[Test_Command] distance_unit (auto/km/mi) controls {path_distance} and {firstlast_distance}. Auto follows the reply language (miles for en/en-US, kilometres otherwise, including en-GB). Elapsed times of 1s and above render as seconds to keep the ack short.
This commit is contained in:
CoderNemesis
2026-09-13 12:49:21 -07:00
committed by GitHub
parent 1f87707ade
commit b5fc971148
5 changed files with 282 additions and 4 deletions
+10
View File
@@ -1571,6 +1571,16 @@ require_path_bytes_greater_or_equal_to = 0
# Optional response when rejected by path-byte requirement; leave empty for silent reject
require_path_bytes_failure_response =
# Unit for the {path_distance} and {firstlast_distance} placeholders.
# auto (default) = follow the reply language: miles for en / en-US, kilometres
# for every other locale, en-GB included (it shares the English
# catalog but not the units)
# km = always kilometres
# mi = always miles
# An invalid value falls back to auto with a startup warning. This only changes
# what is printed; repeater-selection distances are always computed in km.
distance_unit = auto
# Optional: full ack template for test/t. When set, overrides [Keywords] test for this command only.
# Same placeholders as Keywords (sender, phrase, phrase_part, connection_info, path, hops, hops_label,
# timestamp, elapsed, snr, rssi, packet_hash, path_distance, firstlast_distance).
+82 -3
View File
@@ -37,11 +37,20 @@ class TestCommand(BaseCommand):
settings_schema = [
{"key": "response_format", "label": "Response format", "type": "str", "default": "",
"help": "Template for the test reply. Empty uses the default format."},
{"key": "distance_unit", "label": "Distance unit", "type": "enum",
"options": [
{"value": "auto", "label": "Auto (follow reply language)"},
{"value": "km", "label": "Kilometres"},
{"value": "mi", "label": "Miles"},
],
"default": "auto",
"help": "Unit for {path_distance} and {firstlast_distance}."},
]
def __init__(self, bot):
super().__init__(bot)
self.test_enabled = self.get_config_value('Test_Command', 'enabled', fallback=True, value_type='bool')
self.distance_unit = self._read_distance_unit()
# Get bot location from config for geographic proximity calculations
self.geographic_guessing_enabled = False
self.bot_latitude = None
@@ -536,6 +545,75 @@ class TestCommand(BaseCommand):
return best_repeater
# Whether a reply reads in kilometres or miles is an operator choice, so
# [Test_Command] distance_unit decides it — the same call !gwx makes with its
# [Weather] unit config. 'auto' keeps the language as a proxy for the operator
# not having stated one: only US English gets miles, and en-GB is deliberately
# excluded because it shares the "en" catalog but not the units.
KM_TO_MILES = 0.621371
DISTANCE_UNITS = frozenset({'auto', 'km', 'mi'})
MILES_LANGUAGES = frozenset({'en', 'en-us'})
def _read_distance_unit(self) -> str:
"""Read and validate ``[Test_Command] distance_unit``.
Returns:
str: One of 'auto', 'km' or 'mi'; 'auto' when unset or invalid.
"""
raw = self.get_config_value('Test_Command', 'distance_unit', fallback='auto')
unit = str(raw or 'auto').strip().lower()
if unit not in self.DISTANCE_UNITS:
self.logger.warning(
f"Invalid distance_unit '{raw}' in [Test_Command]; "
f"expected one of {sorted(self.DISTANCE_UNITS)}. Falling back to 'auto'."
)
return 'auto'
return unit
def _response_language(self) -> str:
"""Get the normalized language code the current reply is rendered in.
Prefers the translator bound for this reply (so an auto-detected sender
language wins) and falls back to ``[Localization] language``.
Returns:
str: Lowercased language code with ``_`` normalized to ``-`` (e.g. 'en-gb').
"""
language = getattr(self.response_translator, 'language', None)
if not isinstance(language, str) or not language:
try:
language = self.bot.config.get('Localization', 'language', fallback='en')
except Exception:
language = 'en'
return str(language).strip().replace('_', '-').lower() or 'en'
def _uses_miles(self) -> bool:
"""Check whether distances should be rendered in miles.
Returns:
bool: True when ``distance_unit`` is 'mi', or when it is 'auto' and
the reply language is US English.
"""
if self.distance_unit != 'auto':
return self.distance_unit == 'mi'
return self._response_language() in self.MILES_LANGUAGES
def _format_distance(self, distance_km: float) -> str:
"""Format a distance for display in the reply's units.
Only display is converted; proximity scoring stays in kilometres so the
repeater-selection thresholds keep their meaning.
Args:
distance_km: Distance in kilometres.
Returns:
str: Distance with its unit suffix, e.g. ``'12.4km'`` or ``'7.7mi'``.
"""
if self._uses_miles():
return f"{distance_km * self.KM_TO_MILES:.1f}mi"
return f"{distance_km:.1f}km"
def _calculate_path_distance(self, message: MeshMessage) -> str:
"""Calculate total distance along path (sum of distances between consecutive repeaters with locations).
@@ -589,10 +667,11 @@ class TestCommand(BaseCommand):
return "" # No valid segments found
# Format the result compactly
distance_str = self._format_distance(total_distance)
if skipped_nodes > 0:
return f"{total_distance:.1f}km ({valid_segments} segs, {skipped_nodes} no-loc)"
return f"{distance_str} ({valid_segments} segs, {skipped_nodes} no-loc)"
else:
return f"{total_distance:.1f}km ({valid_segments} segs)"
return f"{distance_str} ({valid_segments} segs)"
def _calculate_firstlast_distance(self, message: MeshMessage) -> str:
"""Calculate straight-line distance between first and last repeater in path.
@@ -634,7 +713,7 @@ class TestCommand(BaseCommand):
last_location[0], last_location[1]
)
return f"{distance:.1f}km"
return self._format_distance(distance)
def format_response(self, message: MeshMessage, response_format: str,
extra: Optional[dict[str, Any]] = None) -> str:
+2
View File
@@ -2395,6 +2395,8 @@ def format_elapsed_display(ts: Any, translator: Any = None) -> str:
elapsed_ms = (datetime.now(UTC).timestamp() - ts_f) * 1000
if elapsed_ms < 0 or elapsed_ms > _ELAPSED_MS_MAX:
return _sync_str()
if elapsed_ms >= 1000:
return f"{round(elapsed_ms / 1000, 1)}s"
return f"{round(elapsed_ms)}ms"
+9 -1
View File
@@ -166,11 +166,19 @@ class TestFormatElapsedDisplay:
def test_valid_recent_timestamp_returns_ms(self):
import time
ts = time.time() - 1.5 # 1.5 seconds ago
ts = time.time() - 0.5 # 0.5 seconds ago
result = format_elapsed_display(ts)
assert "ms" in result
assert "Sync" not in result
def test_valid_recent_timestamp_returns_s(self):
import time
ts = time.time() - 1.5 # 1.5 seconds ago
result = format_elapsed_display(ts)
assert "s" in result
assert "ms" not in result
assert "Sync" not in result
def test_future_timestamp_returns_sync_message(self):
import time
ts = time.time() + 3600 # 1 hour in future
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""
Unit tests for TestCommand distance units.
[Test_Command] distance_unit decides the unit for the {path_distance} and
{firstlast_distance} placeholders. Under the default 'auto' it follows the reply
language: miles for US English, kilometres everywhere else — en-GB shares the
"en" catalog but not the units, so it stays metric.
"""
import pytest
from modules.commands.test_command import TestCommand as MeshTestCommand
from tests.conftest import mock_message
@pytest.fixture
def build_command(mock_bot):
"""Build a TestCommand whose repeater lookups resolve to fixed coordinates.
distance_unit is read once at construction, so it is a build-time argument.
"""
for section in ('Localization', 'Test_Command'):
if not mock_bot.config.has_section(section):
mock_bot.config.add_section(section)
def _build(distance_unit=None):
if distance_unit is None:
mock_bot.config.remove_option('Test_Command', 'distance_unit')
else:
mock_bot.config.set('Test_Command', 'distance_unit', distance_unit)
cmd = MeshTestCommand(mock_bot)
# Two hops one degree of longitude apart at 47N (~75.8 km/deg), so the
# distance is large enough that km and mi cannot be confused.
coords = {'AA': (47.0, -122.0), 'BB': (47.0, -121.0)}
cmd._lookup_repeater_location = lambda node_id, path_context=None: coords.get(node_id)
return cmd
return _build
@pytest.fixture
def test_command(build_command):
"""TestCommand with the default distance_unit ('auto')."""
return build_command()
def _two_hop_message():
return mock_message(
content="test",
routing_info={'path_length': 2, 'path_nodes': ['AA', 'BB']},
)
def _set_language(cmd, language):
cmd.bot.config.set('Localization', 'language', language)
@pytest.mark.unit
class TestDistanceUnitConfig:
"""distance_unit overrides the language-derived unit."""
def test_defaults_to_auto(self, test_command):
assert test_command.distance_unit == 'auto'
def test_km_forces_metric_for_english(self, build_command):
cmd = build_command('km')
_set_language(cmd, 'en')
assert cmd._format_distance(100.0) == "100.0km"
def test_mi_forces_miles_for_german(self, build_command):
cmd = build_command('mi')
_set_language(cmd, 'de')
assert cmd._format_distance(100.0) == "62.1mi"
def test_value_is_normalized(self, build_command):
cmd = build_command(' MI ')
_set_language(cmd, 'de')
assert cmd.distance_unit == 'mi'
assert cmd._format_distance(100.0) == "62.1mi"
def test_invalid_value_falls_back_to_auto(self, build_command):
cmd = build_command('furlongs')
assert cmd.distance_unit == 'auto'
_set_language(cmd, 'en')
assert cmd._format_distance(100.0) == "62.1mi"
_set_language(cmd, 'de')
assert cmd._format_distance(100.0) == "100.0km"
def test_invalid_value_warns_once_at_startup(self, build_command):
cmd = build_command('furlongs')
warnings = [str(call) for call in cmd.logger.warning.call_args_list]
assert any('distance_unit' in w and 'furlongs' in w for w in warnings)
# Rendering must not re-warn on every reply.
cmd.logger.warning.reset_mock()
cmd._format_distance(100.0)
cmd.logger.warning.assert_not_called()
def test_explicit_unit_reaches_the_placeholders(self, build_command):
cmd = build_command('km')
_set_language(cmd, 'en')
assert cmd._calculate_firstlast_distance(_two_hop_message()) == "75.8km"
@pytest.mark.unit
class TestAutoUnitFollowsLanguage:
"""Under 'auto', _format_distance picks its unit from the reply language."""
def test_english_converts_to_miles(self, test_command):
_set_language(test_command, 'en')
assert test_command._format_distance(100.0) == "62.1mi"
def test_non_english_stays_metric(self, test_command):
_set_language(test_command, 'de')
assert test_command._format_distance(100.0) == "100.0km"
def test_en_us_converts_to_miles(self, test_command):
_set_language(test_command, 'en-US')
assert test_command._format_distance(100.0) == "62.1mi"
def test_en_gb_stays_metric(self, test_command):
# en-GB reads the English catalog but not US units — the same split the
# !gwx unit fix settled on.
_set_language(test_command, 'en-GB')
assert test_command._format_distance(100.0) == "100.0km"
def test_underscore_locale_is_normalized(self, test_command):
_set_language(test_command, 'en_US')
assert test_command._format_distance(100.0) == "62.1mi"
def test_missing_language_defaults_to_miles(self, test_command):
# [Localization] language defaults to 'en' throughout the bot.
assert test_command._format_distance(100.0) == "62.1mi"
def test_response_translator_language_wins(self, test_command):
# An auto-detected sender language must carry the units with it.
_set_language(test_command, 'en')
test_command.bot.translator.language = 'fr'
assert test_command._format_distance(100.0) == "100.0km"
@pytest.mark.unit
class TestPathDistancePlaceholders:
"""The rendered {path_distance} / {firstlast_distance} strings."""
def test_path_distance_in_miles_for_english(self, test_command):
_set_language(test_command, 'en')
rendered = test_command._calculate_path_distance(_two_hop_message())
assert rendered.endswith(" (1 segs)")
assert "mi " in rendered
assert "km" not in rendered
def test_path_distance_in_km_for_spanish(self, test_command):
_set_language(test_command, 'es')
rendered = test_command._calculate_path_distance(_two_hop_message())
assert rendered.endswith(" (1 segs)")
assert "km " in rendered
assert "mi" not in rendered
def test_firstlast_distance_in_miles_for_english(self, test_command):
_set_language(test_command, 'en')
assert test_command._calculate_firstlast_distance(_two_hop_message()) == "47.1mi"
def test_firstlast_distance_in_km_for_spanish(self, test_command):
_set_language(test_command, 'es')
assert test_command._calculate_firstlast_distance(_two_hop_message()) == "75.8km"
def test_miles_value_is_the_converted_km_value(self, test_command):
_set_language(test_command, 'es')
km = float(test_command._calculate_firstlast_distance(_two_hop_message())[:-2])
_set_language(test_command, 'en')
mi = float(test_command._calculate_firstlast_distance(_two_hop_message())[:-2])
assert mi == pytest.approx(km * 0.621371, abs=0.05)
def test_direct_connection_is_unit_agnostic(self, test_command):
_set_language(test_command, 'en')
direct = mock_message(content="test", routing_info={'path_length': 0})
assert test_command._calculate_path_distance(direct) == "N/A"
assert test_command._calculate_firstlast_distance(direct) == "N/A"