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
Почему: файлы в 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
130 lines
6.4 KiB
Python
130 lines
6.4 KiB
Python
"""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, "число перестало печататься с одним знаком"
|