kerf-engine/engine/tests/test_output.py
jlightner 9540f37f70 test: Wire post-processing into /engine/trace, add output_format routin…
- "engine/api/routes.py"
- "engine/tests/test_api.py"

GSD-Task: S02/T03
2026-03-26 04:39:52 +00:00

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)