Files
autocad-mcp-pro/tests/test_batch_plot.py
T
Umut CANandClaude Opus 5 f6951e2597 fix(task-18): the COM plot is foreground, by layout name, and confirmed on disk; plot_style_list reads PrinterStyleSheetPath
Three review findings against SECTION 19 on the live engine, each measured
on AutoCAD 2026 in a scratch document with the operator's BACKGROUNDPLOT=2:

- plot_style_list read Preferences.Files.PrintStyleSheetPath; the ActiveX
  member is PrinterStyleSheetPath, so the AttributeError was swallowed and
  every row came back installed=None. Renamed in the backend, the server
  docstring and the test fake (which faked the same wrong name). Live: 13
  rows, all 9 catalogue ctb files installed, four .stb files as installed.
- ComBackend.drawing_export_pdf returned ok before any file existed under
  background plotting, so batch_plot died on path.stat() with an unhandled
  FileNotFoundError and a later foreground plot raised E_FAIL. The export
  now sets BACKGROUNDPLOT=0 around PlotToFile (restored after), honours the
  method's bool and reports ok only with the file on disk (bytes included).
  batch_plot turns a backend RuntimeError or a phantom ok into a per-row
  ok:false, keeping the rows already plotted.
- With a sheet current, the Model row was that sheet plotted again: the COM
  export switched tabs only when a layout was named. layout=None now makes
  Model current (restoring the tab), and batch_plot names every layout,
  Model included, so both engines plot the tab the row is labelled with.
  Live: Model row /Title (Model) at 210x297 beside the A3 sheet, the active
  tab and BACKGROUNDPLOT restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 21:36:07 +03:00

387 lines
15 KiB
Python

