mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-26 01:23:39 +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.
259 lines
9.5 KiB
Python
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) == ""
|