Files
meshcore-bot/tests/unit/test_weather_service_localization.py
agessaman 595f37a27b fix(i18n): address review findings on weather localization
- Extracted `modules/alert_format.py` as the single NWS alert formatter, replacing four copies of the event-type abbreviation table and two of the time compactor. `!wx alerts` and the proactive `WeatherService` broadcasts now localize from one code path, so a Russian bot no longer answers `!wx alerts` in English while its proactive alerts are Russian.
- Stopped leaking translation key paths into mesh broadcasts. `Translator.translate` returns the dotted key when a lookup misses in both the locale and the English fallback, which is right for development but reached the air in production: an unclassifiable NWS title rendered as `⚪Hazardous services.weather_service.event_types.Unknown`, an unmapped WMO code as `services.weather_service.weather_descriptions.4`, and an oddly-cased `wind_speed_unit` as `services.weather_service.wind_speed_units.KMH`. `alert_format.translate_or()` carries an English default at each site, and `WeatherService` now normalizes and validates its three `[Weather]` unit settings the way `GlobalWxCommand` already did.
- Fixed alert expiry rendering in every locale. The formatter rendered a timestamp to a string and re-parsed its own output with `(\d+)(AM|PM)` against a hardcoded English month list, so translated months took the wrong branch and truncated mid-string. Times now carry parsed parts and render through a per-locale `common.alerts.time_12h` template — the space before AM/PM was correct (Russian writes "6 дня", not "6дня"); the downstream regex was the bug.
- Restored month abbreviation. `_compact_time` iterated over abbreviations and replaced them in the string instead of mapping full names, so English stopped shortening "June 28" and Russian replaced the "Jun" inside "June", leaving a stray Latin "e" (`июнe 28`). Reuses the existing `common.date_time.month_abbreviations` rather than the duplicate `services.weather_service.months` block.
- Made `!gwx` display units follow `[Weather]` config instead of the response language. Visibility switched on `base_language != 'en'`, so `language = ru` with the default `temperature_unit = fahrenheit` printed Fahrenheit beside kilometers, and `en-GB` was forced to miles. Pressure is a locale convention rather than a metric/imperial split, so each catalog names its own via `commands.gwx.pressure_unit` — previously every non-English locale inherited mmHg from the English catalog, whose `pressure_mmhg` string contained Russian text, giving German and French users Cyrillic pressure units.
- Let localized `H`/`L` labels reach a standard install. `config.ini.example` shipped the three `temperature_*_format` keys uncommented with literal `H:`/`L:`, and a config value always beats the new locale-aware default, so a Russian bot built from the documented example still rendered `H:47°C L:33°C`. The example now uses the `{high_label}`/`{low_label}` placeholders, which were documented in the docstring but not in the file.
- Routed high/low labels through the reply's translator. `_format_high_low` passed `bot.translator`, so with `auto_detect_language` on, an English-default bot answering a Russian sender localized the rest of the line but not `H:`/`L:`. Added `BaseCommand.response_translator` for this, replacing `wx_international`'s reach into the private `_response_translator` ContextVar.
- Fixed a byte-budget overrun in `!gwx`. The guard on the extra conditions block compared a character count against a byte-derived budget while the rest of the function used `_count_display_width`; Cyrillic is two bytes per character, so the block was appended after the budget was spent.
- Reverted nine `commands.gwx` English rewordings that were not localization work, including the configuration hint in `mqtt_weather_no_subscriber` — dropping it left a mis-configured operator with no pointer to the two keys they need.
- Fixed the Russian `visibility` string, which said "км" on the miles key — the same locale/config conflation as the code bug, in the data. Shortened the Russian event-type abbreviations, which were full words consuming a quarter of the 130-byte budget at two bytes per character.
- Added `commands.wx.hourly_not_available`, missing from every catalog so `!wx hourly` printed the raw key path. Predates this branch; found while auditing every translation key the weather modules reference.
- Moved alert strings to `common.alerts.*` and wind directions to `common.wind_directions.*`, since a command and a service both read them.
2026-09-07 15:13:18 -07:00

