From 0600fb4acd855ec4bb59295c6fb4123738435eb5 Mon Sep 17 00:00:00 2001 From: Cody Date: Mon, 13 Apr 2026 01:21:40 -0400 Subject: [PATCH] test(py): add coverage for PyO3 bindings + gallery round-trip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grows the Python test suite from 5 tests (test_draw.py) to 27 across two files: tests/test_core.py - top-level re-export smoke test (dkdc_draw → dkdc_draw.core) - new_document structure - save/load round-trip (empty + with element) - SVG well-formedness - PNG magic bytes, default scale, custom scale - error paths: nonexistent load, invalid JSON on save/svg/png tests/test_gallery_roundtrip.py - Uses the 5 committed examples/gallery/*.draw.json fixtures (flowchart, sticky, wireframe, sketch, patterns) to exercise every element type and fill pattern against the Python layer. - Per-slug: save-reload fixed point + SVG well-formed + PNG valid. Deletes tests/test_draw.py — the 4 non-smoke tests were narrower duplicates of the new test_core.py versions, and the `HAS_NATIVE` ImportError guard was cargo-cult (the project ships via maturin and every test needs the native module). Run with `bin/test-py` or `uv run pytest`. Runs as part of `bin/test`. Not added to `bin/check-py` to preserve the existing CI-skip pattern (maturin+ty already local-only; tests follow suit). Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/__init__.py | 0 tests/test_core.py | 132 ++++++++++++++++++++++++++++++++ tests/test_draw.py | 61 --------------- tests/test_gallery_roundtrip.py | 56 ++++++++++++++ 4 files changed, 188 insertions(+), 61 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_core.py delete mode 100644 tests/test_draw.py create mode 100644 tests/test_gallery_roundtrip.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..6c70c45 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,132 @@ +"""Unit tests for the `dkdc_draw.core` PyO3 surface. + +Covers the canonical flow — build / save / load / export — plus error paths. +Skips `run_cli` since it's an argv-driven CLI harness; structural correctness +is already covered by the Rust integration tests. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +import dkdc_draw +from dkdc_draw import core + + +def _parse(doc_json: str) -> dict: + return json.loads(doc_json) + + +def test_top_level_reexports_wrapper_api() -> None: + # `dkdc_draw.__init__` re-exports the PyO3 functions so `from dkdc_draw + # import new_document` works. Smoke-test that the wrapper layer is intact. + for name in ( + "new_document", + "load_document", + "save_document", + "export_svg", + "export_png", + "run_cli", + "run", + "main", + ): + assert hasattr(dkdc_draw, name), f"dkdc_draw missing {name}" + + +def test_new_document_returns_valid_json() -> None: + doc = _parse(core.new_document("my drawing")) + assert doc["name"] == "my drawing" + assert doc["elements"] == [] + assert doc["version"] == 1 + assert isinstance(doc["id"], str) and doc["id"] + + +def test_save_load_roundtrip_empty(tmp_path: Path) -> None: + original = core.new_document("roundtrip") + path = tmp_path / "empty.draw.json" + + core.save_document(original, str(path)) + loaded = core.load_document(str(path)) + + assert _parse(original) == _parse(loaded) + + +def test_save_load_roundtrip_with_element(tmp_path: Path) -> None: + doc = _parse(core.new_document("with element")) + doc["elements"].append( + { + "type": "Rectangle", + "id": "r1", + "x": 10.0, + "y": 20.0, + "width": 100.0, + "height": 60.0, + } + ) + path = tmp_path / "rect.draw.json" + + core.save_document(json.dumps(doc), str(path)) + loaded = _parse(core.load_document(str(path))) + + assert loaded["elements"][0]["id"] == "r1" + assert loaded["elements"][0]["type"] == "Rectangle" + assert loaded["elements"][0]["width"] == 100.0 + + +def test_export_svg_well_formed() -> None: + doc = core.new_document("svg") + svg = core.export_svg(doc) + assert svg.startswith("") + + +def test_export_png_has_magic_bytes() -> None: + doc = core.new_document("png") + png = core.export_png(doc) + assert png[:4] == b"\x89PNG" + + +def test_export_png_default_scale_is_2() -> None: + doc = core.new_document("scale") + # Default (scale=2.0) should be larger than scale=1.0 for the same doc. + default_png = core.export_png(doc) + small_png = core.export_png(doc, 1.0) + # An empty doc renders to a small image either way, but 2x should still + # produce a file at least as large as 1x in practice. + assert len(default_png) >= len(small_png) + + +def test_export_png_custom_scale() -> None: + doc = core.new_document("scale custom") + big = core.export_png(doc, 3.0) + assert big[:4] == b"\x89PNG" + + +# ── Error paths ────────────────────────────────────────────────────── + + +def test_load_nonexistent_raises(tmp_path: Path) -> None: + with pytest.raises(RuntimeError): + core.load_document(str(tmp_path / "does-not-exist.draw.json")) + + +def test_save_invalid_json_raises(tmp_path: Path) -> None: + with pytest.raises(RuntimeError): + core.save_document("not valid json", str(tmp_path / "x.draw.json")) + + +def test_export_svg_invalid_json_raises() -> None: + with pytest.raises(RuntimeError): + core.export_svg("") + with pytest.raises(RuntimeError): + core.export_svg("not valid json") + with pytest.raises(RuntimeError): + core.export_svg("{}") # valid json, missing Document fields + + +def test_export_png_invalid_json_raises() -> None: + with pytest.raises(RuntimeError): + core.export_png("{ not json") diff --git a/tests/test_draw.py b/tests/test_draw.py deleted file mode 100644 index ca1af7e..0000000 --- a/tests/test_draw.py +++ /dev/null @@ -1,61 +0,0 @@ -import json -import os -import tempfile - -import pytest - -try: - from dkdc_draw import export_svg, load_document, new_document, save_document - - HAS_NATIVE = True -except ImportError: - HAS_NATIVE = False - - -def test_import(): - """Verify the package can be imported.""" - import dkdc_draw - - assert hasattr(dkdc_draw, "run_cli") - assert hasattr(dkdc_draw, "new_document") - assert hasattr(dkdc_draw, "export_svg") - - -@pytest.mark.skipif(not HAS_NATIVE, reason="native module not built") -def test_new_document_returns_valid_json(): - """new_document returns valid JSON with expected structure.""" - result = new_document("test") - doc = json.loads(result) - assert doc["name"] == "test" - assert "elements" in doc - assert isinstance(doc["elements"], list) - - -@pytest.mark.skipif(not HAS_NATIVE, reason="native module not built") -def test_export_svg_returns_svg_string(): - """export_svg returns an SVG string containing expected tags.""" - doc_json = new_document("svg-test") - svg = export_svg(doc_json) - assert "" in svg - - -@pytest.mark.skipif(not HAS_NATIVE, reason="native module not built") -def test_save_and_load_roundtrip(): - """save_document + load_document round-trips without data loss.""" - doc_json = new_document("roundtrip") - with tempfile.TemporaryDirectory() as tmp: - path = os.path.join(tmp, "test.draw.json") - save_document(doc_json, path) - loaded_json = load_document(path) - assert json.loads(doc_json) == json.loads(loaded_json) - - -@pytest.mark.skipif(not HAS_NATIVE, reason="native module not built") -def test_export_svg_invalid_json_raises(): - """export_svg raises RuntimeError on invalid JSON input.""" - with pytest.raises(RuntimeError): - export_svg("") - - with pytest.raises(RuntimeError): - export_svg("not valid json") diff --git a/tests/test_gallery_roundtrip.py b/tests/test_gallery_roundtrip.py new file mode 100644 index 0000000..4140054 --- /dev/null +++ b/tests/test_gallery_roundtrip.py @@ -0,0 +1,56 @@ +"""Round-trip every gallery drawing through the Python bindings. + +The committed `examples/gallery/*.draw.json` files act as fixtures — they +cover every element type, fill pattern, and stroke style in the core API. +If a backwards-incompatible change lands in the serde layer or the export +pipeline, this surfaces immediately against real documents. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dkdc_draw import core + +GALLERY = Path(__file__).resolve().parents[1] / "examples" / "gallery" +SLUGS = ["flowchart", "sticky", "wireframe", "sketch", "patterns"] + + +@pytest.fixture(scope="module") +def gallery_json() -> dict[str, str]: + missing = [s for s in SLUGS if not (GALLERY / f"{s}.draw.json").exists()] + if missing: + pytest.skip(f"gallery fixtures missing: {missing}") + return {s: (GALLERY / f"{s}.draw.json").read_text() for s in SLUGS} + + +@pytest.mark.parametrize("slug", SLUGS) +def test_gallery_load_roundtrip( + tmp_path: Path, slug: str, gallery_json: dict[str, str] +) -> None: + src = GALLERY / f"{slug}.draw.json" + loaded = core.load_document(str(src)) + + dst = tmp_path / f"{slug}.draw.json" + core.save_document(loaded, str(dst)) + reloaded = core.load_document(str(dst)) + + # Re-save-then-reload is a byte-stable fixed point. + assert loaded == reloaded + + +@pytest.mark.parametrize("slug", SLUGS) +def test_gallery_export_svg(slug: str, gallery_json: dict[str, str]) -> None: + svg = core.export_svg(gallery_json[slug]) + assert svg.startswith("") + + +@pytest.mark.parametrize("slug", SLUGS) +def test_gallery_export_png(slug: str, gallery_json: dict[str, str]) -> None: + png = core.export_png(gallery_json[slug]) + assert png[:4] == b"\x89PNG" + # Gallery drawings have content, so PNG shouldn't be trivially small. + assert len(png) > 500