Merge pull request #253 from gcolonese/feat/add-remaining-export-options

feat: add kicad-cli export tools (19 fabrication/3D/schematic formats)
This commit is contained in:
mixelpixx
2026-06-12 12:22:21 -04:00
committed by GitHub
4 changed files with 2823 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -58,6 +58,25 @@ export const toolCategories: ToolCategory[] = [
description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models", description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models",
tools: [ tools: [
"export_gerber", "export_gerber",
"export_gerbers",
"export_drill",
"export_ipc2581",
"export_odb",
"export_ipcd356",
"export_gencad",
"export_pos",
"export_pcb_pdf",
"export_pcb_svg",
"export_pcb_dxf",
"export_gerber_single",
"export_3d_cli",
"export_sch_bom",
"export_sch_pdf",
"export_sch_svg",
"export_sch_dxf",
"export_sch_hpgl",
"export_sch_ps",
"export_sch_python_bom",
"export_pdf", "export_pdf",
"export_svg", "export_svg",
"export_3d", "export_3d",

176
tests/test_export_cli.py Normal file
View File

@@ -0,0 +1,176 @@
"""Unit tests for the kicad-cli-backed export handlers on ``KiCADInterface``.
These handlers (``_handle_export_*``) shell out to ``kicad-cli``. The tests mock
the subprocess call and the filesystem, then assert command construction,
validation, and error handling. No real ``kicad-cli`` binary, board, or
schematic is touched.
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules so
kicad_interface imports without a real KiCAD install.
"""
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
pytestmark = pytest.mark.unit
from kicad_interface import KiCADInterface # noqa: E402
# (handler suffix, minimal params) — correct output key per handler, schematicPath for sch tools
PCB_HANDLERS = [
("gerbers", {"outputDir": "/out"}),
("drill", {"outputDir": "/out"}),
("ipc2581", {"outputPath": "/out/x.xml"}),
("odb", {"outputPath": "/out/x.zip"}),
("ipcd356", {"outputPath": "/out/x.d356"}),
("gencad", {"outputPath": "/out/x.cad"}),
("pos", {"outputPath": "/out/x.pos"}),
("pcb_pdf", {"outputPath": "/out/x.pdf"}),
("pcb_svg", {"outputPath": "/out/x.svg"}),
("pcb_dxf", {"outputPath": "/out/x.dxf"}),
("gerber_single", {"outputPath": "/out/x.gbr", "layers": ["F.Cu"]}),
("3d_cli", {"outputPath": "/out/x.step", "format": "step"}),
]
SCH_HANDLERS = [
("sch_bom", {"outputPath": "/out/b.csv", "schematicPath": "/p/x.kicad_sch"}),
("sch_pdf", {"outputPath": "/out/b.pdf", "schematicPath": "/p/x.kicad_sch"}),
("sch_python_bom", {"outputPath": "/out/b.xml", "schematicPath": "/p/x.kicad_sch"}),
("sch_svg", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_dxf", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_hpgl", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
("sch_ps", {"outputDir": "/out", "schematicPath": "/p/x.kicad_sch"}),
]
ALL_HANDLERS = PCB_HANDLERS + SCH_HANDLERS
def _make_iface():
"""KiCADInterface instance with the cli/board-path helpers mocked."""
iface = KiCADInterface.__new__(KiCADInterface)
iface.board = None
iface._find_kicad_cli_static = MagicMock(return_value="kicad-cli")
iface._current_board_path = MagicMock(return_value="/proj/board.kicad_pcb")
return iface
def _call(iface, suffix, params, rc=0, stderr=""):
"""Invoke a handler with subprocess + filesystem mocked. Returns (result, run_mock)."""
method = getattr(iface, f"_handle_export_{suffix}")
fake = SimpleNamespace(returncode=rc, stdout="", stderr=stderr)
with (
patch("subprocess.run", return_value=fake) as run,
patch("pathlib.Path.exists", return_value=True),
patch("pathlib.Path.mkdir"),
patch("pathlib.Path.iterdir", return_value=[]),
patch("pathlib.Path.is_file", return_value=True),
):
result = method(dict(params))
return result, run
class TestAllHandlers:
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_success_invokes_kicad_cli(self, suffix, params):
iface = _make_iface()
result, run = _call(iface, suffix, params, rc=0)
assert result["success"] is True, result
run.assert_called_once()
cmd = run.call_args.args[0]
assert cmd[0] == "kicad-cli"
assert cmd[1] in ("pcb", "sch")
assert cmd[2] == "export"
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_cli_not_found(self, suffix, params):
iface = _make_iface()
iface._find_kicad_cli_static = MagicMock(return_value=None)
result, _ = _call(iface, suffix, params)
assert result["success"] is False
assert "kicad-cli" in result["message"].lower()
@pytest.mark.parametrize("suffix,params", ALL_HANDLERS)
def test_subprocess_failure_propagates(self, suffix, params):
iface = _make_iface()
result, _ = _call(iface, suffix, params, rc=1, stderr="boom")
assert result["success"] is False
class TestValidation:
def test_missing_output_errors(self):
iface = _make_iface()
result, _ = _call(iface, "gerbers", {})
assert result["success"] is False
def test_pcb_no_board_resolvable_errors(self):
iface = _make_iface()
iface._current_board_path = MagicMock(return_value=None)
result, _ = _call(iface, "ipc2581", {"outputPath": "/o/x.xml"})
assert result["success"] is False
def test_sch_missing_schematic_errors(self):
iface = _make_iface()
result, _ = _call(iface, "sch_bom", {"outputPath": "/o/b.csv"})
assert result["success"] is False
class TestFlagConstruction:
"""Detailed flag mapping for the originally-authored handlers."""
def test_gerbers_flags(self):
iface = _make_iface()
_, run = _call(
iface,
"gerbers",
{
"outputDir": "/out",
"layers": ["F.Cu", "B.Cu"],
"subtractSoldermask": True,
"noX2": True,
"precision": 5,
},
)
cmd = run.call_args.args[0]
assert "--subtract-soldermask" in cmd
assert "--no-x2" in cmd
assert "--layers" in cmd
assert "F.Cu,B.Cu" in cmd
assert cmd[cmd.index("--precision") + 1] == "5"
def test_gerbers_omitted_flags_absent(self):
iface = _make_iface()
_, run = _call(iface, "gerbers", {"outputDir": "/out"})
cmd = run.call_args.args[0]
assert "--no-x2" not in cmd
assert "--subtract-soldermask" not in cmd
def test_drill_flags(self):
iface = _make_iface()
_, run = _call(
iface,
"drill",
{
"outputDir": "/out",
"format": "gerber",
"excellonSeparateTh": True,
"generateMap": True,
},
)
cmd = run.call_args.args[0]
assert "--excellon-separate-th" in cmd
assert "--generate-map" in cmd
assert cmd[cmd.index("--format") + 1] == "gerber"
def test_ipc2581_bom_column_mapping(self):
iface = _make_iface()
_, run = _call(
iface,
"ipc2581",
{"outputPath": "/o/x.xml", "bomColIntId": "FAST P/N"},
)
cmd = run.call_args.args[0]
assert cmd[cmd.index("--bom-col-int-id") + 1] == "FAST P/N"