gendesign/backend/tests/test_layout_tz_html.py
bot-backend a6e0d231ac
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 9s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 3m26s
CI / backend-tests (pull_request) Successful in 16m9s
feat(exporters): §4.2 «По предложению (без темпа продаж)» в ТЗ-PDF
layout_tz_pdf рендерит supply_only_layouts (#2177 шаг 3): таблица
комплекс/тип/площадь/лотов/медиана ₽/м² + честный caveat, аддитивно после
топа продаж. Graceful на старых снапшотах (пусто → секции нет). HTML-сборка
вынесена в pure build_layout_tz_html — юнит-тесты без native WeasyPrint
(до этого весь файл скипался на macOS). Экранирование скрапленных строк
html.escape — консистентно соседним блокам (ревью-verified).

Примечание: остальные блоки недели (§4.3 offer_trend, «Как участок
сходится», §3 gas) — frontend-only; полного backend-экспортёра /analyze
не существует → issue #2259.
2026-07-03 11:21:19 +05:00

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

"""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