"""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"