- "engine/presets/sign.json" - "engine/presets/patch.json" - "engine/presets/stencil.json" - "engine/presets/detailed.json" - "engine/presets/custom.json" - "engine/presets/loader.py" - "engine/api/routes.py" - "engine/tests/test_presets.py" GSD-Task: S03/T01
515 lines
17 KiB
Python
515 lines
17 KiB
Python
"""Integration tests for /engine/trace and /engine/simplify endpoints."""
|
|
|
|
import json
|
|
|
|
import cv2
|
|
import numpy as np
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from main import app
|
|
|
|
client = TestClient(app)
|
|
|
|
|
|
def _make_test_png(width: int = 100, height: int = 100) -> bytes:
|
|
"""Create a simple test PNG with a white rectangle on black background."""
|
|
img = np.zeros((height, width, 3), dtype=np.uint8)
|
|
cv2.rectangle(img, (20, 20), (80, 80), (255, 255, 255), -1)
|
|
ok, buf = cv2.imencode(".png", img)
|
|
assert ok
|
|
return buf.tobytes()
|
|
|
|
|
|
def _make_test_svg() -> str:
|
|
"""Create a simple SVG with a rectangular path for simplify tests."""
|
|
return (
|
|
'<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>"
|
|
)
|
|
|
|
|
|
def _make_complex_svg() -> str:
|
|
"""Create an SVG with many intermediate points (suitable for RDP reduction)."""
|
|
# A path with extra collinear intermediate points that RDP can remove
|
|
points = " ".join(
|
|
f"L {x},{10}" for x in range(11, 91)
|
|
)
|
|
return (
|
|
'<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100">'
|
|
f'<path d="M 10,10 {points} L 90,90 L 10,90 Z" fill="black"/>'
|
|
"</svg>"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def test_png() -> bytes:
|
|
return _make_test_png()
|
|
|
|
|
|
@pytest.fixture
|
|
def test_svg() -> bytes:
|
|
return _make_test_svg().encode("utf-8")
|
|
|
|
|
|
@pytest.fixture
|
|
def complex_svg() -> bytes:
|
|
return _make_complex_svg().encode("utf-8")
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/trace — SVG output (default)
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestTraceEndpointSVG:
|
|
"""Tests for /engine/trace with SVG output format."""
|
|
|
|
def test_basic_trace(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "svg"
|
|
assert "<svg" in body["output"]
|
|
assert "metadata" in body
|
|
|
|
def test_metadata_shape(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace"},
|
|
)
|
|
body = resp.json()
|
|
meta = body["metadata"]
|
|
assert meta["format"] == "svg"
|
|
assert "path_count" in meta
|
|
assert "node_count_total" in meta
|
|
assert "open_paths" in meta
|
|
assert "island_count" in meta
|
|
assert "warnings" in meta
|
|
assert "processing_ms" in meta
|
|
assert isinstance(meta["path_count"], int)
|
|
assert isinstance(meta["node_count_total"], int)
|
|
assert isinstance(meta["processing_ms"], float)
|
|
|
|
def test_svg_has_paths(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace"},
|
|
)
|
|
body = resp.json()
|
|
assert body["metadata"]["path_count"] >= 1
|
|
|
|
def test_custom_params(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={
|
|
"mode": "potrace",
|
|
"params": json.dumps({"turdsize": 5, "alphamax": 0.5}),
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert "<svg" in resp.json()["output"]
|
|
|
|
def test_defaults_to_potrace_svg(self, test_png):
|
|
"""Mode defaults to potrace, format defaults to svg."""
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "svg"
|
|
assert "<svg" in body["output"]
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/trace — JSON output
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestTraceEndpointJSON:
|
|
"""Tests for /engine/trace with JSON output format."""
|
|
|
|
def test_json_output(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "json"
|
|
assert "output" in body
|
|
assert "paths" in body["output"]
|
|
assert "metadata" in body["output"]
|
|
|
|
def test_json_metadata_fields(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "json"},
|
|
)
|
|
body = resp.json()
|
|
# Envelope metadata
|
|
assert body["metadata"]["format"] == "json"
|
|
assert isinstance(body["metadata"]["node_count_total"], int)
|
|
# Inline JSON metadata
|
|
inner_meta = body["output"]["metadata"]
|
|
assert "path_count" in inner_meta
|
|
assert "total_nodes" in inner_meta
|
|
|
|
def test_json_path_structure(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "json"},
|
|
)
|
|
body = resp.json()
|
|
paths = body["output"]["paths"]
|
|
assert len(paths) >= 1
|
|
p = paths[0]
|
|
assert "commands" in p
|
|
assert "properties" in p
|
|
assert p["commands"][0]["type"] == "M" # starts with MoveTo
|
|
|
|
def test_json_vtracer(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "vtracer", "output_format": "json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["format"] == "json"
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/trace — DXF output
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestTraceEndpointDXF:
|
|
"""Tests for /engine/trace with DXF output format."""
|
|
|
|
def test_dxf_output(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "dxf"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/dxf"
|
|
assert "Content-Disposition" in resp.headers
|
|
assert len(resp.content) > 0
|
|
|
|
def test_dxf_metadata_header(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "dxf"},
|
|
)
|
|
meta_header = resp.headers.get("X-Kerf-Metadata")
|
|
assert meta_header is not None
|
|
meta = json.loads(meta_header)
|
|
assert meta["format"] == "dxf"
|
|
assert "path_count" in meta
|
|
assert "node_count_total" in meta
|
|
|
|
def test_dxf_is_valid_ac1015(self, test_png):
|
|
"""DXF output should be parseable by ezdxf as AC1015."""
|
|
import io
|
|
import ezdxf
|
|
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "dxf"},
|
|
)
|
|
stream = io.StringIO(resp.content.decode("utf-8"))
|
|
doc = ezdxf.read(stream)
|
|
assert doc.dxfversion == "AC1015"
|
|
msp = doc.modelspace()
|
|
entities = list(msp)
|
|
assert len(entities) >= 1
|
|
|
|
def test_dxf_vtracer(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "vtracer", "output_format": "dxf"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/dxf"
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/trace — Validation
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestTraceEndpointValidation:
|
|
"""Tests for input validation on /engine/trace."""
|
|
|
|
def test_invalid_mode(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "invalid"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_unsupported_output_format(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "output_format": "pdf"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_invalid_params_json(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "params": "not-json"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_empty_file(self):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("empty.png", b"", "image/png")},
|
|
data={"mode": "potrace"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_corrupt_image(self):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("bad.png", b"not an image", "image/png")},
|
|
data={"mode": "potrace"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_unknown_preset_rejected(self, test_png):
|
|
"""Unknown preset names are rejected with 422."""
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "potrace", "preset": "logo"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/trace — VTracer mode
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestTraceEndpointVtracer:
|
|
"""Tests for /engine/trace with mode=vtracer."""
|
|
|
|
def test_basic_trace(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "vtracer"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "svg"
|
|
|
|
def test_metadata_present(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={"mode": "vtracer"},
|
|
)
|
|
body = resp.json()
|
|
meta = body["metadata"]
|
|
assert isinstance(meta["path_count"], int)
|
|
assert isinstance(meta["processing_ms"], float)
|
|
|
|
def test_custom_params(self, test_png):
|
|
resp = client.post(
|
|
"/engine/trace",
|
|
files={"file": ("test.png", test_png, "image/png")},
|
|
data={
|
|
"mode": "vtracer",
|
|
"params": json.dumps({"filter_speckle": 10, "corner_threshold": 90}),
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/simplify — SVG output
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestSimplifyEndpointSVG:
|
|
"""Tests for /engine/simplify with SVG output format."""
|
|
|
|
def test_basic_simplify(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "svg"
|
|
assert "<svg" in body["output"]
|
|
|
|
def test_simplify_metadata(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0"},
|
|
)
|
|
body = resp.json()
|
|
meta = body["metadata"]
|
|
assert meta["format"] == "svg"
|
|
assert "path_count" in meta
|
|
assert "node_count_total" in meta
|
|
assert "open_paths" in meta
|
|
assert "island_count" in meta
|
|
assert "processing_ms" in meta
|
|
|
|
def test_simplify_reduces_nodes(self, complex_svg):
|
|
"""Higher epsilon should reduce node count on complex paths."""
|
|
resp_low = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
|
|
data={"epsilon": "0.01"},
|
|
)
|
|
resp_high = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", complex_svg, "image/svg+xml")},
|
|
data={"epsilon": "10.0"},
|
|
)
|
|
low_nodes = resp_low.json()["metadata"]["node_count_total"]
|
|
high_nodes = resp_high.json()["metadata"]["node_count_total"]
|
|
assert high_nodes <= low_nodes
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/simplify — JSON output
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestSimplifyEndpointJSON:
|
|
"""Tests for /engine/simplify with JSON output format."""
|
|
|
|
def test_json_output(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "json"},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = resp.json()
|
|
assert body["format"] == "json"
|
|
assert "paths" in body["output"]
|
|
|
|
def test_json_path_commands(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "json"},
|
|
)
|
|
body = resp.json()
|
|
paths = body["output"]["paths"]
|
|
assert len(paths) >= 1
|
|
assert paths[0]["commands"][0]["type"] == "M"
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/simplify — DXF output
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestSimplifyEndpointDXF:
|
|
"""Tests for /engine/simplify with DXF output format."""
|
|
|
|
def test_dxf_output(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "dxf"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.headers["content-type"] == "application/dxf"
|
|
assert len(resp.content) > 0
|
|
|
|
def test_dxf_metadata_header(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "dxf"},
|
|
)
|
|
meta = json.loads(resp.headers["X-Kerf-Metadata"])
|
|
assert meta["format"] == "dxf"
|
|
assert isinstance(meta["path_count"], int)
|
|
|
|
def test_dxf_is_valid(self, test_svg):
|
|
"""DXF output from simplify should be parseable."""
|
|
import io
|
|
import ezdxf
|
|
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "dxf"},
|
|
)
|
|
stream = io.StringIO(resp.content.decode("utf-8"))
|
|
doc = ezdxf.read(stream)
|
|
assert doc.dxfversion == "AC1015"
|
|
|
|
|
|
# -----------------------------------------------------------------------
|
|
# /engine/simplify — Validation
|
|
# -----------------------------------------------------------------------
|
|
|
|
class TestSimplifyEndpointValidation:
|
|
"""Tests for input validation on /engine/simplify."""
|
|
|
|
def test_empty_file(self):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("empty.svg", b"", "image/svg+xml")},
|
|
data={"epsilon": "1.0"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_not_svg(self):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.txt", b"hello world", "text/plain")},
|
|
data={"epsilon": "1.0"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_invalid_output_format(self, test_svg):
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
data={"epsilon": "1.0", "output_format": "pdf"},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_default_epsilon(self, test_svg):
|
|
"""Epsilon defaults to 1.0 when not specified."""
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", test_svg, "image/svg+xml")},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["format"] == "svg"
|
|
|
|
def test_binary_file_rejected(self):
|
|
"""Binary (non-UTF-8) file should be rejected."""
|
|
resp = client.post(
|
|
"/engine/simplify",
|
|
files={"file": ("test.svg", b"\x80\x81\x82\xff", "image/svg+xml")},
|
|
data={"epsilon": "1.0"},
|
|
)
|
|
assert resp.status_code == 422
|