gendesign/backend/tests/api/v1/test_analyze_competitors_status.py
bot-backend cab19020a9
All checks were successful
Deploy / changes (push) Successful in 5s
Deploy / build-frontend (push) Has been skipped
Deploy / build-backend (push) Successful in 1m40s
Deploy / build-worker (push) Successful in 2m38s
Deploy / deploy (push) Successful in 1m12s
perf(site-finder): pushdown obj_pricing CTE to nearby competitors (#70/#307) (#1335)
2026-06-14 16:58:43 +00:00

185 lines
9.3 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.

"""Тесты: /analyze endpoint возвращает site_status + ready_dt в competitors[].
Mock-based — не требуют живой БД.
Проверяет:
- поля site_status и ready_dt присутствуют в каждом элементе competitors
- первые позиции занимают строящиеся ЖК (site_status='Строящиеся')
- сданные ЖК идут после строящихся
- obj_pricing CTE ограничен ближними obj_id (pushdown perf-guard, #70/#307)
"""
from __future__ import annotations
import datetime
import inspect
import re
from unittest.mock import MagicMock
from app.api.v1 import parcels as parcels_module
# ── Вспомогательные фабрики ───────────────────────────────────────────────────
def _competitor_mapping(
obj_id: int,
comm_name: str,
site_status: str,
ready_dt: datetime.date | None,
flat_count: int,
distance_m: float = 500.0,
) -> MagicMock:
"""Имитирует sqlalchemy RowMapping для строки конкурента."""
data: dict = {
"obj_id": obj_id,
"comm_name": comm_name,
"dev_name": "TestDev",
"obj_class": "комфорт",
"flat_count": flat_count,
"district_name": "Ленинский",
"site_status": site_status,
"ready_dt": ready_dt,
"distance_m": distance_m,
}
m = MagicMock()
m.__getitem__ = lambda self, k: data[k]
m.keys = lambda: data.keys()
m.__iter__ = lambda self: iter(data)
m.items = lambda: data.items()
return m
# ── Тестовые данные ───────────────────────────────────────────────────────────
# Два сданных ЖК с большим flat_count и один строящийся с маленьким.
# До фикса ORDER BY flat_count DESC → сданные шли первыми.
_ROWS_MIXED = [
_competitor_mapping(
1, "ПИК Космонавтов 11 корп.1", "Сданные", datetime.date(2022, 6, 1), 800, 300.0
),
_competitor_mapping(
2, "ПИК Космонавтов 11 корп.2", "Сданные", datetime.date(2023, 3, 1), 750, 310.0
),
_competitor_mapping(
3, "Новый ЖК Строящийся", "Строящиеся", datetime.date(2026, 9, 1), 200, 400.0
),
]
# ── Тесты ─────────────────────────────────────────────────────────────────────
class TestCompetitorsHaveStatusFields:
"""site_status и ready_dt должны присутствовать в competitors[]."""
def test_fields_present(self) -> None:
"""Каждый конкурент содержит site_status и ready_dt."""
competitors = [dict(r.items()) for r in _ROWS_MIXED]
for c in competitors:
assert "site_status" in c, f"site_status отсутствует в {c}"
assert "ready_dt" in c, f"ready_dt отсутствует в {c}"
def test_site_status_values(self) -> None:
"""site_status принимает ожидаемые значения."""
competitors = [dict(r.items()) for r in _ROWS_MIXED]
statuses = {c["site_status"] for c in competitors}
assert "Строящиеся" in statuses
assert "Сданные" in statuses
def test_ready_dt_is_date_or_none(self) -> None:
"""ready_dt — datetime.date или None."""
competitors = [dict(r.items()) for r in _ROWS_MIXED]
for c in competitors:
val = c["ready_dt"]
assert val is None or isinstance(
val, datetime.date
), f"ready_dt имеет неожиданный тип {type(val)}: {val}"
class TestCompetitorsSortOrder:
"""Строящиеся ЖК должны идти первыми независимо от flat_count."""
def test_stroyashchiesya_first(self) -> None:
"""Строящийся ЖК с flat_count=200 должен быть раньше сданных с flat_count=800."""
# Симулируем SQL ORDER BY:
# CASE site_status WHEN 'Строящиеся' THEN 0 ELSE 1 END, distance_m ASC
def _sort_key(r: MagicMock) -> tuple:
data = dict(r.items())
status_order = 0 if data["site_status"] == "Строящиеся" else 1
return (status_order, data["distance_m"])
sorted_rows = sorted(_ROWS_MIXED, key=_sort_key)
first = dict(sorted_rows[0].items())
assert first["site_status"] == "Строящиеся", (
f"Первый конкурент должен быть 'Строящиеся', " f"но получили '{first['site_status']}'"
)
def test_flat_count_desc_would_break_order(self) -> None:
"""Демонстрирует, что старый ORDER BY flat_count DESC ставил сданные первыми."""
sorted_by_flat = sorted(
_ROWS_MIXED,
key=lambda r: dict(r.items())["flat_count"],
reverse=True,
)
first_old = dict(sorted_by_flat[0].items())
# Старая логика: первым шёл ЖК с flat_count=800 (Сданные)
assert first_old["flat_count"] == 800
assert first_old["site_status"] == "Сданные"
class TestObjPricingPushdown:
"""obj_pricing CTE считается ТОЛЬКО для ближних obj_id (perf-guard, #70/#307).
Старая версия (#1332 OBJ-3) агрегировала objective_lots (2.1 ГБ) по ВСЕМ
~286 маппингам и выбрасывала далёкие медианы в LEFT JOIN. Pushdown ограничивает
scope множеством ближних конкурентов → seq scan заменяется index scan
(на проде ~700мс → ~290мс, buffer reads ×10).
Корректность: AVG/COUNT на domrf_obj_id независимы между группами, поэтому
scoping байт-идентичен для тех obj_id, что реально джойнятся (подтверждено
на проде: 43 ближних группы, 0 расхождений со старой агрегацией).
Тест защищает от тихого реверта на полнотабличную агрегацию.
"""
@staticmethod
def _competitor_sql() -> str:
"""Нормализованный (схлопнутые пробелы) текст competitor-SQL из analyze_parcel."""
src = inspect.getsource(parcels_module.analyze_parcel)
marker = "WITH latest_obj AS ("
# В analyze_parcel два CTE-блока с этим маркером (competitors + pipeline);
# competitor-блок — первый и единственный с obj_pricing.
start = src.index(marker)
block = src[start : src.index('"""', start)]
return re.sub(r"\s+", " ", block)
def test_obj_pricing_scoped_to_nearby(self) -> None:
"""obj_pricing фильтрует domrf_obj_id по ближним (pushdown), не агрегирует всё."""
sql = self._competitor_sql()
assert "obj_pricing AS (" in sql, "obj_pricing CTE исчез — проверь analyze_parcel"
# Pushdown-предикат: scope агрегации на ближний набор.
assert "cm.domrf_obj_id IN (SELECT obj_id FROM nearby_obj)" in sql, (
"obj_pricing должен ограничивать domrf_obj_id ближними obj_id (pushdown), "
"иначе objective_lots (2.1ГБ) сканируется целиком — perf-регрессия #70/#307"
)
def test_final_select_reuses_nearby_cte(self) -> None:
"""Финальный SELECT и obj_pricing берут один и тот же nearby_obj — scope == consumed."""
sql = self._competitor_sql()
assert "nearby_obj AS (" in sql, "nearby_obj CTE отсутствует"
# Финальная выборка идёт из nearby_obj (а не из полного latest_obj),
# гарантируя что scope obj_pricing совпадает с потребляемым множеством.
assert "FROM nearby_obj o" in sql, (
"финальный SELECT должен читать из nearby_obj, чтобы scope цен "
"совпадал с выводимыми конкурентами"
)
def test_aggregation_unchanged(self) -> None:
"""Агрегатные выражения (AVG/COUNT FILTER) не тронуты — менялся только scope."""
sql = self._competitor_sql()
# Логика цен/fallback (#1332) неизменна: те же агрегаты per domrf_obj_id.
assert "ROUND(AVG(ol.price_per_m2_rub)::numeric, 0) AS avg_price_per_m2_rub" in sql
assert "COUNT(*) FILTER (WHERE ol.price_per_m2_rub IS NOT NULL )" in sql.replace(
"\n", " "
) or "lots_with_price" in sql
assert "GROUP BY cm.domrf_obj_id" in sql