- "engine/output/dxf.py" - "engine/api/routes.py" - "engine/tests/test_output.py" GSD-Task: S01/T01
437 lines
16 KiB
Python
437 lines
16 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)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# DXF unit/scale/layer_map tests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
# 384×576 px artboard = 4×6 inches at 96 PPI
|
||
_ARTBOARD_SVG_4x6 = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" width="384" height="576" viewBox="0 0 384 576">'
|
||
'<path d="M 0,0 L 384,0 L 384,576 L 0,576 Z" fill="black"/>'
|
||
"</svg>"
|
||
)
|
||
|
||
|
||
class TestDXFUnitsAndScale:
|
||
"""Tests for the units, scale_factor, and layer_map extensions."""
|
||
|
||
def test_scale_factor_converts_pixel_coords_to_inches(self):
|
||
"""A 384×576 px rectangle scaled by 1/96 should span 0–4 × 0–6 inches."""
|
||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||
scale = 1.0 / 96.0
|
||
dxf_bytes = generate_dxf(result, units="inches", scale_factor=scale)
|
||
doc = _read_dxf(dxf_bytes)
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
assert len(polylines) >= 1
|
||
|
||
# Collect all x/y values across polylines
|
||
all_x: list[float] = []
|
||
all_y: list[float] = []
|
||
for pl in polylines:
|
||
for pt in pl.get_points():
|
||
all_x.append(pt[0])
|
||
all_y.append(pt[1])
|
||
|
||
assert min(all_x) == pytest.approx(0.0, abs=0.01)
|
||
assert max(all_x) == pytest.approx(4.0, abs=0.01)
|
||
assert min(all_y) == pytest.approx(0.0, abs=0.01)
|
||
assert max(all_y) == pytest.approx(6.0, abs=0.01)
|
||
|
||
def test_scale_factor_converts_pixel_coords_to_mm(self):
|
||
"""A 384×576 px rectangle scaled by 25.4/96 should span 0–101.6 × 0–152.4 mm."""
|
||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||
scale = 25.4 / 96.0
|
||
dxf_bytes = generate_dxf(result, units="mm", scale_factor=scale)
|
||
doc = _read_dxf(dxf_bytes)
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
|
||
all_x: list[float] = []
|
||
all_y: list[float] = []
|
||
for pl in polylines:
|
||
for pt in pl.get_points():
|
||
all_x.append(pt[0])
|
||
all_y.append(pt[1])
|
||
|
||
assert max(all_x) == pytest.approx(101.6, abs=0.1)
|
||
assert max(all_y) == pytest.approx(152.4, abs=0.1)
|
||
|
||
def test_insunits_header_set_for_inches(self):
|
||
"""$INSUNITS should be 1 (inches) when units='inches'."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result, units="inches")
|
||
doc = _read_dxf(dxf_bytes)
|
||
assert doc.header["$INSUNITS"] == 1
|
||
|
||
def test_insunits_header_set_for_mm(self):
|
||
"""$INSUNITS should be 4 (millimeters) when units='mm'."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result, units="mm")
|
||
doc = _read_dxf(dxf_bytes)
|
||
assert doc.header["$INSUNITS"] == 4
|
||
|
||
def test_measurement_header_imperial(self):
|
||
"""$MEASUREMENT should be 0 (imperial) when units='inches'."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result, units="inches")
|
||
doc = _read_dxf(dxf_bytes)
|
||
assert doc.header["$MEASUREMENT"] == 0
|
||
|
||
def test_measurement_header_metric(self):
|
||
"""$MEASUREMENT should be 1 (metric) when units='mm'."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result, units="mm")
|
||
doc = _read_dxf(dxf_bytes)
|
||
assert doc.header["$MEASUREMENT"] == 1
|
||
|
||
def test_no_units_omits_insunits_header(self):
|
||
"""When no units specified, $INSUNITS stays at the ezdxf R2000 default (6 = meters)."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result)
|
||
doc = _read_dxf(dxf_bytes)
|
||
# ezdxf R2000 template defaults $INSUNITS to 6 (meters) — we don't override it
|
||
assert doc.header["$INSUNITS"] == 6
|
||
|
||
def test_scale_factor_default_no_change(self):
|
||
"""With scale_factor=1.0 (default), coords remain unchanged from pixel values."""
|
||
result = _make_result()
|
||
dxf_bytes = generate_dxf(result)
|
||
doc = _read_dxf(dxf_bytes)
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
# Original SIMPLE_SVG has coords around 10–90
|
||
all_x: list[float] = []
|
||
for pl in polylines:
|
||
for pt in pl.get_points():
|
||
all_x.append(pt[0])
|
||
assert max(all_x) == pytest.approx(90.0, abs=1.0)
|
||
|
||
def test_layer_map_assigns_paths_to_named_layers(self):
|
||
"""layer_map should place specific paths on custom layers."""
|
||
result = _make_result(SVG_WITH_ISLAND)
|
||
# Assign path 0 to "CUT" layer
|
||
dxf_bytes = generate_dxf(result, layer_map={0: "CUT"})
|
||
doc = _read_dxf(dxf_bytes)
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
layers = [pl.dxf.layer for pl in polylines]
|
||
assert "CUT" in layers
|
||
|
||
def test_layer_map_does_not_override_island_layer(self):
|
||
"""Islands should still go to ISLANDS layer even when layer_map is used,
|
||
unless the layer_map explicitly assigns them elsewhere.
|
||
"""
|
||
result = _make_result(SVG_WITH_ISLAND)
|
||
# Only assign path 0 to "CUT", leave other paths to default logic
|
||
# Find which index is the island
|
||
island_idx = next(i for i, p in enumerate(result.paths) if p.is_island)
|
||
non_island_idx = next(i for i, p in enumerate(result.paths) if not p.is_island)
|
||
dxf_bytes = generate_dxf(result, layer_map={non_island_idx: "CUT"})
|
||
doc = _read_dxf(dxf_bytes)
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
layers = [pl.dxf.layer for pl in polylines]
|
||
assert "CUT" in layers
|
||
assert "ISLANDS" in layers
|
||
|
||
def test_combined_units_scale_and_layer_map(self):
|
||
"""All three features work together: units + scale + layer_map."""
|
||
result = _make_result(_ARTBOARD_SVG_4x6)
|
||
scale = 1.0 / 96.0
|
||
dxf_bytes = generate_dxf(
|
||
result, units="inches", scale_factor=scale, layer_map={0: "OUTLINE"}
|
||
)
|
||
doc = _read_dxf(dxf_bytes)
|
||
|
||
# Headers set
|
||
assert doc.header["$INSUNITS"] == 1
|
||
assert doc.header["$MEASUREMENT"] == 0
|
||
|
||
# Scale applied
|
||
msp = doc.modelspace()
|
||
polylines = list(msp.query("LWPOLYLINE"))
|
||
all_x: list[float] = []
|
||
for pl in polylines:
|
||
for pt in pl.get_points():
|
||
all_x.append(pt[0])
|
||
assert max(all_x) == pytest.approx(4.0, abs=0.01)
|
||
|
||
# Layer assigned
|
||
layers = [pl.dxf.layer for pl in polylines]
|
||
assert "OUTLINE" in layers
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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)
|