274 lines
9.4 KiB
Python
274 lines
9.4 KiB
Python
"""Tests for output format generators (SVG, DXF, JSON)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
import io
|
|
|
|
import ezdxf
|
|
import pytest
|
|
|
|
from pipeline.postprocess import PathInfo, PostProcessResult, postprocess_svg
|
|
from output.svg import generate_svg
|
|
from output.json_output import generate_json
|
|
from output.dxf import generate_dxf
|
|
|
|
|
|
def _read_dxf(dxf_bytes: bytes) -> ezdxf.document.Drawing:
|
|
"""Read DXF bytes back into an ezdxf document (needs StringIO, not BytesIO)."""
|
|
return ezdxf.read(io.StringIO(dxf_bytes.decode("utf-8")))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SIMPLE_SVG = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
|
|
'<path d="M 10,10 L 90,10 L 90,90 L 10,90 Z" fill="black"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
# SVG with an outer CCW rectangle and an inner CW rectangle (island/hole)
|
|
SVG_WITH_ISLAND = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
|
|
'<path d="M 0,0 L 100,0 L 100,100 L 0,100 Z" fill="black"/>'
|
|
'<path d="M 30,30 L 30,70 L 70,70 L 70,30 Z" fill="black"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
# SVG with an open path (no Z close)
|
|
SVG_OPEN_PATH = (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
|
|
'<path d="M 10,10 L 50,10 L 50,50" fill="none" stroke="black"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
|
|
def _make_result(svg: str = SIMPLE_SVG, epsilon: float = 0.1) -> PostProcessResult:
|
|
"""Run postprocess pipeline and return result for testing output generators."""
|
|
return postprocess_svg(svg, epsilon=epsilon)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SVG output tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestSVGOutput:
|
|
def test_returns_string(self):
|
|
result = _make_result()
|
|
svg = generate_svg(result)
|
|
assert isinstance(svg, str)
|
|
|
|
def test_contains_svg_root(self):
|
|
result = _make_result()
|
|
svg = generate_svg(result)
|
|
assert "<svg" in svg
|
|
assert "</svg>" in svg
|
|
|
|
def test_contains_path_element(self):
|
|
result = _make_result()
|
|
svg = generate_svg(result)
|
|
assert "<path" in svg
|
|
|
|
def test_valid_xml(self):
|
|
import xml.etree.ElementTree as ET
|
|
|
|
result = _make_result()
|
|
svg = generate_svg(result)
|
|
root = ET.fromstring(svg)
|
|
assert root.tag == "{http://www.w3.org/2000/svg}svg"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# JSON output tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestJSONOutput:
|
|
def test_returns_valid_json(self):
|
|
result = _make_result()
|
|
output = generate_json(result)
|
|
parsed = json.loads(output)
|
|
assert isinstance(parsed, dict)
|
|
|
|
def test_has_paths_and_metadata(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
assert "paths" in parsed
|
|
assert "metadata" in parsed
|
|
|
|
def test_path_has_commands_and_properties(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
assert len(parsed["paths"]) > 0
|
|
path = parsed["paths"][0]
|
|
assert "commands" in path
|
|
assert "properties" in path
|
|
|
|
def test_commands_have_correct_types(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
path = parsed["paths"][0]
|
|
types = [cmd["type"] for cmd in path["commands"]]
|
|
assert types[0] == "M" # starts with Move
|
|
assert "L" in types # has line commands
|
|
assert types[-1] == "Z" # closed path ends with Z
|
|
|
|
def test_properties_fields(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
props = parsed["paths"][0]["properties"]
|
|
assert "is_closed" in props
|
|
assert "is_island" in props
|
|
assert "node_count" in props
|
|
assert "original_node_count" in props
|
|
assert "area" in props
|
|
|
|
def test_metadata_fields(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
meta = parsed["metadata"]
|
|
assert "path_count" in meta
|
|
assert "total_nodes" in meta
|
|
assert "total_original_nodes" in meta
|
|
assert "open_path_count" in meta
|
|
assert "island_count" in meta
|
|
|
|
def test_metadata_path_count_matches(self):
|
|
result = _make_result()
|
|
parsed = json.loads(generate_json(result))
|
|
assert parsed["metadata"]["path_count"] == len(parsed["paths"])
|
|
|
|
def test_island_flagged_in_properties(self):
|
|
result = _make_result(SVG_WITH_ISLAND)
|
|
parsed = json.loads(generate_json(result))
|
|
island_flags = [p["properties"]["is_island"] for p in parsed["paths"]]
|
|
assert True in island_flags # at least one island detected
|
|
|
|
def test_open_path_not_closed(self):
|
|
result = _make_result(SVG_OPEN_PATH)
|
|
parsed = json.loads(generate_json(result))
|
|
# Open path should not end with Z
|
|
path = parsed["paths"][0]
|
|
types = [cmd["type"] for cmd in path["commands"]]
|
|
assert types[-1] != "Z"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# DXF output tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDXFOutput:
|
|
def test_returns_bytes(self):
|
|
result = _make_result()
|
|
dxf = generate_dxf(result)
|
|
assert isinstance(dxf, bytes)
|
|
|
|
def test_valid_dxf_structure(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
# ezdxf can read it back
|
|
doc = _read_dxf(dxf_bytes)
|
|
assert doc.dxfversion >= "AC1015"
|
|
|
|
def test_contains_lwpolyline_entities(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
assert len(polylines) > 0
|
|
|
|
def test_polyline_has_correct_point_count(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
# Simple square: 4 unique points (close flag handles the fifth)
|
|
total_points = sum(len(list(pl.get_points())) for pl in polylines)
|
|
assert total_points >= 4
|
|
|
|
def test_closed_path_produces_closed_polyline(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
# At least one closed polyline
|
|
assert any(pl.is_closed for pl in polylines)
|
|
|
|
def test_island_on_islands_layer(self):
|
|
result = _make_result(SVG_WITH_ISLAND)
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
layers = [pl.dxf.layer for pl in polylines]
|
|
assert "ISLANDS" in layers
|
|
|
|
def test_outer_contour_on_default_layer(self):
|
|
result = _make_result(SVG_WITH_ISLAND)
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
layers = [pl.dxf.layer for pl in polylines]
|
|
assert "0" in layers
|
|
|
|
def test_ac1015_version(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
assert doc.dxfversion == "AC1015"
|
|
|
|
def test_dxf_header_present(self):
|
|
result = _make_result()
|
|
dxf_bytes = generate_dxf(result)
|
|
# Raw bytes should contain DXF section markers
|
|
text = dxf_bytes.decode("ascii", errors="replace")
|
|
assert "HEADER" in text
|
|
assert "ENTITIES" in text
|
|
|
|
def test_open_path_produces_open_polyline(self):
|
|
result = _make_result(SVG_OPEN_PATH)
|
|
dxf_bytes = generate_dxf(result)
|
|
doc = _read_dxf(dxf_bytes)
|
|
msp = doc.modelspace()
|
|
polylines = list(msp.query("LWPOLYLINE"))
|
|
# Open path should produce non-closed polyline
|
|
assert any(not pl.is_closed for pl in polylines)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: round-trip through all formats
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestRoundTrip:
|
|
def test_all_formats_from_same_input(self):
|
|
"""All three generators produce valid output from the same PostProcessResult."""
|
|
result = _make_result()
|
|
svg = generate_svg(result)
|
|
json_out = generate_json(result)
|
|
dxf = generate_dxf(result)
|
|
|
|
assert isinstance(svg, str) and "<svg" in svg
|
|
assert isinstance(json_out, str) and json.loads(json_out)
|
|
assert isinstance(dxf, bytes) and len(dxf) > 0
|
|
|
|
def test_path_count_consistent_across_formats(self):
|
|
"""JSON and DXF should have the same number of paths as the result."""
|
|
result = _make_result(SVG_WITH_ISLAND)
|
|
parsed_json = json.loads(generate_json(result))
|
|
|
|
doc = _read_dxf(generate_dxf(result))
|
|
msp = doc.modelspace()
|
|
dxf_polylines = list(msp.query("LWPOLYLINE"))
|
|
|
|
assert parsed_json["metadata"]["path_count"] == len(result.paths)
|
|
assert len(dxf_polylines) == len(result.paths)
|