mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-10-31 18:12:45 +00:00
- 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.
142 lines
5.4 KiB
Python
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"
|