Files
meshcore-bot/tests/unit/test_alert_format.py
T
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

259 lines
9.5 KiB
Python

#!/usr/bin/env python3
"""Unit tests for the shared NWS alert formatter (modules.alert_format)."""
import pytest
from modules import alert_format as af
from modules.i18n import Translator
pytestmark = pytest.mark.unit
@pytest.fixture
def en():
return Translator("en")
@pytest.fixture
def ru():
return Translator("ru")
def alert(**overrides):
base = {
"event": "Flood Warning",
"event_type": "Warning",
"severity": "Severe",
"expires": "2026-06-28T18:00:00-07:00",
"office": "NWS Seattle WA",
"area_desc": "King County; Snohomish County",
}
base.update(overrides)
return base
class TestTranslateOr:
def test_present_key_translates(self, ru):
assert af.translate_or(ru, "common.unknown", "Unknown") == "Неизвестно"
def test_missing_key_uses_default(self, en):
assert af.translate_or(en, "no.such.key.at.all", "fallback") == "fallback"
def test_missing_key_formats_default(self, en):
assert af.translate_or(en, "no.such.key", "{n} left", n=3) == "3 left"
def test_no_translator_uses_default(self):
assert af.translate_or(None, "common.unknown", "Unknown") == "Unknown"
class TestEventTypeAbbrev:
@pytest.mark.parametrize(
("event_type", "expected"),
[("Warning", "Warn"), ("Watch", "Watch"), ("Advisory", "Adv"), ("Statement", "Stmt")],
)
def test_known_types(self, en, event_type, expected):
assert af.event_type_abbrev(event_type, en) == expected
def test_unknown_type_never_leaks_a_key(self, en):
# _parse_alert_entry emits "Unknown" for any title we cannot classify.
assert af.event_type_abbrev("Unknown", en) == "Unknown"
def test_unknown_type_never_leaks_a_key_localized(self, ru):
assert af.event_type_abbrev("Unknown", ru) == "Unknown"
def test_empty_type(self, en):
assert af.event_type_abbrev("", en) == ""
def test_localized_abbreviations_stay_abbreviations(self, ru):
# Full words would eat a quarter of the 130-byte budget at 2 bytes/char.
for event_type in ("Warning", "Watch", "Advisory", "Statement"):
assert len(af.event_type_abbrev(event_type, ru).encode("utf-8")) <= 16
class TestCompactTime:
def test_iso_english(self, en):
assert af.compact_time("2026-06-28T18:00:00-07:00", en) == "Jun 28 6PM"
def test_iso_midnight_and_noon(self, en):
assert af.compact_time("2026-06-28T00:30:00-07:00", en) == "Jun 28 12AM"
assert af.compact_time("2026-06-28T12:00:00-07:00", en) == "Jun 28 12PM"
def test_iso_russian_separates_hour_from_meridiem(self, ru):
# "6дня" is not Russian; the locale owns the separator.
assert af.compact_time("2026-06-28T18:00:00-07:00", ru) == "июн 28 6 дня"
def test_full_month_name_is_abbreviated(self, en):
assert af.compact_time("June 28 at 6:00AM", en) == "Jun 28 6AM"
def test_full_month_name_is_not_corrupted_mid_word(self, ru):
# Replacing the "Jun" inside "June" used to leave a stray Latin "e".
assert af.compact_time("June 28 at 6:00AM", ru) == "июн 28 6 утра"
@pytest.mark.parametrize("month", ["March", "April", "May", "July", "August"])
def test_no_latin_residue_in_any_month(self, ru, month):
out = af.compact_time(f"{month} 3 at 1:00PM", ru)
assert not any(ch.isascii() and ch.isalpha() for ch in out), out
def test_minutes_are_kept(self, en):
assert af.compact_time("December 16 at 3:12PM", en) == "Dec 16 3:12PM"
def test_empty_passes_through(self, en):
assert af.compact_time("", en) == ""
def test_unparseable_iso_falls_back_to_text_path(self, en):
assert af.compact_time("2026-13-45T99:00:00", en) == "2026-13-45T99:00:00"
def test_no_translator_is_english(self):
assert af.compact_time("2026-06-28T18:00:00-07:00") == "Jun 28 6PM"
class TestExtractClock:
def test_reads_clock_off_english_source(self, en):
assert af.extract_clock("December 17 at 6:00AM PST", en) == "6AM"
def test_keeps_real_minutes(self, en):
assert af.extract_clock("December 16 at 3:12PM PST", en) == "3:12PM"
def test_localized(self, ru):
assert af.extract_clock("December 17 at 6:00AM PST", ru) == "6 утра"
def test_no_clock_returns_none(self, en):
assert af.extract_clock("sometime tomorrow", en) is None
class TestFormatAlertCompact:
def test_english_iso_expiry_is_time_only(self, en):
assert af.format_alert_compact(alert(), en) == "🟠Flood Warning King til 6PM by NWS SEA"
def test_russian_iso_expiry_is_time_only(self, ru):
# The English-month check plus an (AM|PM) regex used to send every
# non-English locale down a mid-string truncation.
assert af.format_alert_compact(alert(), ru) == "🟠Flood Warning King до 6 дня от NWS SEA"
def test_free_text_expiry_is_time_only(self, en):
out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), en)
assert out == "🟠Flood Warning King til 6AM by NWS SEA"
def test_free_text_expiry_is_not_truncated_mid_word(self, ru):
out = af.format_alert_compact(alert(expires="December 17 at 6:00AM PST"), ru)
assert out == "🟠Flood Warning King до 6 утра от NWS SEA"
def test_unknown_event_type_never_leaks_a_key(self, en):
out = af.format_alert_compact(
alert(event="Hazardous", event_type="Unknown", severity="Minor",
expires="", office="", area_desc=""),
en,
)
assert out == "⚪Hazardous Unknown"
def test_summary_form_omits_details(self, en):
out = af.format_alert_compact(alert(), en, include_details=False)
assert out == "🟠Flood Warning Warn"
def test_location_can_be_suppressed(self, en):
out = af.format_alert_compact(alert(), en, include_location=False)
assert out == "🟠Flood Warning til 6PM by NWS SEA"
def test_redundant_event_type_is_dropped(self, en):
# "Flood Warning" already says "Warning".
assert "Warn " not in af.format_alert_compact(alert(), en)
def test_event_type_is_appended_when_not_redundant(self, en):
out = af.format_alert_compact(
alert(event="Dense Fog", event_type="Advisory", expires="", office="", area_desc=""), en
)
assert out == "🟠Dense Fog Adv"
def test_severity_emoji(self, en):
for severity, emoji in (("Extreme", "🔴"), ("Severe", "🟠"),
("Moderate", "🟡"), ("Minor", "⚪"), ("Nonsense", "⚪")):
out = af.format_alert_compact(
alert(severity=severity, expires="", office="", area_desc=""), en
)
assert out.startswith(emoji)
def test_fits_the_mesh_budget_in_both_locales(self, en, ru):
for translator in (en, ru):
out = af.format_alert_compact(alert(), translator)
assert len(out.encode("utf-8")) <= 130, out
class TestShortenEvent:
def test_short_name_untouched(self):
assert af.shorten_event("Dense Fog") == "Dense Fog"
def test_long_name_keeps_two_words(self):
assert af.shorten_event("Excessive Heat Warning Extended") == "Excessive Heat"
def test_single_long_word_is_cut(self):
assert af.shorten_event("Thunderstormageddon") == "Thunderstormage"
def test_summary_mode_keeps_one_word(self):
assert af.shorten_event("Excessive Heat Warning", limit=12, max_words=1) == "Excessive"
def test_none_limit_never_trims(self):
assert af.shorten_event("Excessive Heat Warning Extended", limit=None) == \
"Excessive Heat Warning Extended"
class TestLocation:
def test_strips_state(self):
assert af.first_location("Seattle, WA") == "Seattle"
def test_strips_county(self):
assert af.first_location("King County; Snohomish County") == "King"
def test_keeps_multiword_place(self):
assert af.first_location("Puget Sound") == "Puget Sound"
def test_truncates_long_name(self):
assert len(af.first_location("A" * 40)) == 20
def test_empty(self):
assert af.first_location("") == ""
class TestOffice:
def test_known_city(self, en):
assert af.format_office("NWS Seattle WA", en) == "by NWS SEA"
def test_localized_label(self, ru):
assert af.format_office("NWS Seattle WA", ru) == "от NWS SEA"
def test_single_token(self, en):
assert af.format_office("NWS", en) == "by NWS"
def test_empty(self, en):
assert af.format_office("", en) == ""
class TestCityAbbrev:
@pytest.mark.parametrize(("city", "expected"),
[("Seattle", "SEA"), ("Seattle WA", "SEA"), ("Portland", "PDX")])
def test_known(self, city, expected):
assert af.abbreviate_city_name(city) == expected
def test_initials_for_unknown_multiword(self):
assert af.abbreviate_city_name("Little Rock") == "LR"
def test_prefix_for_unknown_single_word(self):
assert af.abbreviate_city_name("Bozeman") == "BOZE"
def test_empty(self):
assert af.abbreviate_city_name("") == ""
class TestAlertWindow:
def test_both_ends(self, en):
out = af.format_alert_window(
{"effective": "2026-06-28T15:00:00-07:00", "expires": "2026-06-29T06:00:00-07:00"}, en
)
assert out == "from Jun 28 3PM til Jun 29 6AM"
def test_localized(self, ru):
out = af.format_alert_window({"expires": "2026-06-29T06:00:00-07:00"}, ru)
assert out == "до июн 29 6 утра"
def test_no_times(self, en):
assert af.format_alert_window({}, en) == ""