160 lines
5.5 KiB
Python
160 lines
5.5 KiB
Python
"""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
|
||
|
||
|
||
def _weasyprint_native_libs_available() -> tuple[bool, str]:
|
||
"""Probe whether WeasyPrint can actually render (native GTK/Pango/GObject libs).
|
||
|
||
WeasyPrint is lazy-imported inside render_layout_tz_pdf, so a plain module
|
||
import no longer surfaces the missing-native-lib OSError. The native libs
|
||
(libgobject/libpango) are present in the Docker/CI image (Linux) but absent
|
||
on macOS/Windows dev machines — the OSError fires only at write_pdf() time.
|
||
We probe by rendering a trivial document; failure ⇒ skip on dev, but the
|
||
suite still runs the PDF path on CI where the libs exist.
|
||
"""
|
||
try:
|
||
from weasyprint import HTML
|
||
|
||
HTML(string="<p>x</p>").write_pdf()
|
||
except (OSError, ImportError) as e:
|
||
return False, str(e)
|
||
return True, ""
|
||
|
||
|
||
_WP_OK, _WP_ERR = _weasyprint_native_libs_available()
|
||
if not _WP_OK:
|
||
pytest.skip(f"WeasyPrint native libs missing: {_WP_ERR}", allow_module_level=True)
|
||
|
||
# Импорты app-модулей ПОСЛЕ module-level skip guard выше — иначе на macOS без
|
||
# native libs импорт схемы не нужен (модуль skip'ается). E402 здесь намеренный.
|
||
from app.schemas.parcel import ( # noqa: E402
|
||
BestLayoutsResponse,
|
||
LayoutDataQuality,
|
||
LayoutTzMixRow,
|
||
LayoutTzRecommendation,
|
||
TopLayoutRow,
|
||
)
|
||
from app.services.exporters.layout_tz_pdf import render_layout_tz_pdf # noqa: E402
|
||
|
||
|
||
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,
|
||
is_oversold=False,
|
||
),
|
||
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,
|
||
is_oversold=False,
|
||
),
|
||
],
|
||
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,
|
||
raw_objects_total=14,
|
||
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"
|