gendesign/backend/tests/test_layout_tz_pdf.py
lekss361 5878596bc5 feat(layouts): PDF ТЗ endpoint + BestLayoutsBlock UI (#113 PR D)
Backend (~330 LOC):
- POST /api/v1/parcels/{cad_num}/best-layouts/pdf via WeasyPrint
- services/exporters/layout_tz_pdf.py — HTML template с XSS-escape (html.escape)
  для cad_num/parcel_address/rationale_text/time_window
- 5 unit tests (skip on Windows если WeasyPrint deps missing)

Frontend (~800 LOC):
- BestLayoutsBlock.tsx — DataQualityCard + TopLayoutsTable + RecommendationCard
  + PDF download button (Blob → revokeObjectURL)
- useBestLayouts.ts — TanStack useMutation hook
- types/best-layouts.ts — manual TS types
- MarketTab.tsx +4 LOC integration после competitors

Pre-push fixes per review:
- XSS escape user-data в PDF HTML
- sold_pct_of_supply — убрать double ×100 (backend уже 0..100)
- alert() → pdfError state
- isNaN → Number.isNaN
- 7 inline-styles → Tailwind utilities

Phase 2.1 complete. Phase 2.2 (layout_type/balcony_count via B2B Объектив
#52) — follow-up issue.
2026-05-16 12:38:23 +03:00

136 lines
4.5 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 для layout_tz_pdf renderer (Issue #113 PR D).
WeasyPrint requires native GTK/Pango/GObject shared libraries. These are present
in the Docker container (Linux) but absent on Windows dev machines. All tests in
this module are skipped automatically when the native libs are unavailable.
"""
import datetime as dt
import pytest
# Attempt to import the module under test; skip entire module if native libs missing.
try:
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf
except (OSError, ImportError) as _e: # GTK libs missing on Windows, or weasyprint not installed
pytest.skip(f"WeasyPrint deps missing: {_e}", allow_module_level=True)
from app.schemas.parcel import (
BestLayoutsResponse,
LayoutDataQuality,
LayoutTzMixRow,
LayoutTzRecommendation,
TopLayoutRow,
)
def _sample_response() -> BestLayoutsResponse:
return BestLayoutsResponse(
top_layouts=[
TopLayoutRow(
rank=1,
room_bucket="1",
area_bin="25-40",
signature="1__25-40",
competitor_obj_ids=[1234, 5678],
competitor_count=2,
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,
),
TopLayoutRow(
rank=2,
room_bucket="studio",
area_bin="<25",
signature="studio__<25",
competitor_obj_ids=[1234],
competitor_count=1,
total_sold_in_window=40,
velocity_per_month=5.0,
avg_price_per_m2_rub=160000.0,
avg_area_m2=22.0,
supply_units_in_radius=100,
sold_pct_of_supply=40.0,
),
],
recommendation_for_tz=LayoutTzRecommendation(
rationale_text="Test rationale текст с кириллицей",
mix=[
LayoutTzMixRow(room_bucket="studio", pct=10, abs_units=30, avg_target_area_m2=22.0),
LayoutTzMixRow(room_bucket="1", pct=60, abs_units=180, avg_target_area_m2=38.5),
LayoutTzMixRow(room_bucket="2", pct=30, abs_units=90, avg_target_area_m2=55.0),
],
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,
velocity_coverage_pct=62.5,
confidence="medium",
),
)
def test_pdf_renders_non_empty_bytes() -> None:
pdf = render_layout_tz_pdf(
_sample_response(),
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert len(pdf) > 1000 # PDF минимум ~1KB
def test_pdf_starts_with_pdf_magic() -> None:
pdf = render_layout_tz_pdf(
_sample_response(),
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"
def test_pdf_renders_cyrillic_correctly() -> None:
"""Smoke — WeasyPrint должен handle кириллический rationale_text без UnicodeEncodeError."""
response = _sample_response()
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0303161:42",
radius_km=1.5,
time_window="last_year",
)
# Embedded text может быть compressed, но без exception = OK
assert len(pdf) > 1000
def test_pdf_handles_empty_top_layouts() -> None:
response = _sample_response()
response.top_layouts = []
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"
def test_pdf_handles_null_avg_price() -> None:
"""avg_price_per_m2_rub=None (ЖК не покрыт Objective) → должно рендериться как ''."""
response = _sample_response()
response.top_layouts[0].avg_price_per_m2_rub = None
pdf = render_layout_tz_pdf(
response,
cad_num="66:41:0204016:10",
radius_km=1.0,
time_window="last_quarter",
)
assert pdf[:4] == b"%PDF"