gendesign/backend/tests/test_2867_avg_area_nullable.py
bot-backend 14818f0303
All checks were successful
CI Trade-In / changes (pull_request) Successful in 15s
CI / changes (pull_request) Successful in 15s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m56s
CI Trade-In / backend-tests (pull_request) Successful in 5m25s
CI / backend-tests (pull_request) Successful in 18m18s
chore(format): нормализация под ruff 0.15.20 — 161 файл, только формат (#2864)
Почему: файлы в main были отформатированы старым ruff (хук 0.7.4), а после
#3021 хук, CI и `uv run ruff format` — на 0.15.20. Без нормализации каждый
коммит, касающийся одного из этих 161 файлов, тащил бы посторонние хунки
на нетронутых строках (длинные assert-сообщения и т.п.).

Что: `ruff format` 0.15.20 по backend/ и tradein-mvp/backend/; ничего,
кроме форматирования (958+/1034−). `ruff check` 0.15.20 зелёный на обоих
проектах, повторный `format --check` — 1001 файл без изменений
(идемпотентно), compileall чистый.

Refs #2864
2026-08-21 16:39:28 +05:00

130 lines
6.4 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.

"""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, "число перестало печататься с одним знаком"