"""Тесты мемоизации coord→zone_index в get_or_fetch_zone_regulation (#1850 Fix 1). Маппинг «координата → индекс терзоны» статичен → единственный живой WFS-вызов резолвера (``client.zone_index_at``) кэшируется процесс-локально. Проверяем: - повтор по тем же координатам → zone_index_at вызван ОДИН раз (memo hit), - негативное кэширование: координата без зоны (None) тоже кэшируется (вне ЕКБ не дёргаем WFS), - округление до 5 знаков: близкие координаты (<1e-5) → один ключ, - _reset_zone_index_memo() очищает кэш между тестами. Сеть/БД не дёргаются: фейковый клиент считает вызовы zone_index_at; кэш регламента (get_cached_zone_regulation) замокан так, чтобы зона считалась уже закэшированной (short-circuit до urbanCard). """ from __future__ import annotations from typing import Any import pytest from app.services.site_finder import zone_regulation as zr class _CountingClient: """Фейковый WFS-клиент: считает вызовы zone_index_at, отдаёт заданный индекс (или None).""" def __init__(self, zone_index: str | None) -> None: self._zone_index = zone_index self.index_calls: list[tuple[float, float]] = [] self.regulation_calls: int = 0 def zone_index_at(self, lon: float, lat: float) -> str | None: self.index_calls.append((lon, lat)) return self._zone_index def zone_regulation_at(self, lon: float, lat: float) -> Any: self.regulation_calls += 1 return None @pytest.fixture(autouse=True) def _reset_memo() -> Any: """Чистый memo до и после каждого теста (процесс-локальный кэш течёт между тестами).""" zr._reset_zone_index_memo() yield zr._reset_zone_index_memo() def test_zone_index_memoized_across_calls(monkeypatch: Any) -> None: """Два вызова с теми же координатами → zone_index_at вызван РОВНО один раз (memo hit).""" # Зона "Ц-1" уже в кэше регламента → short-circuit до urbanCard, изолируем memo. monkeypatch.setattr( zr, "get_cached_zone_regulation", lambda db, zone_index, **kw: {"zone_index": zone_index}, ) client = _CountingClient("Ц-1") r1 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client) r2 = zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client) assert r1 == {"zone_index": "Ц-1"} assert r2 == {"zone_index": "Ц-1"} assert len(client.index_calls) == 1 # второй вызов — memo hit, без WFS def test_negative_result_is_memoized(monkeypatch: Any) -> None: """Координата без терзоны (None) кэшируется → второй вызов не дёргает WFS (вне ЕКБ).""" # get_cached не должен влиять: zone_index=None → ветка кэша не выполняется, # zone_regulation_at вернёт None → результат None. monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None) client = _CountingClient(None) r1 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client) r2 = zr.get_or_fetch_zone_regulation(object(), 1.0, 1.0, client=client) assert r1 is None assert r2 is None assert len(client.index_calls) == 1 # негативный результат закэширован def test_nearby_coords_share_memo_key(monkeypatch: Any) -> None: """Координаты, совпадающие после round(,5), делят ключ → один WFS-вызов.""" monkeypatch.setattr( zr, "get_cached_zone_regulation", lambda db, zone_index, **kw: {"zone_index": zone_index}, ) client = _CountingClient("Ж-2") # Различие в 6-м знаке → одинаковый ключ после round(lon, 5)/round(lat, 5). zr.get_or_fetch_zone_regulation(object(), 60.600001, 56.800002, client=client) zr.get_or_fetch_zone_regulation(object(), 60.600002, 56.800001, client=client) assert len(client.index_calls) == 1 def test_distinct_coords_each_hit_wfs(monkeypatch: Any) -> None: """Координаты, различимые на уровне 5 знаков, бьют WFS отдельно (round не слишком грубый).""" monkeypatch.setattr( zr, "get_cached_zone_regulation", lambda db, zone_index, **kw: {"zone_index": zone_index}, ) client = _CountingClient("Ц-2") zr.get_or_fetch_zone_regulation(object(), 60.60000, 56.80000, client=client) zr.get_or_fetch_zone_regulation(object(), 60.60002, 56.80000, client=client) # ~1.2 м восточнее assert len(client.index_calls) == 2 def test_reset_memo_clears_cache(monkeypatch: Any) -> None: """_reset_zone_index_memo() сбрасывает кэш → следующий вызов снова бьёт WFS.""" monkeypatch.setattr( zr, "get_cached_zone_regulation", lambda db, zone_index, **kw: {"zone_index": zone_index}, ) client = _CountingClient("Ц-1") zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client) assert len(client.index_calls) == 1 zr._reset_zone_index_memo() zr.get_or_fetch_zone_regulation(object(), 60.6, 56.8, client=client) assert len(client.index_calls) == 2 # после reset — снова WFS def test_memo_evicts_oldest_over_maxsize(monkeypatch: Any) -> None: """LRU-эвикция: при превышении maxsize выбрасывается самый старый ключ.""" monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 3) monkeypatch.setattr(zr, "get_cached_zone_regulation", lambda *a, **kw: None) client = _CountingClient(None) # 4 различных ключа при maxsize=3 → первый (самый старый) эвиктится. for i in range(4): zr.get_or_fetch_zone_regulation(object(), 60.0 + i * 0.001, 56.0, client=client) assert len(client.index_calls) == 4 # Повтор самого старого (60.000) → cache miss (эвиктнут) → ещё один WFS-вызов. zr.get_or_fetch_zone_regulation(object(), 60.0, 56.0, client=client) assert len(client.index_calls) == 5 def test_memo_concurrent_access_no_error(monkeypatch: Any) -> None: """#1850 thread-safety: N потоков сквозь _memoized_zone_index при tiny maxsize → мутации под Lock'ом не дают KeyError/гонок (memo делится между threadpool-потоками).""" import threading monkeypatch.setattr(zr, "_ZONE_INDEX_MEMO_MAXSIZE", 2) # форсим частую эвикцию client = _CountingClient("Ц-1") errors: list[BaseException] = [] barrier = threading.Barrier(8) def worker(n: int) -> None: try: barrier.wait() # стартуем одновременно → максимум контеншена for i in range(200): zr._memoized_zone_index(client, 60.0 + (i % 5) * 0.001, 56.0) except BaseException as exc: # фиксируем любую гонку (KeyError/RuntimeError) errors.append(exc) threads = [threading.Thread(target=worker, args=(n,)) for n in range(8)] for t in threads: t.start() for t in threads: t.join() assert not errors, f"concurrent memo access raised: {errors[:3]}"