"""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 with an outer CCW rectangle and an inner CW rectangle (island/hole) SVG_WITH_ISLAND = ( '' '' '' "" ) # SVG with an open path (no Z close) SVG_OPEN_PATH = ( '' '' "" ) 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 "" in svg def test_contains_path_element(self): result = _make_result() svg = generate_svg(result) assert " 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 = ( '' '' "" ) 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 " 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)