"""batch_plot: every sheet to PDF, each sheet size read back from the PDF.
The composition is exercised on the headless engine end to end; the COM-only
pieces of this task (the installed ctb scan, and the plot call's shape —
foreground, by layout name, confirmed on disk) are fake-tested; the SECTION 19
tools are called directly with a minimal context.
"""
from __future__ import annotations
import types
from pathlib import Path
import pytest
from fastmcp.exceptions import ToolError
import server
from engineering.standards.papers import CTB_CATALOG, resolve_page_setup
from engineering.standards.plot import batch_plot, safe_filename
pytestmark = pytest.mark.asyncio
class _Ctx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, message):
pass
async def warning(self, message):
pass
async def report_progress(self, progress, total):
pass
async def _two_sheets(backend):
"""Exactly two paper layouts: the default tab renamed, plus one more."""
await backend.layout_rename("Layout1", "A3")
await backend.layout_create("B")
await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape"))
await backend.entity_create_line(0, 0, 100, 50)
def test_safe_filename_strips_what_a_filesystem_refuses():
assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B"
assert safe_filename("Layout1") == "Layout1"
async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, None, str(tmp_path / "out"))
assert result["ok"] is True and result["format"] == "pdf"
assert result["count"] == 2
by_layout = {row["layout"]: row for row in result["sheets"]}
assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named"
a3, b = by_layout["A3"], by_layout["B"]
assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf")
assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5
assert a3["paper"] == "ISO_A3"
assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5
assert b["paper"] == "ANSI_B"
async def test_model_is_included_only_when_named(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
names = [row["layout"] for row in result["sheets"]]
assert names == ["Model", "A3"]
model = result["sheets"][0]
assert model["ok"] and model["mediabox_mm"] is not None
assert model["paper"] is None, "model space has no sheet; the size is reported, not named"
async def test_refusals_write_nothing(backend, tmp_path):
await _two_sheets(backend)
out = tmp_path / "nothing"
with pytest.raises(ValueError, match="Nope"):
await batch_plot(backend, ["Nope"], str(out))
with pytest.raises(ValueError, match="layout"):
await batch_plot(backend, None, str(out), file_pattern="all.pdf")
with pytest.raises(ValueError, match="file_pattern"):
await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf")
with pytest.raises(ValueError, match="layouts"):
await batch_plot(backend, [], str(out))
assert not out.exists() or not any(out.iterdir())
async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(
backend, tmp_path
):
"""No page setup ever applied: the sheet still plots, at ezdxf's stored
default (A3, 420 x 297), and the row says so rather than guessing."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf")
assert [row["layout"] for row in result["sheets"]] == ["Layout1"]
row = result["sheets"][0]
assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"
async def test_model_is_named_to_the_engine_even_when_a_sheet_is_current(backend, tmp_path):
"""The Model row must be model space whichever tab is current. On COM the
export used to plot ``ActiveLayout`` for ``layout=None``, so with a sheet
current the "Model" PDF was that sheet again; batch_plot now names the
layout for every row, Model included."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
await backend.layout_set_current("A3")
seen: list[str | None] = []
original = backend.drawing_export_pdf
async def _recording(path, layout=None):
seen.append(layout)
return await original(path, layout=layout)
backend.drawing_export_pdf = _recording
result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
assert seen == ["Model", "A3"]
assert result["ok"] and result["sheets"][0]["paper"] is None
assert result["sheets"][1]["paper"] == "ISO_A3"
async def test_a_sheet_the_engine_cannot_plot_is_a_failed_row_not_a_lost_batch(backend, tmp_path):
"""COM raises RuntimeError for an AutoCAD error (E_FAIL after a stranded
background job, say). That is one row's failure; the sheets already
written stay in the report with their evidence."""
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
original = backend.drawing_export_pdf
async def _flaky(path, layout=None):
if layout == "B":
raise RuntimeError("AutoCAD COM error (-0x7ffdfff7): Unspecified error")
return await original(path, layout=layout)
backend.drawing_export_pdf = _flaky
result = await batch_plot(backend, None, str(tmp_path))
assert result["ok"] is False and result["count"] == 2
a3, b = result["sheets"]
assert a3["ok"] and a3["paper"] == "ISO_A3"
assert b == {
"layout": "B",
"ok": False,
"path": str(tmp_path / "untitled-B.pdf"),
"error": "AutoCAD COM error (-0x7ffdfff7): Unspecified error",
}
async def test_a_plot_reported_ok_without_a_file_is_a_failed_row(backend, tmp_path):
"""A background plot answers ok before any file exists; the row says so
instead of dying on ``path.stat()`` with an unhandled FileNotFoundError."""
await _two_sheets(backend)
async def _phantom(path, layout=None):
return {"ok": True, "path": path}
backend.drawing_export_pdf = _phantom
result = await batch_plot(backend, ["A3"], str(tmp_path))
assert result["ok"] is False
row = result["sheets"][0]
assert row["ok"] is False and "no file" in row["error"]
assert "bytes" not in row and "mediabox_mm" not in row
# ── the tools ───────────────────────────────────────────────────────────────
async def test_page_setup_tools_round_trip(backend):
ctx = _Ctx(backend)
# A fresh drawing already carries Layout1; renaming it keeps exactly one
# sheet, so the unfiltered listing has one row and it is the A3.
await backend.layout_rename("Layout1", "A3")
applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx)
assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3"
listed = await server.page_setup_list(ctx=ctx)
assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3"
only = await server.page_setup_list("A3", ctx=ctx)
assert only["layouts"][0]["layout"] == "A3"
async def test_page_setup_apply_tool_refuses_before_writing(backend):
ctx = _Ctx(backend)
await backend.layout_create("A3")
with pytest.raises(ToolError, match="scale"):
await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx)
with pytest.raises(ToolError, match="Model"):
await server.page_setup_apply("Model", "ISO_A3", ctx=ctx)
row = (await backend.page_setup_list("A3"))[0]
assert row["device"] == "", "nothing was written"
async def test_plot_style_list_tool(backend):
result = await server.plot_style_list(ctx=_Ctx(backend))
assert result["ok"] and result["count"] == len(CTB_CATALOG)
assert result["backend"] == "ezdxf"
async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path):
pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
await _two_sheets(backend)
with pytest.raises(ToolError):
await server.batch_plot("../../outside", ctx=_Ctx(backend))
with pytest.raises(ToolError, match="format"):
await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend))
result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend))
assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3"
# ── COM: the installed ctb scan ─────────────────────────────────────────────
@pytest.fixture
def com_backend(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
styles = tmp_path / "Plot Styles"
styles.mkdir()
(styles / "monochrome.ctb").write_bytes(b"")
(styles / "Anka.ctb").write_bytes(b"")
(styles / "readme.txt").write_bytes(b"")
app = types.SimpleNamespace(
Preferences=types.SimpleNamespace(
Files=types.SimpleNamespace(PrinterStyleSheetPath=f"{styles};C:\\does\\not\\exist")
)
)
monkeypatch.setattr(module, "_acad_app", lambda: app)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, app
async def test_com_plot_style_list_marks_installed_and_adds_local_files(com_backend):
backend, _ = com_backend
rows = await backend.plot_style_list()
by_name = {row["name"]: row for row in rows}
assert by_name["monochrome.ctb"] == {
"name": "monochrome.ctb",
"source": "catalog",
"installed": True,
}
assert by_name["acad.ctb"]["installed"] is False
assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True}
assert "readme.txt" not in by_name
async def test_com_plot_style_list_survives_an_unreachable_preferences_object(
com_backend, monkeypatch
):
backend, _ = com_backend
from backends import com_backend as module
def _boom():
raise RuntimeError("no application")
monkeypatch.setattr(module, "_acad_app", _boom)
rows = await backend.plot_style_list()
assert [row["name"] for row in rows] == list(CTB_CATALOG)
assert all(row["installed"] is None for row in rows)
# ── COM: the plot call's shape ──────────────────────────────────────────────
@pytest.fixture
def com_plot(monkeypatch):
"""A fake document whose ``Plot.PlotToFile`` records the active layout and
BACKGROUNDPLOT at the moment of the plot — the two things AutoCAD acts on
and the reviewer measured wrong: with a sheet current, ``layout=None``
plotted that sheet; with the operator's BACKGROUNDPLOT=2 the file did not
exist when the call returned."""
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
state = {
"vars": {"BACKGROUNDPLOT": 2},
"sets": [],
"active": "Layout1",
"plots": [],
"write_file": True,
"plotted": True,
}
names = ["Model", "Layout1", "Layout2"]
class _Layout:
def __init__(self, name):
self.Name = name
class _Layouts:
def Item(self, key):
for name in names:
if name.lower() == str(key).lower():
return _Layout(name)
raise KeyError(key)
class _Plot:
def PlotToFile(self, path, config):
state["plots"].append((state["active"], config, state["vars"]["BACKGROUNDPLOT"]))
if state["write_file"]:
Path(path).write_bytes(b"%PDF-1.4 fake")
return state["plotted"]
class _Doc:
Layouts = _Layouts()
Plot = _Plot()
@property
def ActiveLayout(self):
return _Layout(state["active"])
@ActiveLayout.setter
def ActiveLayout(self, layout):
state["active"] = layout.Name
def GetVariable(self, name):
return state["vars"][name]
def SetVariable(self, name, value):
state["sets"].append((name, value))
state["vars"][name] = value
monkeypatch.setattr(module, "_acad_doc", lambda: _Doc())
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, state
async def test_com_export_pdf_plots_model_space_by_name_in_the_foreground(com_plot, tmp_path):
backend, state = com_plot
out = tmp_path / "model.pdf"
result = await backend.drawing_export_pdf(str(out))
assert state["plots"] == [("Model", "DWG To PDF.pc3", 0)], (
"layout=None is model space, plotted with BACKGROUNDPLOT forced to 0"
)
assert result == {"ok": True, "path": str(out), "layout": "Model", "bytes": out.stat().st_size}
assert state["active"] == "Layout1", "the sheet that was current is current again"
assert state["vars"]["BACKGROUNDPLOT"] == 2, "the operator's setting is restored"
assert state["sets"] == [("BACKGROUNDPLOT", 0), ("BACKGROUNDPLOT", 2)]
async def test_com_export_pdf_plots_the_named_sheet_and_restores_the_tab(com_plot, tmp_path):
backend, state = com_plot
result = await backend.drawing_export_pdf(str(tmp_path / "l2.pdf"), layout="layout2")
assert [row[0] for row in state["plots"]] == ["Layout2"]
assert result["ok"] and result["layout"] == "layout2"
assert state["active"] == "Layout1"
async def test_com_export_pdf_leaves_backgroundplot_alone_when_already_foreground(
com_plot, tmp_path
):
backend, state = com_plot
state["vars"]["BACKGROUNDPLOT"] = 0
await backend.drawing_export_pdf(str(tmp_path / "m.pdf"))
assert state["sets"] == []
async def test_com_export_pdf_reports_a_refused_plot(com_plot, tmp_path):
backend, state = com_plot
state["plotted"] = False
state["write_file"] = False
result = await backend.drawing_export_pdf(str(tmp_path / "no.pdf"), layout="Layout1")
assert result["ok"] is False and "PlotToFile returned False" in result["error"]
assert state["vars"]["BACKGROUNDPLOT"] == 2
async def test_com_export_pdf_reports_a_plot_that_left_no_file(com_plot, tmp_path):
"""PlotToFile answering True with nothing on disk is the background-plot
signature; it is a failure, not an ok."""
backend, state = com_plot
state["write_file"] = False
result = await backend.drawing_export_pdf(str(tmp_path / "gone.pdf"))
assert result["ok"] is False and "wrote no file" in result["error"]
assert "bytes" not in result