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