149 lines
5.4 KiB
Python
149 lines
5.4 KiB
Python
"""Stage 1c tests — DXF and PDF exporters.
|
||
|
||
DXF is asserted by a binary round-trip (re-read with ezdxf, check layers/entities).
|
||
The PDF render is skipped when WeasyPrint's native libraries are unavailable (dev
|
||
boxes без libgobject); the HTML-build step is always exercised.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import os
|
||
import tempfile
|
||
from collections import Counter
|
||
|
||
import ezdxf
|
||
import pytest
|
||
|
||
from app.schemas.concept import ConceptInput
|
||
from app.services.generative import geometry
|
||
from app.services.generative.exporters import dxf, pdf
|
||
|
||
_PARCEL_COORDS = [
|
||
[60.60, 56.830],
|
||
[60.6045, 56.830],
|
||
[60.6045, 56.8328],
|
||
[60.60, 56.8328],
|
||
[60.60, 56.830],
|
||
]
|
||
|
||
|
||
def _payload() -> ConceptInput:
|
||
return ConceptInput(
|
||
parcel_geojson={"type": "Polygon", "coordinates": [_PARCEL_COORDS]},
|
||
housing_class="comfort",
|
||
target_floors=9,
|
||
development_type="mid_rise",
|
||
land_cost_rub=150_000_000.0,
|
||
)
|
||
|
||
|
||
def _weasyprint_available() -> bool:
|
||
"""WeasyPrint импортируется только с нативными библиотеками (libgobject и т.д.)."""
|
||
if importlib.util.find_spec("weasyprint") is None:
|
||
return False
|
||
try:
|
||
import weasyprint # noqa: F401
|
||
except OSError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def test_dxf_export_round_trips_with_layers() -> None:
|
||
payload = _payload()
|
||
parcel = geometry.parse_parcel(payload)
|
||
variant = geometry.generate(payload)[0]
|
||
|
||
data = dxf.export_concept_dxf(parcel, variant)
|
||
assert data.startswith(b"AutoCAD Binary DXF")
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh:
|
||
fh.write(data)
|
||
path = fh.name
|
||
try:
|
||
doc = ezdxf.readfile(path)
|
||
finally:
|
||
os.unlink(path)
|
||
|
||
msp = doc.modelspace()
|
||
by_layer = Counter(e.dxf.layer for e in msp)
|
||
# Участок и пятно застройки нарисованы.
|
||
assert by_layer["PARCEL"] == 1
|
||
assert by_layer["BUILDABLE"] == 1
|
||
# Секции нарисованы (по одному polyline на секцию + подписи).
|
||
n_sections = len(variant.buildings_geojson["features"])
|
||
assert n_sections > 0
|
||
assert by_layer["BUILDINGS"] >= n_sections
|
||
|
||
|
||
def test_dxf_building_footprints_have_metric_area() -> None:
|
||
from shapely.geometry import Polygon
|
||
|
||
payload = _payload()
|
||
parcel = geometry.parse_parcel(payload)
|
||
variant = geometry.generate(payload)[0]
|
||
data = dxf.export_concept_dxf(parcel, variant)
|
||
|
||
with tempfile.NamedTemporaryFile(suffix=".dxf", delete=False) as fh:
|
||
fh.write(data)
|
||
path = fh.name
|
||
try:
|
||
doc = ezdxf.readfile(path)
|
||
finally:
|
||
os.unlink(path)
|
||
|
||
areas = []
|
||
for e in doc.modelspace():
|
||
if e.dxftype() == "LWPOLYLINE" and e.dxf.layer == "BUILDINGS":
|
||
pts = [(p[0], p[1]) for p in e.get_points()]
|
||
areas.append(Polygon(pts).area)
|
||
assert areas, "no building polylines found"
|
||
# Площади секций — десятки/сотни кв.м (метры), не доли (градусы).
|
||
for area in areas:
|
||
assert 50.0 < area < 2000.0
|
||
|
||
|
||
def test_pdf_html_build_contains_tables() -> None:
|
||
variants = geometry.generate(_payload())
|
||
html = pdf._build_html(variants)
|
||
assert "Технико-экономические показатели" in html
|
||
assert "Финансовая модель" in html
|
||
# PR-3: DCF-строки заменили IRR-proxy в таблице финмодели.
|
||
assert "NPV (DCF), млн руб" in html
|
||
assert "IRR (DCF, годовой)" in html
|
||
assert "Окупаемость (PBP)" in html
|
||
|
||
|
||
def test_pdf_html_build_graceful_on_empty() -> None:
|
||
html = pdf._build_html([])
|
||
assert "нет вариантов" in html
|
||
|
||
|
||
def test_pdf_caveat_does_not_contradict_office_revenue_row() -> None:
|
||
"""Эпик #2445 C1: каскад включает нежилую выручку/НДС — caveat не должен это отрицать.
|
||
|
||
Payload — ``comfort`` (office_share=0.05 в teap.py), поэтому строка "Выручка —
|
||
нежилое (1-й этаж)" в таблице ненулевая: caveat ниже НЕ может утверждать, что
|
||
коммерция/офисы "не учитываются" или что НДС — "только машиноместа".
|
||
"""
|
||
variants = geometry.generate(_payload())
|
||
html = pdf._build_html(variants)
|
||
assert "Выручка — нежилое (1-й этаж), млн руб" in html
|
||
assert "НДС (нежилое: паркинг + коммерция), млн руб" in html
|
||
# Старые (стейл) формулировки, отрицающие модель нежилого, — не должны вернуться.
|
||
assert "не учитываются" not in html
|
||
assert "только на нежилую часть" not in html
|
||
assert "только машиноместа" not in html
|
||
# Caveat честно описывает нежилое как учтённое (не «нет в ТЭП»).
|
||
assert "нет в ТЭП" not in html
|
||
|
||
|
||
@pytest.mark.skipif(
|
||
not _weasyprint_available(),
|
||
reason="WeasyPrint native libs unavailable on this host",
|
||
)
|
||
def test_pdf_export_produces_pdf_bytes() -> None:
|
||
variants = geometry.generate(_payload())
|
||
data = pdf.export_concept_pdf(variants)
|
||
assert data.startswith(b"%PDF-")
|
||
assert len(data) > 1000
|