gendesign/backend/tests/services/test_forecast_request_cache.py
Light1YT 8206a0b067
All checks were successful
Deploy / changes (push) Successful in 6s
Deploy / build-backend (push) Successful in 1m49s
Deploy / build-frontend (push) Has been skipped
Deploy / build-worker (push) Successful in 2m45s
Deploy / deploy (push) Successful in 1m10s
perf(forecast): per-request memoization cache for §22 cold build (#1129)
Cold §22 forecast measured ~215-233s on prod: §9.x layers re-execute the same
horizon/segment-invariant DB loads with identical args hundreds of times per
report (profiled: get_competitors x69, market_metrics x124, get_monthly_macro
x290). Add a per-report ContextVar cache (forecast_cache(), opened once in the
orchestrator) + @cached(key_builder) on the expensive §9.x loaders so each
unique load runs ONCE and reuses the same frozen, read-only instance.

Output is byte-identical (memoized producers are frozen dataclasses / read-only
Pydantic, callers never mutate; cache is per-report, discarded on exit; no-op
outside the report build). No concurrency, no signature changes.

- forecast_request_cache.py: ContextVar cache + cached() decorator (no-op
  outside context, reentrant, _MISS sentinel for cached None)
- @cached on competitors/future_supply/market_metrics/macro_series/
  sales_series/macro_coefficient/demand_normalization/regression loaders
- orchestrator: wrap build_site_finder_report in forecast_cache()
- 58 tests: key discrimination (call-counting regression guard), no-op-outside,
  per-report isolation, reentrancy, frozen-producer canary, amplification proof
  (real get_monthly_macro xN->1)

code-reviewer APPROVE (keys correct, mutation-safe, output identical). 1265
forecast/cache tests green. No new deps. Refs #1129.
2026-06-08 05:26:27 +00:00

593 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 отвалилась"
)