130 lines
5.3 KiB
Python
130 lines
5.3 KiB
Python
"""Tests для build_layout_tz_html — §4.2 «По предложению (без темпа продаж)».
|
||
|
||
Проверяют HTML-разметку ТЗ (наличие блоков, RU-микрокопию, форматирование ₽/м²)
|
||
БЕЗ WeasyPrint: `build_layout_tz_html` — PURE-функция (строит строку HTML), поэтому
|
||
эти тесты гоняются и на macOS/Windows, где native GTK/Pango отсутствуют (в отличие
|
||
от test_layout_tz_pdf.py, который зависит от write_pdf → native libs).
|
||
|
||
Ключевой сценарий: §4.2 блок рендерится, когда `supply_only_layouts` есть, и
|
||
graceful ОТСУТСТВУЕТ (без ошибок), когда список пуст (старые снапшоты / нет матча).
|
||
"""
|
||
|
||
import datetime as dt
|
||
|
||
from app.schemas.parcel import (
|
||
BestLayoutsResponse,
|
||
LayoutDataQuality,
|
||
LayoutTzMixRow,
|
||
LayoutTzRecommendation,
|
||
SupplyOnlyLayoutRow,
|
||
TopLayoutRow,
|
||
)
|
||
from app.services.exporters.layout_tz_pdf import build_layout_tz_html
|
||
|
||
|
||
def _base_response(supply_only: list[SupplyOnlyLayoutRow] | None = None) -> BestLayoutsResponse:
|
||
return BestLayoutsResponse(
|
||
top_layouts=[
|
||
TopLayoutRow(
|
||
rank=1,
|
||
room_bucket="1",
|
||
area_bin="25-40",
|
||
signature="1__25-40",
|
||
competitor_obj_ids=[1234],
|
||
competitor_count=1,
|
||
total_sold_in_window=67,
|
||
velocity_per_month=8.4,
|
||
avg_price_per_m2_rub=148000.0,
|
||
avg_area_m2=38.5,
|
||
supply_units_in_radius=312,
|
||
sold_pct_of_supply=21.5,
|
||
is_oversold=False,
|
||
),
|
||
],
|
||
recommendation_for_tz=LayoutTzRecommendation(
|
||
rationale_text="Test rationale текст",
|
||
mix=[LayoutTzMixRow(room_bucket="1", pct=100, abs_units=100, avg_target_area_m2=38.5)],
|
||
weighted_avg_price_per_m2_rub=152000.0,
|
||
based_on_obj_count=5,
|
||
based_on_total_deals=107,
|
||
data_window_start=dt.date(2026, 2, 1),
|
||
data_window_end=dt.date(2026, 5, 1),
|
||
),
|
||
data_quality=LayoutDataQuality(
|
||
objects_with_velocity_data=5,
|
||
objects_total_in_radius=8,
|
||
raw_objects_total=14,
|
||
velocity_coverage_pct=62.5,
|
||
confidence="medium",
|
||
),
|
||
supply_only_layouts=supply_only or [],
|
||
)
|
||
|
||
|
||
def _render(response: BestLayoutsResponse) -> str:
|
||
return build_layout_tz_html(
|
||
response,
|
||
cad_num="66:41:0204016:10",
|
||
radius_km=1.0,
|
||
time_window="last_quarter",
|
||
)
|
||
|
||
|
||
def test_supply_only_block_renders_when_present() -> None:
|
||
"""§4.2: блок «По предложению (без темпа продаж)» + строки supply_only_layouts."""
|
||
resp = _base_response(
|
||
[
|
||
SupplyOnlyLayoutRow(
|
||
project_name="ЖК Тестовый",
|
||
room_bucket="2",
|
||
area_bin="50-65",
|
||
lots_count=17,
|
||
median_price_m2=132500.0,
|
||
median_area_m2=58.3,
|
||
),
|
||
]
|
||
)
|
||
html = _render(resp)
|
||
# Заголовок + caveat про «без темпа продаж» / objective_lots.
|
||
assert "По предложению (без темпа продаж)" in html
|
||
assert "objective_lots" in html or "Объектив" in html
|
||
# Данные строки: комплекс, тип, лоты, медиана ₽/м², ср. площадь.
|
||
assert "ЖК Тестовый" in html
|
||
assert "17" in html
|
||
assert "132 500 ₽/м²" in html # тыс-разделитель — неразрывный/обычный пробел
|
||
assert "58.3 м²" in html
|
||
|
||
|
||
def test_supply_only_block_absent_when_empty() -> None:
|
||
"""Graceful: пустой supply_only_layouts (старый снапшот) → секции НЕТ, не падаем."""
|
||
html = _render(_base_response([]))
|
||
assert "По предложению (без темпа продаж)" not in html
|
||
# Основной документ всё равно собран.
|
||
assert "Техническое задание на проектирование" in html
|
||
|
||
|
||
def test_supply_only_null_price_renders_dash() -> None:
|
||
"""median_price_m2=None (цены не прошли price-sanity) → «—», без ошибок."""
|
||
resp = _base_response(
|
||
[
|
||
SupplyOnlyLayoutRow(
|
||
project_name="ЖК Без цен",
|
||
room_bucket="studio",
|
||
area_bin="<25",
|
||
lots_count=4,
|
||
median_price_m2=None,
|
||
median_area_m2=None,
|
||
),
|
||
]
|
||
)
|
||
html = _render(resp)
|
||
assert "По предложению (без темпа продаж)" in html
|
||
assert "ЖК Без цен" in html
|
||
assert "<td>—</td>" in html
|
||
|
||
|
||
def test_top_layouts_block_still_present() -> None:
|
||
"""§4.2-блок аддитивен: топ продаж конкурентов не потеснён."""
|
||
html = _render(_base_response([]))
|
||
assert "Топ планировок конкурентов по продажам" in html
|
||
assert "Рекомендуемая структура квартирографии" in html
|