142 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Unit tests for WeatherService's localized daily-forecast pieces.
Covers the presentation helpers the localization pass rewrote: an unmapped WMO
code, a wind-speed unit the operator wrote in unexpected casing, and the
compass direction lookup. All three used to render a translation key path into
a channel broadcast when the lookup missed.
"""
import configparser
from unittest.mock import Mock
import pytest
from modules.i18n import Translator
from modules.service_plugins.weather_service import WeatherService
pytestmark = pytest.mark.unit
def build_service(*, language="en", weather_overrides=None):
cfg = configparser.ConfigParser()
cfg.add_section("Weather")
cfg.add_section("Weather_Service")
cfg.set("Weather_Service", "my_position_lat", "47.6")
cfg.set("Weather_Service", "my_position_lon", "-122.3")
for key, value in (weather_overrides or {}).items():
cfg.set("Weather", key, value)
bot = Mock()
bot.logger = Mock()
bot.config = cfg
bot.db_manager = Mock()
bot.translator = Translator(language, "translations/")
service = WeatherService(bot)
service.api_session = Mock()
return service
class TestUnitConfigNormalization:
def test_casing_is_normalized(self):
service = build_service(weather_overrides={"wind_speed_unit": "KMH"})
assert service.wind_speed_unit == "kmh"
def test_unknown_unit_falls_back(self):
service = build_service(weather_overrides={"wind_speed_unit": "furlongs/fortnight"})
assert service.wind_speed_unit == "mph"
def test_temperature_and_precipitation_normalized(self):
service = build_service(weather_overrides={
"temperature_unit": "Celsius", "precipitation_unit": "MM",
})
assert service.temperature_unit == "celsius"
assert service.precipitation_unit == "mm"
@pytest.mark.parametrize(("unit", "label"),
[("mph", "mph"), ("kmh", "km/h"), ("ms", "m/s"), ("kn", "kn")])
def test_every_valid_unit_has_a_label(self, unit, label):
service = build_service(weather_overrides={"wind_speed_unit": unit})
from modules import alert_format as af
rendered = af.translate_or(
service.bot.translator,
f"services.weather_service.wind_speed_units.{service.wind_speed_unit}",
service.wind_speed_unit,
)
assert rendered == label
def test_odd_casing_never_renders_a_key_path(self):
service = build_service(weather_overrides={"wind_speed_unit": "KMH"})
from modules import alert_format as af
rendered = af.translate_or(
service.bot.translator,
f"services.weather_service.wind_speed_units.{service.wind_speed_unit}",
service.wind_speed_unit,
)
assert "services.weather_service" not in rendered
class TestWeatherDescription:
def test_mapped_code(self):
assert build_service()._get_weather_description(0) == "Clear"
def test_mapped_code_localized(self):
assert build_service(language="ru")._get_weather_description(0) == "Ясно"
@pytest.mark.parametrize("code", [4, 7, 123, 999])
def test_unmapped_code_says_unknown(self, code):
assert build_service()._get_weather_description(code) == "Unknown"
def test_unmapped_code_localized(self):
assert build_service(language="ru")._get_weather_description(4) == "Неизвестно"
@pytest.mark.parametrize("code", [4, 999])
def test_unmapped_code_never_renders_a_key_path(self, code):
for language in ("en", "ru", "de"):
out = build_service(language=language)._get_weather_description(code)
assert "weather_descriptions" not in out, out
class TestWindDirection:
@pytest.mark.parametrize(("degrees", "expected"),
[(0, "N"), (45, "NE"), (90, "E"), (180, "S"), (270, "W"), (359, "N")])
def test_english_compass(self, degrees, expected):
assert build_service()._degrees_to_direction(degrees) == expected
def test_localized_compass(self):
assert build_service(language="ru")._degrees_to_direction(292.5) == "ЗСЗ"
def test_none_is_empty(self):
assert build_service()._degrees_to_direction(None) == ""
def test_never_renders_a_key_path(self):
service = build_service(language="de")
for degrees in range(0, 360, 15):
assert "wind_directions" not in service._degrees_to_direction(degrees)
class TestAlertFormattingIsShared:
@pytest.mark.asyncio
async def test_matches_the_shared_helper(self):
from modules import alert_format as af
service = build_service()
alert = {
"event": "Flood Warning", "event_type": "Warning", "severity": "Severe",
"expires": "2026-06-28T18:00:00-07:00", "office": "NWS Seattle WA",
"area_desc": "King County", "link": "",
}
assert await service._format_alert_compact(alert) == af.format_alert_compact(
alert, service.bot.translator
)
@pytest.mark.asyncio
async def test_unknown_event_type_never_renders_a_key_path(self):
service = build_service(language="ru")
alert = {
"event": "Hazardous", "event_type": "Unknown", "severity": "Minor",
"expires": "", "office": "", "area_desc": "", "link": "",
}
assert await service._format_alert_compact(alert) == "⚪Hazardous Unknown"