gendesign/backend/tests/test_2867_avg_area_nullable.py
bot-backend cdf493f345
All checks were successful
Deploy / changes (push) Successful in 9s
Deploy Trade-In / changes (push) Successful in 13s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy / build-backend (push) Successful in 2m23s
Deploy Trade-In / test (push) Successful in 3m56s
Deploy / build-worker (push) Successful in 4m16s
Deploy Trade-In / build-backend (push) Successful in 1m19s
Deploy / deploy (push) Successful in 1m49s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 12s
Deploy Trade-In / deploy (push) Successful in 2m25s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 11s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864) (#3022)
2026-08-21 12:01:52 +00:00

130 lines
6.4 KiB
Python
Raw Permalink 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.

"""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 "<td>—</td>"' in src
assert "<td>{val:.1f}</td>" in src, "число перестало печататься с одним знаком"