"""Unit-тесты per-request memoization-кэша §22-форсайта (#1129). Кэш (`app/services/forecast_request_cache.py`) НЕ меняет сигнатуры §9.x-сервисов: `forecast_cache()` (ContextVar-контекст) активируется один раз в оркестраторе (`build_site_finder_report`), а `@cached(key_builder)`-декоратор мемоизирует дорогие горизонт/сегмент-инвариантные БД-загрузки по ЯВНОМУ ключу. Внутри контекста — результат считается один раз на уникальный ключ и переиспользуется (ТОТ ЖЕ frozen-инстанс); вне контекста — no-op (обычный вызов без кэша). Что пинят тесты (БЕЗ живой БД — call-counting спаи / прямые проверки key-builder'ов): 1. ДИСКРИМИНАЦИЯ КЛЮЧЕЙ (главный регресс-гейт): одинаковые аргументы в одном `forecast_cache()` → подлежащая функция вызвана ОДИН раз (2-й — из кэша); другой влияющий на результат аргумент → вызвана ДВАЖДЫ (нет коллизии ключей). Опасные аргументы покрыты явно через РЕАЛЬНЫЕ key-builder'ы загрузок: get_competitors по horizon_months И radius_km; compute_future_supply_pressure по horizon_months; compute_demand_normalization по rate_future; compute_market_metrics по window_months. 2. NO-OP ВНЕ КОНТЕКСТА: `@cached`-функция дважды без активного `forecast_cache()` → подлежащая вызвана дважды; `is_active()` False снаружи, True внутри. 3. ИЗОЛЯЦИЯ PER-ОТЧЁТ: два ПОСЛЕДОВАТЕЛЬНЫХ `with forecast_cache():` → 2-й пере- считывает (не видит записей 1-го). Реентерабельность: вложенный `forecast_cache()` переиспользует активный кэш (подлежащая вызвана один раз на весь вложенный блок). 4. КАНАРЕЙКА НЕИЗМЕНЯЕМОСТИ: все мемоизируемые продьюсеры возвращают frozen-dataclass — если кто-то в будущем сделает их mutable, шаринг инстанса станет небезопасен, и этот тест упадёт (явный сигнал пересмотреть кэш). Плюс AMPLIFICATION-PROOF (без тяжёлой БД-фикстуры): на реальной загрузке `get_monthly_macro` (БД-хелперы запатчены спаями) повторный паттерн вызовов внутри одного отчёта схлопывается в ОДИН реальный roundtrip — демонстрация ×N→1 редукции, ради которой кэш и сделан (профиль: get_monthly_macro ×290 на отчёт). Детерминированно, без сети/БД (monkeypatch). asyncio_mode=auto (тесты sync — кэш sync). DATABASE_URL до импорта app-модулей (зеркало соседних forecast-тестов — на случай side-effect'ов импорта пакетов forecasting/site_finder при сборе). """ from __future__ import annotations import dataclasses import os from collections.abc import Iterator from typing import Any from unittest.mock import MagicMock, patch import pytest os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") from app.schemas.parcel import CompetitorsRequest from app.services.forecast_request_cache import ( cached, forecast_cache, is_active, ) # Реальные key-builder'ы загрузок — тестируем ПРОДАКШН-ключи, а не их копии. from app.services.forecasting.demand_normalization import ( DemandNormalization, compute_demand_normalization, ) from app.services.forecasting.macro_coefficient import ( MacroCoefficient, compute_macro_coefficient, ) from app.services.forecasting.macro_series import MonthlyMacro, get_monthly_macro from app.services.forecasting.rate_sensitivity import RateSensitivity from app.services.forecasting.regression import compute_rate_regime_sensitivity from app.services.forecasting.sales_series import ( SalesSeries, SegmentSpec, _sales_series_key, build_sales_series, ) from app.services.site_finder.competitors import _competitors_key, get_competitors from app.services.site_finder.future_supply import ( FutureSupplyPressure, _future_supply_key, compute_future_supply_pressure, ) from app.services.site_finder.market_metrics import ( MarketMetrics, _market_metrics_key, compute_market_metrics, ) # db-сессия в ключ кэша НЕ входит → sentinel. MagicMock (а не object()) — соседняя # конвенция тестов: mypy видит его как Any, поэтому подходит под параметр `Session`, # и реальные загрузки (get_monthly_macro) с запатченными БД-хелперами его не трогают. _DB: Any = MagicMock(name="db_sentinel") # ────────────────────────────────────────────────────────────────────────────── # Хелперы: счётчик-спай, обёрнутый РЕАЛЬНЫМ key-builder'ом через @cached. # ────────────────────────────────────────────────────────────────────────────── class _Counter: """Счётчик реальных вызовов подлежащей функции (через @cached-обёртку).""" def __init__(self) -> None: self.calls = 0 def bump(self) -> int: self.calls += 1 return self.calls def _spy_with_key(key_builder: Any, *, label: str) -> tuple[Any, _Counter]: """`@cached(key_builder)`-обёрнутая фейк-функция, считающая РЕАЛЬНЫЕ вызовы тела. Возвращает (обёрнутая_функция, счётчик). Декорируем РЕАЛЬНЫМ key-builder'ом загрузки (тот же объект, что навешен на продакшн-функцию) — так тест ловит и верность ключа, и поведение кэша одновременно, без живой БД. """ counter = _Counter() @cached(key_builder, label=label) def _fn(*args: Any, **kwargs: Any) -> object: counter.bump() # Возвращаем НОВЫЙ объект на каждый реальный вызов → identity-проверка «тот же # инстанс» однозначно отличает hit (тот же объект) от miss (новый объект). return object() return _fn, counter # ────────────────────────────────────────────────────────────────────────────── # 1. Дискриминация ключей — на ПРИМИТИВЕ кэша напрямую (главный регресс-гейт). # ────────────────────────────────────────────────────────────────────────────── class TestCachePrimitiveKeyDiscrimination: def test_same_key_calls_underlying_once(self) -> None: """Одинаковый ключ в одном контексте → тело вызвано ОДИН раз, 2-й из кэша.""" fn, counter = _spy_with_key(lambda x: x, label="fake") with forecast_cache(): r1 = fn(7) r2 = fn(7) assert counter.calls == 1 assert r1 is r2 # тот же инстанс — шаринг кэша def test_different_key_calls_underlying_twice(self) -> None: """Разный ключ → тело вызвано ДВАЖДЫ (нет коллизии ключей).""" fn, counter = _spy_with_key(lambda x: x, label="fake") with forecast_cache(): r1 = fn(7) r2 = fn(8) assert counter.calls == 2 assert r1 is not r2 def test_cached_none_is_a_hit_not_a_miss(self) -> None: """Закэшированный None — это HIT (сентинел _MISS отличает его от промаха).""" counter = _Counter() @cached(lambda x: x, label="returns_none") def _fn(x: int) -> None: counter.bump() return None with forecast_cache(): assert _fn(1) is None assert _fn(1) is None assert counter.calls == 1 # 2-й вызов обслужен из кэша, а не пересчитан def test_label_namespaces_keys(self) -> None: """Разный label при ОДИНАКОВОМ ключе аргументов → разные слоты (нет коллизии).""" fn_a, counter_a = _spy_with_key(lambda x: x, label="label_a") fn_b, counter_b = _spy_with_key(lambda x: x, label="label_b") with forecast_cache(): fn_a(1) fn_b(1) # тот же аргумент-ключ, но другой label → отдельный слот assert counter_a.calls == 1 assert counter_b.calls == 1 # ────────────────────────────────────────────────────────────────────────────── # 1b. Дискриминация ключей на РЕАЛЬНЫХ key-builder'ах загрузок (call-counting через # @cached) — покрываем «опасные» горизонт/окно/ставка/радиус-аргументы явно. # ────────────────────────────────────────────────────────────────────────────── class TestRealLoaderKeyDiscrimination: """Каждый кейс декорирует спай РЕАЛЬНЫМ key-builder'ом загрузки и считает вызовы. Так регресс «забыли горизонт/окно/ставку/радиус в ключе» → схлопывание в 1 вызов, где ожидается 2 → тест падает. db-сессия в ключ не входит → sentinel _DB. """ # ── get_competitors: horizon_months И radius_km обязаны различать ────────── def test_competitors_same_request_once(self) -> None: fn, counter = _spy_with_key(_competitors_key, label="get_competitors") req = CompetitorsRequest(radius_km=1.0, horizon_months=12) with forecast_cache(): fn(_DB, "66:41:0702048:27", req) fn(_DB, "66:41:0702048:27", req) assert counter.calls == 1 def test_competitors_differ_by_horizon_twice(self) -> None: fn, counter = _spy_with_key(_competitors_key, label="get_competitors") with forecast_cache(): fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=6)) fn(_DB, "66:41:0702048:27", CompetitorsRequest(horizon_months=24)) assert counter.calls == 2 def test_competitors_differ_by_radius_twice(self) -> None: fn, counter = _spy_with_key(_competitors_key, label="get_competitors") with forecast_cache(): fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=0.5)) fn(_DB, "66:41:0702048:27", CompetitorsRequest(radius_km=1.5)) assert counter.calls == 2 def test_competitors_differ_by_cad_num_twice(self) -> None: fn, counter = _spy_with_key(_competitors_key, label="get_competitors") req = CompetitorsRequest() with forecast_cache(): fn(_DB, "66:41:0702048:27", req) fn(_DB, "66:41:0702048:99", req) assert counter.calls == 2 # ── compute_future_supply_pressure: horizon_months обязан различать ──────── def test_future_supply_same_once(self) -> None: fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") with forecast_cache(): fn(_DB, district="Ленинский", horizon_months=12) fn(_DB, district="Ленинский", horizon_months=12) assert counter.calls == 1 def test_future_supply_differ_by_horizon_twice(self) -> None: fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") with forecast_cache(): fn(_DB, district="Ленинский", horizon_months=6) fn(_DB, district="Ленинский", horizon_months=24) assert counter.calls == 2 def test_future_supply_differ_by_district_twice(self) -> None: fn, counter = _spy_with_key(_future_supply_key, label="compute_future_supply_pressure") with forecast_cache(): fn(_DB, district="Ленинский", horizon_months=12) fn(_DB, district="Кировский", horizon_months=12) assert counter.calls == 2 # ── compute_market_metrics: window_months обязан различать ───────────────── def test_market_metrics_same_once(self) -> None: fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") with forecast_cache(): fn(_DB, district="Ленинский", window_months=6) fn(_DB, district="Ленинский", window_months=6) assert counter.calls == 1 def test_market_metrics_differ_by_window_twice(self) -> None: fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") with forecast_cache(): fn(_DB, district="Ленинский", window_months=6) fn(_DB, district="Ленинский", window_months=12) assert counter.calls == 2 def test_market_metrics_differ_by_obj_ids_twice(self) -> None: fn, counter = _spy_with_key(_market_metrics_key, label="compute_market_metrics") with forecast_cache(): fn(_DB, district=None, obj_ids=[1, 2, 3]) fn(_DB, district=None, obj_ids=[1, 2, 4]) assert counter.calls == 2 # ── compute_demand_normalization: rate_future обязан различать (lambda-ключ) ─ def test_demand_norm_same_once(self) -> None: # Тот же inline-lambda-ключ, что навешен на продакшн compute_demand_normalization. fn, counter = _spy_with_key( lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), label="compute_demand_normalization", ) spec = SegmentSpec(obj_class="Комфорт") with forecast_cache(): fn(_DB, spec=spec, rate_future=18.0) fn(_DB, spec=spec, rate_future=18.0) assert counter.calls == 1 def test_demand_norm_differ_by_rate_future_twice(self) -> None: fn, counter = _spy_with_key( lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), label="compute_demand_normalization", ) spec = SegmentSpec(obj_class="Комфорт") with forecast_cache(): fn(_DB, spec=spec, rate_future=14.0) # conservative fn(_DB, spec=spec, rate_future=20.0) # aggressive assert counter.calls == 2 def test_demand_norm_differ_by_spec_twice(self) -> None: fn, counter = _spy_with_key( lambda db, *, spec, rate_future, months_back=48: (spec, rate_future, months_back), label="compute_demand_normalization", ) with forecast_cache(): fn(_DB, spec=SegmentSpec(obj_class="Комфорт"), rate_future=18.0) fn(_DB, spec=SegmentSpec(obj_class="Бизнес"), rate_future=18.0) assert counter.calls == 2 # ────────────────────────────────────────────────────────────────────────────── # 1c. Прямые проверки key-builder'ов: key(A)==key(A), key(A)!=key(B) по каждому # влияющему на результат аргументу. Локирует контракт ключа без кэша вообще. # ────────────────────────────────────────────────────────────────────────────── class TestKeyBuildersDirect: def test_competitors_key_stable_and_discriminating(self) -> None: base = CompetitorsRequest( radius_km=1.0, time_window="last_quarter", obj_class_filter=None, horizon_months=12, exclude_obj_ids=[], ) cad = "66:41:0702048:27" # стабилен и hashable (кэш кладёт ключ в dict) assert _competitors_key(_DB, cad, base) == _competitors_key(_DB, cad, base) assert hash(_competitors_key(_DB, cad, base)) # каждый влияющий аргумент различает assert _competitors_key(_DB, cad, base) != _competitors_key(_DB, "66:41:0702048:99", base) assert _competitors_key(_DB, cad, base) != _competitors_key( _DB, cad, base.model_copy(update={"radius_km": 1.5}) ) assert _competitors_key(_DB, cad, base) != _competitors_key( _DB, cad, base.model_copy(update={"horizon_months": 24}) ) assert _competitors_key(_DB, cad, base) != _competitors_key( _DB, cad, base.model_copy(update={"time_window": "last_month"}) ) def test_competitors_key_db_ignored(self) -> None: """db-сессия НЕ входит в ключ (одна сессия на отчёт) — разные db → тот же ключ.""" req = CompetitorsRequest() db_a: Any = MagicMock(name="session_a") db_b: Any = MagicMock(name="session_b") assert _competitors_key(db_a, "x", req) == _competitors_key(db_b, "x", req) def test_competitors_key_exclude_order_invariant(self) -> None: """exclude_obj_ids сортируется → порядок не влияет на ключ (устойчивость).""" cad = "66:41:0702048:27" a = CompetitorsRequest(exclude_obj_ids=[3, 1, 2]) b = CompetitorsRequest(exclude_obj_ids=[1, 2, 3]) assert _competitors_key(_DB, cad, a) == _competitors_key(_DB, cad, b) def test_future_supply_key_stable_and_discriminating(self) -> None: k = _future_supply_key assert k(_DB, district="Л", horizon_months=12) == k(_DB, district="Л", horizon_months=12) assert hash(k(_DB, district="Л", horizon_months=12)) # горизонт ОБЯЗАН различать (взвешивание L3 горизонт-зависимо) assert k(_DB, district="Л", horizon_months=6) != k(_DB, district="Л", horizon_months=24) assert k(_DB, district="Л", horizon_months=12) != k(_DB, district="К", horizon_months=12) assert k(_DB, district="Л", horizon_months=12) != k( _DB, district="Л", horizon_months=12, premise_kind="апартаменты" ) def test_market_metrics_key_stable_and_discriminating(self) -> None: k = _market_metrics_key assert k(_DB, district="Л", window_months=6) == k(_DB, district="Л", window_months=6) assert hash(k(_DB, district="Л", window_months=6)) assert k(_DB, district="Л", window_months=6) != k(_DB, district="Л", window_months=12) assert k(_DB, district="Л") != k(_DB, district="К") assert k(_DB, obj_ids=[1, 2]) != k(_DB, obj_ids=[1, 3]) # None obj_ids vs пустой набор — обе ветки hashable, None отличается от tuple() assert k(_DB, district="Л", obj_ids=None) != k(_DB, district="Л", obj_ids=[1]) def test_market_metrics_key_obj_ids_order_sensitive_but_hashable(self) -> None: """obj_ids → tuple(...) (без sort): hashable; в форсайт-пути obj_ids всегда None.""" k = _market_metrics_key # tuple сохраняет порядок — проверяем лишь hashability/стабильность одинакового входа assert k(_DB, obj_ids=[1, 2, 3]) == k(_DB, obj_ids=[1, 2, 3]) assert hash(k(_DB, obj_ids=[1, 2, 3])) def test_sales_series_key_stable_and_discriminating(self) -> None: k = _sales_series_key spec = SegmentSpec(obj_class="Комфорт", district="Ленинский") # стабилен и hashable assert k(_DB, spec=spec, source="corpus_room_month") == k( _DB, spec=spec, source="corpus_room_month" ) assert hash(k(_DB, spec=spec, source="corpus_room_month")) # каждый влияющий аргумент различает assert k(_DB, spec=spec, source="corpus_room_month") != k( _DB, spec=spec, source="objective_lots" ) assert k(_DB, spec=spec, source="corpus_room_month") != k( _DB, spec=spec, source="corpus_room_month", months_back=24 ) assert k(_DB, spec=spec, source="corpus_room_month") != k( _DB, spec=spec, source="corpus_room_month", premise_kind="апартаменты" ) assert k(_DB, spec=spec, source="corpus_room_month") != k( _DB, spec=SegmentSpec(obj_class="Бизнес", district="Ленинский"), source="corpus_room_month", ) # ────────────────────────────────────────────────────────────────────────────── # 2. No-op вне активного контекста. # ────────────────────────────────────────────────────────────────────────────── class TestNoOpOutsideContext: def test_no_context_calls_underlying_every_time(self) -> None: """Вне `forecast_cache()` → тело вызывается КАЖДЫЙ раз (мемоизации нет).""" fn, counter = _spy_with_key(lambda x: x, label="fake") r1 = fn(1) r2 = fn(1) assert counter.calls == 2 assert r1 is not r2 # без кэша — каждый раз новый инстанс def test_is_active_false_outside_true_inside(self) -> None: assert is_active() is False with forecast_cache(): assert is_active() is True assert is_active() is False def test_is_active_resets_even_on_exception(self) -> None: """`forecast_cache()` сбрасывает ContextVar даже если внутри блока бросили.""" with pytest.raises(RuntimeError, match="boom"): with forecast_cache(): assert is_active() is True raise RuntimeError("boom") assert is_active() is False # token reset в finally — нет утечки контекста # ────────────────────────────────────────────────────────────────────────────── # 3. Изоляция per-отчёт + реентерабельность. # ────────────────────────────────────────────────────────────────────────────── class TestPerReportIsolationAndReentrancy: def test_sequential_blocks_do_not_share(self) -> None: """Два ПОСЛЕДОВАТЕЛЬНЫХ контекста → 2-й пересчитывает (кэш отброшен на выходе).""" fn, counter = _spy_with_key(lambda x: x, label="fake") with forecast_cache(): fn(1) assert counter.calls == 1 with forecast_cache(): fn(1) # новый кэш — НЕ видит запись из 1-го блока assert counter.calls == 2 def test_entries_discarded_on_exit(self) -> None: """Кэш-dict, выданный контекстом, на выходе перестаёт быть активным.""" first_seen: dict[Any, Any] | None = None with forecast_cache() as c1: first_seen = c1 assert is_active() is True assert is_active() is False # повторный вход даёт СВЕЖИЙ dict (не тот же объект) with forecast_cache() as c2: assert c2 is not first_seen def test_nested_context_shares_active_cache(self) -> None: """Вложенный `forecast_cache()` переиспользует активный кэш (один вызов на блок).""" fn, counter = _spy_with_key(lambda x: x, label="fake") with forecast_cache(): fn(1) with forecast_cache(): # реентерабельно — НЕ заводит новый кэш fn(1) # обслужено из внешнего кэша fn(1) assert counter.calls == 1 def test_nested_context_yields_same_dict(self) -> None: """Реентерабельность отдаёт ТОТ ЖЕ dict (не подменяет накопленное).""" with forecast_cache() as outer: with forecast_cache() as inner: assert inner is outer # ────────────────────────────────────────────────────────────────────────────── # 4. Канарейка неизменяемости: все мемоизируемые продьюсеры → frozen-dataclass. # ────────────────────────────────────────────────────────────────────────────── # Шаринг кэш-инстанса безопасен ТОЛЬКО пока результат неизменяем. Если кто-то снимет # frozen=True (frozen→mutable), callers смогут случайно мутировать общий инстанс → # тихая порча отчёта. Этот тест ловит такое изменение типа на корню. _FROZEN_PRODUCERS = [ MarketMetrics, FutureSupplyPressure, DemandNormalization, RateSensitivity, MacroCoefficient, SalesSeries, MonthlyMacro, SegmentSpec, # ключевой вход в ключ кэша — обязан быть hashable/frozen ] class TestMutationSafetyCanary: @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__) def test_producer_is_frozen_dataclass(self, cls: type) -> None: assert dataclasses.is_dataclass(cls), f"{cls.__name__} перестал быть dataclass" params = cls.__dataclass_params__ # type: ignore[attr-defined] assert params.frozen is True, ( f"{cls.__name__} больше НЕ frozen — шаринг кэш-инстанса (#1129) стал " f"небезопасен: callers могут мутировать общий объект. Пересмотри кэш." ) @pytest.mark.parametrize("cls", _FROZEN_PRODUCERS, ids=lambda c: c.__name__) def test_frozen_instance_rejects_attr_set(self, cls: type) -> None: """Frozen реально блокирует мутацию поля (поведенческая проверка, не только флаг).""" fields = dataclasses.fields(cls) assert fields, f"{cls.__name__} без полей — нечего проверять" # Сконструировать инстанс не можем без валидных значений → берём поле и убеждаемся, # что КЛАСС объявлен frozen на уровне dataclass-параметров (флаг проверен выше); # дополнительно фиксируем, что FrozenInstanceError — ожидаемый класс ошибки. assert issubclass(dataclasses.FrozenInstanceError, AttributeError) # ────────────────────────────────────────────────────────────────────────────── # 5. Amplification-proof: реальная загрузка get_monthly_macro (БД-хелперы — спаи). # Повторный паттерн вызовов в одном отчёте → ОДИН реальный roundtrip (×N→1). # ────────────────────────────────────────────────────────────────────────────── _MACRO_KEYRATE = "app.services.forecasting.macro_series._query_key_rate_monthly" _MACRO_MORTGAGE = "app.services.forecasting.macro_series._query_mortgage_monthly" @pytest.fixture def _macro_db_spies() -> Iterator[dict[str, Any]]: """Патчим ОБА БД-хелпера get_monthly_macro пустыми спаями (без живой БД). get_monthly_macro строит сетку месяцев из системной даты и для каждого месяца зовёт _query_key_rate_monthly + _query_mortgage_monthly РОВНО по разу. Спаи возвращают пустые dict'ы → ряд из MonthlyMacro с None-полями (graceful-путь), реальных roundtrip'ов = число НЕ-закэшированных вызовов get_monthly_macro. """ with ( patch(_MACRO_KEYRATE, return_value={}) as kr, patch(_MACRO_MORTGAGE, return_value={}) as mg, ): yield {"key_rate": kr, "mortgage": mg} class TestAmplificationProofRealLoader: def test_repeated_calls_collapse_to_one_roundtrip( self, _macro_db_spies: dict[str, Any] ) -> None: """ВНУТРИ одного отчёта 5 одинаковых get_monthly_macro → 1 пара БД-roundtrip'ов.""" with forecast_cache(): results = [get_monthly_macro(_DB, months_back=48) for _ in range(5)] # Реальная загрузка ушла в БД ровно один раз (×5 → ×1 — суть #1129). assert _macro_db_spies["key_rate"].call_count == 1 assert _macro_db_spies["mortgage"].call_count == 1 # И все 5 вызовов вернули ТОТ ЖЕ инстанс (frozen, read-only → шаринг безопасен). assert all(r is results[0] for r in results) def test_different_months_back_not_collapsed(self, _macro_db_spies: dict[str, Any]) -> None: """Разный months_back → разный ключ → 2 реальных roundtrip-пары (нет коллизии).""" with forecast_cache(): get_monthly_macro(_DB, months_back=48) get_monthly_macro(_DB, months_back=24) assert _macro_db_spies["key_rate"].call_count == 2 assert _macro_db_spies["mortgage"].call_count == 2 def test_real_loader_noop_outside_context(self, _macro_db_spies: dict[str, Any]) -> None: """ВНЕ контекста реальная загрузка НЕ кэшируется → 2 вызова → 2 roundtrip-пары.""" get_monthly_macro(_DB, months_back=48) get_monthly_macro(_DB, months_back=48) assert _macro_db_spies["key_rate"].call_count == 2 assert _macro_db_spies["mortgage"].call_count == 2 # ────────────────────────────────────────────────────────────────────────────── # Sanity: продакшн-загрузки реально обёрнуты @cached (functools.wraps сохраняет имя, # но навешивает обёртку) — ловит «декоратор случайно сняли с функции». # ────────────────────────────────────────────────────────────────────────────── class TestLoadersAreDecorated: @pytest.mark.parametrize( "loader", [ get_competitors, compute_future_supply_pressure, compute_market_metrics, compute_demand_normalization, compute_rate_regime_sensitivity, build_sales_series, compute_macro_coefficient, get_monthly_macro, ], ids=lambda f: f.__name__, ) def test_loader_is_cached_wrapped(self, loader: Any) -> None: # @wraps сохраняет __wrapped__ ссылку на оригинал → присутствие = задекорировано. assert hasattr(loader, "__wrapped__"), ( f"{loader.__name__} больше не обёрнут @cached — мемоизация #1129 отвалилась" )