Clean up translations for mocked integrations inbetween tests (#138732)

* Clean up translations for mocked integrations inbetween tests

* Adjust code, add test

* Fix docstring

* Improve cleanup, add test

* Fix test
This commit is contained in:
Erik Montnemery 2025-02-19 13:49:31 +01:00 committed by GitHub
parent 1733f5d3fb
commit af0a862aab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 77 additions and 29 deletions

View File

@ -1867,23 +1867,6 @@ async def snapshot_platform(
assert state == snapshot(name=f"{entity_entry.entity_id}-state") assert state == snapshot(name=f"{entity_entry.entity_id}-state")
def reset_translation_cache(hass: HomeAssistant, components: list[str]) -> None:
"""Reset translation cache for specified components.
Use this if you are mocking a core component (for example via
mock_integration), to ensure that the mocked translations are not
persisted in the shared session cache.
"""
translations_cache = translation._async_get_translations_cache(hass)
for loaded_components in translations_cache.cache_data.loaded.values():
for component_to_unload in components:
loaded_components.discard(component_to_unload)
for loaded_categories in translations_cache.cache_data.cache.values():
for loaded_components in loaded_categories.values():
for component_to_unload in components:
loaded_components.pop(component_to_unload, None)
@lru_cache @lru_cache
def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]: def get_quality_scale(integration: str) -> dict[str, QualityScaleStatus]:
"""Load quality scale for integration.""" """Load quality scale for integration."""

View File

@ -34,7 +34,6 @@ from tests.common import (
mock_integration, mock_integration,
mock_platform, mock_platform,
mock_restore_cache, mock_restore_cache,
reset_translation_cache,
) )
from tests.typing import ClientSessionGenerator, WebSocketGenerator from tests.typing import ClientSessionGenerator, WebSocketGenerator
@ -519,9 +518,6 @@ async def test_default_engine_prefer_cloud_entity(
assert provider_engine.name == "test" assert provider_engine.name == "test"
assert async_default_engine(hass) == "stt.cloud_stt_entity" assert async_default_engine(hass) == "stt.cloud_stt_entity"
# Reset the `cloud` translations cache to avoid flaky translation checks
reset_translation_cache(hass, ["cloud"])
async def test_get_engine_legacy( async def test_get_engine_legacy(
hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider hass: HomeAssistant, tmp_path: Path, mock_provider: MockSTTProvider

View File

@ -44,7 +44,6 @@ from tests.common import (
mock_integration, mock_integration,
mock_platform, mock_platform,
mock_restore_cache, mock_restore_cache,
reset_translation_cache,
) )
from tests.typing import ClientSessionGenerator, WebSocketGenerator from tests.typing import ClientSessionGenerator, WebSocketGenerator
@ -1987,6 +1986,3 @@ async def test_default_engine_prefer_cloud_entity(
provider_engine = tts.async_resolve_engine(hass, "test") provider_engine = tts.async_resolve_engine(hass, "test")
assert provider_engine == "test" assert provider_engine == "test"
assert tts.async_default_engine(hass) == "tts.cloud_tts_entity" assert tts.async_default_engine(hass) == "tts.cloud_tts_entity"
# Reset the `cloud` translations cache to avoid flaky translation checks
reset_translation_cache(hass, ["cloud"])

View File

@ -11,6 +11,7 @@ import gc
import itertools import itertools
import logging import logging
import os import os
import pathlib
import reprlib import reprlib
from shutil import rmtree from shutil import rmtree
import sqlite3 import sqlite3
@ -49,7 +50,7 @@ from . import patch_recorder
# Setup patching of dt_util time functions before any other Home Assistant imports # Setup patching of dt_util time functions before any other Home Assistant imports
from . import patch_time # noqa: F401, isort:skip from . import patch_time # noqa: F401, isort:skip
from homeassistant import core as ha, loader, runner from homeassistant import components, core as ha, loader, runner
from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
from homeassistant.auth.models import Credentials from homeassistant.auth.models import Credentials
from homeassistant.auth.providers import homeassistant from homeassistant.auth.providers import homeassistant
@ -85,6 +86,7 @@ from homeassistant.helpers import (
issue_registry as ir, issue_registry as ir,
label_registry as lr, label_registry as lr,
recorder as recorder_helper, recorder as recorder_helper,
translation as translation_helper,
) )
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.translation import _TranslationsCacheData from homeassistant.helpers.translation import _TranslationsCacheData
@ -1234,9 +1236,8 @@ def mock_get_source_ip() -> Generator[_patch]:
def translations_once() -> Generator[_patch]: def translations_once() -> Generator[_patch]:
"""Only load translations once per session. """Only load translations once per session.
Warning: having this as a session fixture can cause issues with tests that Note: To avoid issues with tests that mock integrations, translations for
create mock integrations, overriding the real integration translations mocked integrations are cleaned up by the evict_faked_translations fixture.
with empty ones. Translations should be reset after such tests (see #131628)
""" """
cache = _TranslationsCacheData({}, {}) cache = _TranslationsCacheData({}, {})
patcher = patch( patcher = patch(
@ -1250,6 +1251,30 @@ def translations_once() -> Generator[_patch]:
patcher.stop() patcher.stop()
@pytest.fixture(autouse=True, scope="module")
def evict_faked_translations(translations_once) -> Generator[_patch]:
"""Clear translations for mocked integrations from the cache after each module."""
real_component_strings = translation_helper._async_get_component_strings
with patch(
"homeassistant.helpers.translation._async_get_component_strings",
wraps=real_component_strings,
) as mock_component_strings:
yield
cache: _TranslationsCacheData = translations_once.kwargs["return_value"]
component_paths = components.__path__
for call in mock_component_strings.mock_calls:
integrations: dict[str, loader.Integration] = call.args[3]
for domain, integration in integrations.items():
if any(
pathlib.Path(f"{component_path}/{domain}") == integration.file_path
for component_path in component_paths
):
continue
for loaded_for_lang in cache.loaded.values():
loaded_for_lang.discard(domain)
@pytest.fixture @pytest.fixture
def disable_translations_once( def disable_translations_once(
translations_once: _patch, translations_once: _patch,

View File

@ -1,6 +1,8 @@
"""Test test fixture configuration.""" """Test test fixture configuration."""
from collections.abc import Generator
from http import HTTPStatus from http import HTTPStatus
import pathlib
import socket import socket
from aiohttp import web from aiohttp import web
@ -9,8 +11,11 @@ import pytest_socket
from homeassistant.components.http import HomeAssistantView from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, async_get_hass from homeassistant.core import HomeAssistant, async_get_hass
from homeassistant.helpers import translation
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from .common import MockModule, mock_integration
from .conftest import evict_faked_translations
from .typing import ClientSessionGenerator from .typing import ClientSessionGenerator
@ -70,3 +75,46 @@ async def test_aiohttp_client_frozen_router_view(
assert response.status == HTTPStatus.OK assert response.status == HTTPStatus.OK
result = await response.json() result = await response.json()
assert result["test"] is True assert result["test"] is True
async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None:
"""Test assumptions made when detecting translations for mocked integrations.
If this test fails, the evict_faked_translations may need to be updated.
"""
integration = mock_integration(hass, MockModule("test"), built_in=True)
assert integration.file_path == pathlib.Path("")
async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None:
"""Test the evict_faked_translations fixture."""
cache: translation._TranslationsCacheData = translations_once.kwargs["return_value"]
fake_domain = "test"
real_domain = "homeassistant"
# Evict the real domain from the cache in case it's been loaded before
cache.loaded["en"].discard(real_domain)
assert fake_domain not in cache.loaded["en"]
assert real_domain not in cache.loaded["en"]
# The evict_faked_translations fixture has module scope, so we set it up and
# tear it down manually
real_func = evict_faked_translations.__pytest_wrapped__.obj
gen: Generator = real_func(translations_once)
# Set up the evict_faked_translations fixture
next(gen)
mock_integration(hass, MockModule(fake_domain), built_in=True)
await translation.async_load_integrations(hass, {fake_domain, real_domain})
assert fake_domain in cache.loaded["en"]
assert real_domain in cache.loaded["en"]
# Tear down the evict_faked_translations fixture
with pytest.raises(StopIteration):
next(gen)
# The mock integration should be removed from the cache, the real domain should still be there
assert fake_domain not in cache.loaded["en"]
assert real_domain in cache.loaded["en"]