From 79a5339fdf6d694c31dd2e0d0207a184b538e15c Mon Sep 17 00:00:00 2001 From: Shakti Prasad Mohapatra Date: Thu, 30 Jul 2026 11:36:43 +0530 Subject: [PATCH] refactor(eccc): harden language matching for bare primary subtags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ECCC feed consistently carries en-CA and fr-CA info blocks, and config_flow restricts the language option to those two values, so exact-match selection works today. However, if a CAP body ever declared a bare 'en' instead of 'en-CA', the existing == check would miss it and fall through to doc.infos[0], which can be the French block. _language_matches now falls back to comparing the primary subtag (the part before the first '-') when the exact match fails, so 'en' matches 'en-CA' and vice-versa. This is hardening, not a fix for an observed defect — the ECCC feed has not been seen to do this, but prefix matching makes the selection safe if it ever does. Adds five tests covering exact match, bare-primary against region-tag, en-CA/fr-CA negative case, empty-string guards, and case insensitivity. Re-run in the pinned venv (requirements_test.txt): 123 passed, 0 failed in test_eccc_provider.py. Ruff check and format clean. Assisted-by: GitHub Copilot:glm-5.2 --- .../cap_alerts/providers/eccc.py | 26 ++++++++++++-- tests/test_eccc_provider.py | 34 +++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/custom_components/cap_alerts/providers/eccc.py b/custom_components/cap_alerts/providers/eccc.py index 1b4d218..f139be8 100644 --- a/custom_components/cap_alerts/providers/eccc.py +++ b/custom_components/cap_alerts/providers/eccc.py @@ -357,6 +357,24 @@ def _pick_cap_link(entry: Element) -> tuple[str, str]: # --------------------------------------------------------------------------- +def _language_matches(info_lang: str, preferred: str) -> bool: + """Check language match with BCP 47 primary-subtag fallback. + + Exact match wins (``en-CA`` == ``en-CA``). When that fails, the primary + subtag (the part before the first ``-``) is compared so that a bare + ``en`` info block matches a preferred ``en-CA`` and vice-versa. This + hardens against CAP documents that declare a bare primary subtag + instead of the full region-tagged form the ECCC feed normally carries. + """ + if not info_lang or not preferred: + return False + if info_lang == preferred: + return True + return ( + preferred.split("-", 1)[0].casefold() == info_lang.split("-", 1)[0].casefold() + ) + + def _select_info(doc: CAPDoc, language: str) -> CAPInfoDoc: """Pick the block matching language; fall back to first. @@ -368,7 +386,7 @@ def _select_info(doc: CAPDoc, language: str) -> CAPInfoDoc: if not doc.infos: return CAPInfoDoc() for info in doc.infos: - if info.language == language: + if _language_matches(info.language, language): return info return doc.infos[0] @@ -423,7 +441,9 @@ def _select_region_info( province where the alert is still live would be the worse failure. """ candidates = ( - [info for info in doc.infos if info.language == language] if language else [] + [info for info in doc.infos if _language_matches(info.language, language)] + if language + else [] ) if not candidates: # No block declares this language (single-language document, or a @@ -731,7 +751,7 @@ def _merge_languages(variants: list[CAPAlert], preferred_lang: str) -> CAPAlert: primary = None alt = None for v in variants: - if v.language == preferred_lang: + if _language_matches(v.language, preferred_lang): primary = v else: alt = v diff --git a/tests/test_eccc_provider.py b/tests/test_eccc_provider.py index 599cd6f..0cb9a40 100644 --- a/tests/test_eccc_provider.py +++ b/tests/test_eccc_provider.py @@ -67,6 +67,7 @@ def _load_provider(name: str) -> types.ModuleType: _parse_cap_alert = _cap_mod.parse_cap_alert _select_info = _eccc_mod._select_info _select_region_info = _eccc_mod._select_region_info +_language_matches = _eccc_mod._language_matches _location_status = _eccc_mod._location_status _is_terminal_info = _eccc_mod._is_terminal_info _resolve_chain_leaves = _cap_mod.resolve_chain_leaves @@ -302,6 +303,39 @@ def test_parse_cap_alert_tolerates_newline_separated_references(): assert doc.references[1][1] == "urn:oid:2.49.0.1.124.test.2026.OLD.EN" +# --------------------------------------------------------------------------- +# _language_matches tests +# --------------------------------------------------------------------------- + + +def test_language_matches_exact_match(): + assert _language_matches("en-CA", "en-CA") is True + assert _language_matches("fr-CA", "fr-CA") is True + + +def test_language_matches_bare_primary_against_region(): + """Bare 'en' info block should match preferred 'en-CA'.""" + assert _language_matches("en", "en-CA") is True + assert _language_matches("en-CA", "en") is True + + +def test_language_matches_different_primary_subtags(): + """en-CA and fr-CA must not match each other.""" + assert _language_matches("en-CA", "fr-CA") is False + assert _language_matches("fr-CA", "en-CA") is False + + +def test_language_matches_empty_strings(): + assert _language_matches("", "en-CA") is False + assert _language_matches("en-CA", "") is False + assert _language_matches("", "") is False + + +def test_language_matches_case_insensitive(): + assert _language_matches("EN-CA", "en-ca") is True + assert _language_matches("en-CA", "EN-ca") is True + + # --------------------------------------------------------------------------- # _select_info tests # ---------------------------------------------------------------------------