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:
File diff suppressed because it is too large
Load Diff
1019
src/tools/export.ts
1019
src/tools/export.ts
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,25 @@ export const toolCategories: ToolCategory[] = [
|
||||
description: "File export for fabrication and documentation: Gerber, PDF, BOM, 3D models",
|
||||
tools: [
|
||||
"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_svg",
|
||||
"export_3d",
|
||||
|
||||
176
tests/test_export_cli.py
Normal file
176
tests/test_export_cli.py
Normal 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"
|
||||
Reference in New Issue
Block a user