diff --git a/config.ini.example b/config.ini.example index cb3eabf..dcef21f 100644 --- a/config.ini.example +++ b/config.ini.example @@ -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). diff --git a/modules/commands/test_command.py b/modules/commands/test_command.py index 9f2d320..a0537ee 100644 --- a/modules/commands/test_command.py +++ b/modules/commands/test_command.py @@ -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: diff --git a/modules/utils.py b/modules/utils.py index 4a9e4ae..b3c46d2 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -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" diff --git a/tests/test_utils.py b/tests/test_utils.py index 70f0ef6..9bd781c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -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 diff --git a/tests/unit/test_command_localized_distance.py b/tests/unit/test_command_localized_distance.py new file mode 100644 index 0000000..64a1f78 --- /dev/null +++ b/tests/unit/test_command_localized_distance.py @@ -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"