"""avg_area_m2 в топ-планировках — NULL вместо 0 м², когда сделок за окно нет (#2867). `_INLINE_VELOCITY_SQL` считал среднюю площадь как `COALESCE(SUM(area_weighted_sum) / NULLIF(SUM(deals_window), 0), 0)` — пустое окно продаж давало делитель NULL и COALESCE подставлял 0: «средняя площадь проданной квартиры — 0 м²» вместо «сделок не было, средней нет». Замер прода 13.08: 635 пустых пар (проект × комнатность) из 2083, у 80 проектов пусты все комнатности. Контракт исправлен насквозь: схема `float | None`, SQL без COALESCE (как у соседнего avg_price_per_m2_rub, #2464-B), сборка рядов не round'ит None, взвешенная площадь микса делится на сделки рядов С площадью, PDF печатает «—», фронт/типы — `number | null`. """ from __future__ import annotations import os os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") import inspect import re def test_velocity_sql_does_not_coalesce_area_to_zero() -> None: """Головной (SQL): в deals_per_bucket нет COALESCE(... area ..., 0). На origin/main он есть — с комментарием «остаётся намеренно… отдельным заходом: #2867». """ from app.services.site_finder import best_layouts as m sql = str(m._INLINE_VELOCITY_SQL) blk = sql[sql.index("deals_per_bucket AS (") : sql.index("AS avg_area_m2")] # Только код: SQL-комментарии (--) вырезаем — иначе гейт спорит с собственным # объяснением правки, где слово COALESCE упомянуто (наступал на это трижды). code = "\n".join(line for line in blk.splitlines() if not line.strip().startswith("--")) assert "COALESCE(" not in code, ( "средняя площадь по-прежнему COALESCE'ится в 0 при пустом окне:\n" + code[-300:] ) def test_schema_declares_avg_area_nullable() -> None: """Контракт API: TopLayoutRow.avg_area_m2 допускает None.""" from app.schemas.parcel import TopLayoutRow ann = TopLayoutRow.model_fields["avg_area_m2"].annotation assert ann is not float, f"avg_area_m2 объявлен как {ann!r} — None ронял бы контракт" TopLayoutRow.model_validate( { "room_bucket": "1", "area_bin": "25-40", "project_name": "x", "signature": "s", "competitor_obj_ids": [], "competitor_count": 0, "total_sold_in_window": 0, "velocity_per_month": 0.0, "avg_price_per_m2_rub": None, "avg_area_m2": None, "supply_units_in_radius": 0, "sold_pct_of_supply": None, "is_oversold": False, "rank": 1, } ) def test_row_assembly_keeps_none_not_zero() -> None: """Сборка рядов: NULL из БД остаётся None, а не превращается в 0.0 / round(None). На origin/main — `else 0.0`: пустое окно отдаёт 0 м². Проверяем по исходнику двух мест сборки, потому что собрать полный ответ без БД нельзя. """ from app.services.site_finder import best_layouts as m src = inspect.getsource(m) assert 'if r["avg_area_m2"] is not None else None' in src, "в сборке ряда None → 0.0" assert 'round(row["avg_area_m2"], 1) if row["avg_area_m2"] is not None else None' in src, ( "round(row['avg_area_m2']) без проверки на None" ) def test_mix_weighted_area_excludes_rows_without_area() -> None: """Контроль смысла: взвешенная площадь микса делится на сделки рядов С площадью. Иначе ряд без площади (None) считался бы сделками «с площадью 0» и занижал среднее. То же правило, что у цены в соседних строках. """ from app.services.site_finder import best_layouts as m src = inspect.getsource(m) assert "rb_area_total_deals" in src, "нет отдельного знаменателя для площади" assert re.search(r"rb_area_weighted\[rb\]\s*/\s*rb_area_total_deals\[rb\]", src), ( "площадь по-прежнему делится на все сделки (rb_deals), а не на ряды с площадью" ) def test_pdf_renders_dash_for_missing_area() -> None: """PDF: строка с avg_area_m2=None печатает «—», а не падает на :.1f. На origin/main `f"{r.avg_area_m2:.1f}"` на None бросает TypeError — это отсутствие возможности, а не неверное значение; поэтому рядом контроль: число печатается как раньше с одним знаком. """ from app.schemas.parcel import TopLayoutRow from app.services.exporters import layout_tz_pdf as pdf def row(area): return TopLayoutRow( room_bucket="2", area_bin="40-60", project_name="ЖК", signature="sig", competitor_obj_ids=[1], competitor_count=1, total_sold_in_window=3, velocity_per_month=1.5, avg_price_per_m2_rub=150000.0, avg_area_m2=area, supply_units_in_radius=10, sold_pct_of_supply=30.0, is_oversold=False, rank=1, ) assert row(None).avg_area_m2 is None and row(38.5).avg_area_m2 == 38.5 src = inspect.getsource(pdf) assert "_area_cell" in src, "нет ячейки площади с обработкой None" # прямая проверка самой ячейки через исходник функции — она вложенная в рендер assert 'return "—"' in src assert "{val:.1f}" in src, "число перестало печататься с одним знаком"