kerf-engine/engine/tests/test_api.py
jlightner c693f5e1e2 fix: Implemented 5 preset configs (sign, patch, stencil, detailed, cust…
- "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
2026-03-26 04:45:52 +00:00

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