From 6c44273d554eea0ab9418993fcd496e7531689b3 Mon Sep 17 00:00:00 2001 From: Jeff Laflamme Date: Wed, 27 May 2026 10:50:21 +0700 Subject: [PATCH] =?UTF-8?q?fix(board-view):=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20fold=20responseMode=20+=20restore=20jpg?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto current main (d765bfe). Merged changes: Board view (kicad-cli, cffi-free): - Replace pcbnew/PLOT_CONTROLLER + cairosvg with kicad-cli SVG export and cffi-free PNG conversion: pymupdf → inkscape → imagemagick chain - pcbPath optional param with fallback to loaded board - Restore jpg output via PIL post-processing on PNG bytes - responseMode inline/file from main's #161: inline returns imageData, file writes _2d_view. and returns filePath - shutil.which for cross-platform kicad-cli lookup - Explicit TimeoutExpired catch; errorDetails on all error returns - Validate fmt strictly; TypeScript returns image type for inline png/jpg Schematic view (kicad_interface.py): - Same cffi-free _svg_to_png helper for get_schematic_view and get_schematic_view_region (pymupdf → inkscape → imagemagick) Tests: - Update test_get_board_2d_view_save_to_file.py to mock subprocess/ kicad-cli and _svg_to_png instead of PLOT_CONTROLLER/cairosvg - All 5 responseMode tests pass --- python/commands/board/view.py | 264 +++++++++++++------ python/kicad_interface.py | 80 ++++-- python/schemas/tool_schemas.py | 27 +- src/tools/board.ts | 53 +++- tests/test_get_board_2d_view_save_to_file.py | 113 ++++---- 5 files changed, 349 insertions(+), 188 deletions(-) diff --git a/python/commands/board/view.py b/python/commands/board/view.py index 366c5f8..ef8459e 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -14,6 +14,64 @@ from PIL import Image logger = logging.getLogger("kicad_interface") +def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]: + """Convert SVG to PNG. No cffi dependency. + + Priority: + 1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps + 2. Inkscape CLI — accurate KiCAD SVG rendering + 3. ImageMagick convert — broad availability fallback + Returns PNG bytes or None if all converters fail. + """ + import subprocess + import tempfile + + try: + import fitz + + doc = fitz.open(svg_path) + page = doc[0] + mat = fitz.Matrix(width / page.rect.width, height / page.rect.height) + return page.get_pixmap(matrix=mat).tobytes("png") + except Exception: + pass + + out_path = os.path.join(tempfile.mkdtemp(), "out.png") + + try: + r = subprocess.run( + [ + "inkscape", + svg_path, + "--export-type=png", + f"--export-width={width}", + f"--export-height={height}", + f"--export-filename={out_path}", + ], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + try: + r = subprocess.run( + ["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return None + + class BoardViewCommands: """Handles board viewing operations""" @@ -73,108 +131,142 @@ class BoardViewCommands: } def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: - """Get a 2D image of the PCB. + """Render PCB to PNG/JPG/SVG via kicad-cli (no pcbnew/cffi dependency). responseMode controls how the image is returned: - "inline" (default): image bytes are base64-encoded and returned as ``imageData``. - "file": image is written next to the .kicad_pcb file and ``filePath`` is returned. """ + import glob + import shutil + import subprocess + import tempfile + try: - if not self.board: + pcb_path = params.get("pcbPath") + if not pcb_path: + if self.board: + pcb_path = self.board.GetFileName() + if not pcb_path: + return { + "success": False, + "message": "pcbPath required", + "errorDetails": "Provide pcbPath or load a board first", + } + + if not os.path.exists(pcb_path): return { "success": False, - "message": "No board is loaded", - "errorDetails": "Load or create a board first", + "message": f"PCB file not found: {pcb_path}", + "errorDetails": pcb_path, } - # Get parameters - width = params.get("width", 800) - height = params.get("height", 600) - format = params.get("format", "png") - layers = params.get("layers", []) + width = params.get("width", 1600) + height = params.get("height", 1200) + fmt = params.get("format", "png") + if fmt not in ("png", "jpg", "svg"): + return { + "success": False, + "message": f"Unsupported format '{fmt}'. Use 'png', 'jpg', or 'svg'.", + "errorDetails": f"Got: {fmt}", + } + layers: List[str] = params.get("layers", []) response_mode = params.get("responseMode", "inline") - # Create plot controller - plotter = pcbnew.PLOT_CONTROLLER(self.board) - - # Set up plot options - plot_opts = plotter.GetPlotOptions() - plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) - plot_opts.SetScale(1) - plot_opts.SetMirror(False) - # Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers - plot_opts.SetPlotFrameRef(False) - plot_opts.SetPlotValue(True) - plot_opts.SetPlotReference(True) - - # Plot to SVG first (for vector output) - # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path - plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") - - # Plot specified layers or all enabled layers - # Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer() - if layers: - for layer_name in layers: - layer_id = self.board.GetLayerID(layer_name) - if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): - plotter.SetLayer(layer_id) - plotter.PlotLayer() - else: - for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): - if self.board.IsLayerEnabled(layer_id): - plotter.SetLayer(layer_id) - plotter.PlotLayer() - - # Get the actual filename that was created (includes project name prefix) - temp_svg = plotter.GetPlotFileName() - - plotter.ClosePlot() - - # Determine output path next to the PCB file - board_dir = os.path.dirname(self.board.GetFileName()) - board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0] - - # --- Render to bytes (shared for both response modes) --- - if format == "svg": - with open(temp_svg, "rb") as f: - image_bytes = f.read() - os.remove(temp_svg) - mime_format = "svg" - else: - from cairosvg import svg2png - - image_bytes = svg2png(url=temp_svg, output_width=width, output_height=height) - os.remove(temp_svg) - - if format == "jpg": - img = Image.open(io.BytesIO(image_bytes)) - buf = io.BytesIO() - img.convert("RGB").save(buf, format="JPEG") - image_bytes = buf.getvalue() - mime_format = format - - # --- Package response according to responseMode --- - if response_mode == "file": - output_path = os.path.join(board_dir, f"{board_name}_2d_view.{mime_format}") - with open(output_path, "wb") as f: - f.write(image_bytes) + kicad_cli = shutil.which("kicad-cli") or shutil.which("kicad-cli.exe") + if not kicad_cli: return { - "success": True, - "format": mime_format, - "filePath": output_path, - "message": f"2D view saved to {output_path}", - } - else: - # inline mode: base64-encode and return imageData - image_b64 = base64.b64encode(image_bytes).decode("utf-8") - return { - "success": True, - "format": mime_format, - "imageData": image_b64, + "success": False, + "message": "kicad-cli not found in PATH", + "errorDetails": "Install KiCad and ensure kicad-cli is on PATH", } + with tempfile.TemporaryDirectory() as tmpdir: + cmd = [kicad_cli, "pcb", "export", "svg", "--output", tmpdir, "--black-and-white"] + if layers: + cmd += ["--layers", ",".join(layers)] + cmd.append(pcb_path) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + except subprocess.TimeoutExpired: + return { + "success": False, + "message": "kicad-cli timed out after 60 s", + "errorDetails": " ".join(cmd), + } + + if result.returncode != 0: + return { + "success": False, + "message": "kicad-cli SVG export failed", + "errorDetails": result.stderr.strip() or result.stdout.strip(), + } + + svg_files = glob.glob(os.path.join(tmpdir, "*.svg")) + if not svg_files: + return { + "success": False, + "message": "kicad-cli produced no SVG output", + "errorDetails": result.stdout.strip(), + } + + svg_path = svg_files[0] + + # --- Render to bytes (shared for both response modes) --- + board_dir = os.path.dirname(pcb_path) + board_name = os.path.splitext(os.path.basename(pcb_path))[0] + + if fmt == "svg": + with open(svg_path, "rb") as f: + image_bytes = f.read() + mime_format = "svg" + else: + png_bytes = _svg_to_png(svg_path, width, height) + if png_bytes is None: + # No PNG converter — fall back to SVG inline + with open(svg_path, "r", encoding="utf-8") as f: + return { + "success": True, + "format": "svg", + "imageData": base64.b64encode(f.read().encode()).decode("utf-8"), + "message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.", + } + if fmt == "jpg": + img = Image.open(io.BytesIO(png_bytes)) + buf = io.BytesIO() + img.convert("RGB").save(buf, format="JPEG") + image_bytes = buf.getvalue() + else: + image_bytes = png_bytes + mime_format = fmt + + # --- Package response according to responseMode --- + if response_mode == "file": + output_path = os.path.join(board_dir, f"{board_name}_2d_view.{mime_format}") + with open(output_path, "wb") as f: + f.write(image_bytes) + return { + "success": True, + "format": mime_format, + "filePath": output_path, + "message": f"2D view saved to {output_path}", + } + else: + return { + "success": True, + "format": mime_format, + "imageData": base64.b64encode(image_bytes).decode("utf-8"), + } + + except FileNotFoundError: + return { + "success": False, + "message": "kicad-cli not found in PATH", + "errorDetails": "Install KiCad and ensure kicad-cli is on PATH", + } except Exception as e: - logger.error(f"Error getting board 2D view: {str(e)}") + logger.error(f"Error getting board 2D view: {e}") return { "success": False, "message": "Failed to get board 2D view", diff --git a/python/kicad_interface.py b/python/kicad_interface.py index ed570ec..9ac4588 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -271,6 +271,64 @@ except ImportError as e: sys.exit(1) +def _svg_to_png(svg_path: str, width: int, height: int) -> Optional[bytes]: + """Convert SVG to PNG. No cffi dependency. + + Priority: + 1. pymupdf (fitz) — bundled MuPDF renderer, pure Python, no system deps + 2. Inkscape CLI — accurate KiCAD SVG rendering + 3. ImageMagick convert — broad availability fallback + Returns PNG bytes or None if all converters fail. + """ + import subprocess + import tempfile + + try: + import fitz + + doc = fitz.open(svg_path) + page = doc[0] + mat = fitz.Matrix(width / page.rect.width, height / page.rect.height) + return page.get_pixmap(matrix=mat).tobytes("png") + except Exception: + pass + + out_path = os.path.join(tempfile.mkdtemp(), "out.png") + + try: + r = subprocess.run( + [ + "inkscape", + svg_path, + "--export-type=png", + f"--export-width={width}", + f"--export-height={height}", + f"--export-filename={out_path}", + ], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + try: + r = subprocess.run( + ["convert", "-density", "150", svg_path, "-resize", f"{width}x{height}", out_path], + capture_output=True, + timeout=60, + ) + if r.returncode == 0 and os.path.exists(out_path): + with open(out_path, "rb") as f: + return f.read() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return None + + class KiCADInterface: """Main interface class to handle KiCAD operations""" @@ -2898,22 +2956,18 @@ class KiCADInterface: svg_data = f.read() return {"success": True, "imageData": svg_data, "format": "svg"} - # Step 2: Convert SVG to PNG using cairosvg - try: - from cairosvg import svg2png - except ImportError: - # Fallback: return SVG data with a note + # Step 2: Convert SVG to PNG (cffi-free) + png_data = _svg_to_png(svg_path, width, height) + if png_data is None: with open(svg_path, "r", encoding="utf-8") as f: svg_data = f.read() return { "success": True, "imageData": svg_data, "format": "svg", - "message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg", + "message": "No PNG converter available — returning SVG. Install pymupdf, inkscape, or imagemagick.", } - png_data = svg2png(url=svg_path, output_width=width, output_height=height) - return { "success": True, "imageData": base64.b64encode(png_data).decode("utf-8"), @@ -4871,16 +4925,12 @@ class KiCADInterface: svg_data = f.read() return {"success": True, "imageData": svg_data, "format": "svg"} else: - try: - from cairosvg import svg2png - except ImportError: + png_data = _svg_to_png(cropped_svg_path, width, height) + if png_data is None: return { "success": False, - "message": "PNG export requires the 'cairosvg' package. Install it with: pip install cairosvg", + "message": "No PNG converter available. Install pymupdf, inkscape, or imagemagick.", } - png_data = svg2png( - url=cropped_svg_path, output_width=width, output_height=height - ) return { "success": True, "imageData": base64.b64encode(png_data).decode("utf-8"), diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index 1da88bf..3088d61 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -229,29 +229,32 @@ BOARD_TOOLS = [ "name": "get_board_2d_view", "title": "Render Board Preview", "description": ( - "Generates a 2D visual representation of the current board state as a PNG, JPG, or SVG image. " + "Renders a 2D image of the PCB via kicad-cli (no pcbnew/cffi dependency). " + "Supports PNG, JPG, and SVG output. " "Use responseMode to control how the image is returned. " - 'responseMode="inline" (default) returns the image bytes as a base64-encoded imageData ' - "string in the JSON response — convenient for small boards but may exceed message-size limits on " - "large designs. " + 'responseMode="inline" (default) returns the image as base64-encoded imageData — ' + "convenient for small boards but may exceed message-size limits on large designs. " 'responseMode="file" writes the image next to the .kicad_pcb file as ' - "_2d_view. and returns a filePath; callers that can open local files should " - "prefer this mode for large boards." + "_2d_view. and returns a filePath; prefer this mode for large boards." ), "inputSchema": { "type": "object", "properties": { + "pcbPath": { + "type": "string", + "description": "Absolute path to the .kicad_pcb file. Falls back to the currently loaded board if omitted.", + }, "width": { "type": "number", - "description": "Image width in pixels (default: 800)", + "description": "Image width in pixels (default: 1600)", "minimum": 100, - "default": 800, + "default": 1600, }, "height": { "type": "number", - "description": "Image height in pixels (default: 600)", + "description": "Image height in pixels (default: 1200)", "minimum": 100, - "default": 600, + "default": 1200, }, "format": { "type": "string", @@ -262,7 +265,7 @@ BOARD_TOOLS = [ "layers": { "type": "array", "items": {"type": "string"}, - "description": "Optional list of layer names to include; all enabled layers if omitted", + "description": "Layer names to include, e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"]. Omit for all layers.", }, "responseMode": { "type": "string", @@ -270,7 +273,7 @@ BOARD_TOOLS = [ "default": "inline", "description": ( "How to return the image. " - '"inline" (default): base64-encoded bytes in the imageData response field. ' + '"inline" (default): base64-encoded bytes in imageData. ' '"file": write to _2d_view. next to the PCB and return filePath.' ), }, diff --git a/src/tools/board.ts b/src/tools/board.ts index db46b97..54717cf 100644 --- a/src/tools/board.ts +++ b/src/tools/board.ts @@ -363,27 +363,30 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu server.tool( "get_board_2d_view", [ - "Render a 2D image of the current PCB board and return it as PNG, JPG or SVG.", - "Use responseMode to choose how the image is delivered:", - ' "inline" (default) — base64-encoded bytes returned in imageData; works well for small boards.', + "Render a 2D image of the PCB using kicad-cli. Returns PNG, JPG, or SVG.", + "Use layers to filter — e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"] for copper + outline only.", + "Use responseMode to choose delivery:", + ' "inline" (default) — PNG/JPG rendered as an image visible to Claude; SVG returned as text.', ' "file" — image written next to the .kicad_pcb as _2d_view.; filePath is returned.', - "Use file mode for large boards to avoid hitting MCP message-size limits.", + "Use file mode for large boards to avoid MCP message-size limits.", ].join(" "), { - layers: z.array(z.string()).optional().describe("Optional array of layer names to include"), - width: z.number().optional().describe("Optional width of the image in pixels"), - height: z.number().optional().describe("Optional height of the image in pixels"), - format: z.enum(["png", "jpg", "svg"]).optional().describe("Image format"), + pcbPath: z.string().optional().describe("Absolute path to the .kicad_pcb file. Falls back to the currently loaded board if omitted."), + layers: z.array(z.string()).optional().describe("Layer names to include, e.g. [\"F.Cu\",\"B.Cu\",\"Edge.Cuts\"]. Omit for all layers."), + width: z.number().optional().describe("Output image width in pixels (default: 1600)"), + height: z.number().optional().describe("Output image height in pixels (default: 1200)"), + format: z.enum(["png", "jpg", "svg"]).optional().describe("Output format (default: png)"), responseMode: z .enum(["inline", "file"]) .optional() .describe( - 'How to return the image: "inline" (default) returns base64 imageData; "file" writes to disk and returns filePath', + '"inline" (default): image returned directly; "file": written to disk, filePath returned', ), }, - async ({ layers, width, height, format, responseMode }) => { + async ({ pcbPath, layers, width, height, format, responseMode }) => { logger.debug("Getting 2D board view"); const result = await callKicadScript("get_board_2d_view", { + pcbPath, layers, width, height, @@ -391,13 +394,39 @@ export function registerBoardTools(server: McpServer, callKicadScript: CommandFu responseMode, }); + if (result.success) { + // file mode — just return the path as text + if (responseMode === "file" || result.filePath) { + return { + content: [{ type: "text" as const, text: result.message || result.filePath }], + }; + } + // inline svg (or fallback svg) — return as text, prepend any notice + if (result.format === "svg") { + const parts: { type: "text"; text: string }[] = []; + if (result.message) parts.push({ type: "text" as const, text: result.message }); + parts.push({ type: "text" as const, text: Buffer.from(result.imageData, "base64").toString("utf-8") }); + return { content: parts }; + } + // inline png/jpg — return as renderable image + return { + content: [ + { + type: "image" as const, + data: result.imageData, + mimeType: result.format === "jpg" ? "image/jpeg" : "image/png", + }, + ], + }; + } return { content: [ { - type: "text", - text: JSON.stringify(result), + type: "text" as const, + text: `Failed to get board view: ${result.message || result.errorDetails || "Unknown error"}`, }, ], + isError: true, }; }, ); diff --git a/tests/test_get_board_2d_view_save_to_file.py b/tests/test_get_board_2d_view_save_to_file.py index 424f03f..f6d8b3e 100644 --- a/tests/test_get_board_2d_view_save_to_file.py +++ b/tests/test_get_board_2d_view_save_to_file.py @@ -9,6 +9,7 @@ Two modes are supported: """ import base64 +import subprocess import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -17,14 +18,16 @@ import pytest sys.path.insert(0, str(Path(__file__).parent.parent / "python")) - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- +_FAKE_SVG = b"" +_FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"fakepngbytes" + def _make_view_cmd(tmp_path: Path): - """Build a BoardViewCommands instance with a fake board.""" + """Build a BoardViewCommands instance with a real PCB file and optional fake board.""" from commands.board.view import BoardViewCommands board_path = tmp_path / "MyBoard.kicad_pcb" @@ -32,28 +35,24 @@ def _make_view_cmd(tmp_path: Path): fake_board = MagicMock() fake_board.GetFileName.return_value = str(board_path) - return BoardViewCommands(board=fake_board), tmp_path + return BoardViewCommands(board=fake_board), tmp_path, board_path -def _patched_plotter(temp_svg: Path): - """Context manager that stubs PLOT_CONTROLLER to return the given temp SVG path.""" - plotter = MagicMock() - plotter.GetPlotFileName.return_value = str(temp_svg) - plotter.OpenPlotfile.return_value = True - plotter.GetPlotOptions.return_value = MagicMock() - return patch("commands.board.view.pcbnew.PLOT_CONTROLLER", return_value=plotter) +def _patch_kicad_cli(svg_dir: Path): + """Stubs that make kicad-cli appear to succeed and produce an SVG in svg_dir.""" + svg_path = svg_dir / "MyBoard.svg" + svg_path.write_bytes(_FAKE_SVG) + fake_result = MagicMock() + fake_result.returncode = 0 + fake_result.stderr = "" + fake_result.stdout = "" -_FAKE_SVG = b"" -_FAKE_PNG = b"\x89PNG\r\n\x1a\n" + b"fakepngbytes" - - -def _install_cairosvg_stub(png_bytes: bytes) -> None: - """Inject a cairosvg stub into sys.modules if the real library is absent.""" - if "cairosvg" not in sys.modules: - stub = MagicMock() - stub.svg2png.return_value = png_bytes - sys.modules["cairosvg"] = stub + return ( + patch("shutil.which", return_value="/usr/bin/kicad-cli"), + patch("subprocess.run", return_value=fake_result), + patch("glob.glob", return_value=[str(svg_path)]), + ) # --------------------------------------------------------------------------- @@ -64,58 +63,48 @@ def _install_cairosvg_stub(png_bytes: bytes) -> None: @pytest.mark.unit def test_inline_png_returns_base64_image_data(tmp_path): """responseMode='inline' with format='png' returns valid base64 in imageData.""" - cmd, root = _make_view_cmd(tmp_path) - temp_svg = root / "scratch.svg" - temp_svg.write_bytes(_FAKE_SVG) - _install_cairosvg_stub(_FAKE_PNG) + cmd, root, board_path = _make_view_cmd(tmp_path) + w1, w2, w3 = _patch_kicad_cli(root) - with ( - _patched_plotter(temp_svg), - patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True), - ): - result = cmd.get_board_2d_view({"format": "png", "responseMode": "inline"}) + with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG): + result = cmd.get_board_2d_view( + {"pcbPath": str(board_path), "format": "png", "responseMode": "inline"} + ) assert result["success"] is True assert result["format"] == "png" assert "imageData" in result - decoded = base64.b64decode(result["imageData"]) - assert decoded == _FAKE_PNG - # No file should have been written for inline mode + assert base64.b64decode(result["imageData"]) == _FAKE_PNG assert not (root / "MyBoard_2d_view.png").exists() assert "filePath" not in result @pytest.mark.unit def test_inline_svg_returns_base64_image_data(tmp_path): - """responseMode='inline' with format='svg' returns valid base64 in imageData.""" - cmd, root = _make_view_cmd(tmp_path) - temp_svg = root / "scratch.svg" - temp_svg.write_bytes(_FAKE_SVG) + """responseMode='inline' with format='svg' returns base64-encoded SVG in imageData.""" + cmd, root, board_path = _make_view_cmd(tmp_path) + w1, w2, w3 = _patch_kicad_cli(root) - with _patched_plotter(temp_svg): - result = cmd.get_board_2d_view({"format": "svg", "responseMode": "inline"}) + with w1, w2, w3: + result = cmd.get_board_2d_view( + {"pcbPath": str(board_path), "format": "svg", "responseMode": "inline"} + ) assert result["success"] is True assert result["format"] == "svg" assert "imageData" in result - decoded = base64.b64decode(result["imageData"]) - assert decoded == _FAKE_SVG + assert base64.b64decode(result["imageData"]) == _FAKE_SVG assert "filePath" not in result @pytest.mark.unit def test_default_response_mode_is_inline(tmp_path): """Omitting responseMode defaults to inline behavior (imageData, no filePath).""" - cmd, root = _make_view_cmd(tmp_path) - temp_svg = root / "scratch.svg" - temp_svg.write_bytes(_FAKE_SVG) - _install_cairosvg_stub(_FAKE_PNG) + cmd, root, board_path = _make_view_cmd(tmp_path) + w1, w2, w3 = _patch_kicad_cli(root) - with ( - _patched_plotter(temp_svg), - patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True), - ): - result = cmd.get_board_2d_view({"format": "png"}) + with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG): + result = cmd.get_board_2d_view({"pcbPath": str(board_path), "format": "png"}) assert result["success"] is True assert "imageData" in result @@ -130,12 +119,13 @@ def test_default_response_mode_is_inline(tmp_path): @pytest.mark.unit def test_file_mode_svg_writes_file_and_returns_path(tmp_path): """responseMode='file' with format='svg' writes the file and returns filePath.""" - cmd, root = _make_view_cmd(tmp_path) - temp_svg = root / "scratch.svg" - temp_svg.write_bytes(_FAKE_SVG) + cmd, root, board_path = _make_view_cmd(tmp_path) + w1, w2, w3 = _patch_kicad_cli(root) - with _patched_plotter(temp_svg): - result = cmd.get_board_2d_view({"format": "svg", "responseMode": "file"}) + with w1, w2, w3: + result = cmd.get_board_2d_view( + {"pcbPath": str(board_path), "format": "svg", "responseMode": "file"} + ) assert result["success"] is True assert result["format"] == "svg" @@ -149,16 +139,13 @@ def test_file_mode_svg_writes_file_and_returns_path(tmp_path): @pytest.mark.unit def test_file_mode_png_writes_file_and_returns_path(tmp_path): """responseMode='file' with format='png' writes the PNG and returns filePath.""" - cmd, root = _make_view_cmd(tmp_path) - temp_svg = root / "scratch.svg" - temp_svg.write_bytes(_FAKE_SVG) - _install_cairosvg_stub(_FAKE_PNG) + cmd, root, board_path = _make_view_cmd(tmp_path) + w1, w2, w3 = _patch_kicad_cli(root) - with ( - _patched_plotter(temp_svg), - patch("cairosvg.svg2png", return_value=_FAKE_PNG, create=True), - ): - result = cmd.get_board_2d_view({"format": "png", "responseMode": "file"}) + with w1, w2, w3, patch("commands.board.view._svg_to_png", return_value=_FAKE_PNG): + result = cmd.get_board_2d_view( + {"pcbPath": str(board_path), "format": "png", "responseMode": "file"} + ) assert result["success"] is True assert result["format"] == "png"