mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-30 19:37:59 +00:00
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:
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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"
|
||||
Reference in New Issue
Block a user