kerf-engine/engine/tests/test_output.py
jlightner 6c8c31e13b feat: Extended generate_dxf() with units/scale_factor/layer_map params…
- "engine/output/dxf.py"
- "engine/api/routes.py"
- "engine/tests/test_output.py"

GSD-Task: S01/T01
2026-03-26 06:17:06 +00:00

437 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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 04 × 06 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 0101.6 × 0152.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 1090
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)