feat(refs): xrefs, raster underlays, data extract and real DWG output
SECTION 24 completes at eleven tools with xref_attach, xref_manage,
image_attach, data_extract and drawing_export_dwg, on the four contract members
that genuinely differ per engine -- backends/contracts/refs.py. Everything else
in the sheet track composes the generic members and is written once.
The ActiveX members were read off the AutoCAD 2026 seat's own type library
(C:\Program Files\Common Files\Autodesk Shared\acax25enu.tlb, "AutoCAD 2025
Type Library" 1.0, the library the running 25.x seat registers) before a line of
the COM path was written, and the measurement is in the code beside the calls:
IAcadModelSpace.AttachExternalReference(PathName, Name, InsertionPoint,
Xscale, Yscale, Zscale, Rotation, bOverlay, Password)
IAcadModelSpace.AddRaster(imageFileName, InsertionPoint, ScaleFactor,
RotationAngle)
IAcadBlock: IsXRef (get), Path (get AND put -- so the repath is an assignment,
not a method), Reload(), Unload(), Bind(bPrefixName), Detach(),
XRefDatabase (get)
IAcadDocument.SaveAs(FullFileName, SaveAsType, vSecurityParams)
AcSaveAsType: ac2000_dwg=12 ac2004_dwg=24 ac2007_dwg=36 ac2010_dwg=48
ac2013_dwg=60 ac2018_dwg=64 (=acNative)
The running seat itself was not answering COM while this landed (its window was
Not Responding and GetActiveObject never returned), so the type library -- which
is the same seat's declared surface and reads without a call into the process --
is what the members are measured from; no document of the operator's was opened
or touched. This repository has been bitten four times by a fake that invented a
member, so the COM fakes in the tests model those names and nothing else.
ActiveX angles are radians, so the degree-to-radian conversion happens at that
boundary and nowhere else.
Headlessly, ezdxf.xref.attach carries the attachment (measured on ezdxf 1.4.4:
block flags 20 for an attachment and 24 for an overlay, so "is this an xref" is
flags & (BLK_XREF | BLK_XREF_OVERLAY) -- masking BLK_XREF alone misses every
overlay), add_image_def + add_image the raster, with Pillow reading the pixel
size so the placed image keeps the file's aspect.
Three capability keys, declared in both maps:
dwg_write -- measured absent here (odafc.is_installed() is False), so the
ezdxf entry is a runtime re-evaluation like viewport_render's
matplotlib check rather than a hardcoded False: installing the
ODA File Converter starts the call working with no edit to the
map. The refusal names the install route and drawing_export_dxf.
xref_live -- reload and bind need a live seat's xref manager and are refused
headlessly; list, detach and path are headless and are not
behind the key.
xlsx_write -- openpyxl is optional and absent here; CSV is always available,
so the refusal never blocks the deliverable and says so.
Two numbers the plan predicted wrongly are corrected to what the registry
actually does. `block` outranks `drawing` in _GROUP_TAG_PRIORITY, so the two
xref tools file under `blocks` (9 -> 11) beside block_insert -- which is where
an xref belongs, being a block definition that lives in another file -- and
`drawing` moves 11 -> 12, not 11 -> 14. And `data_extract` must not say "parts
list" in its own text: that is the golden discovery query for
entity_create_table, and a file writer outranking the tool that draws the thing
is a regression the tuning bar catches. It is described by its destination --
a file -- instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
de8dc8f813
commit
3ef316d520
@@ -0,0 +1,390 @@
|
||||
"""External references, raster images and DWG output on both engines.
|
||||
|
||||
The three capability keys are the point of this file: a refusal the feature map
|
||||
never mentions is a lie by omission, so every key raised here is asserted to be
|
||||
declared on both backends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import AutoCADBackend, UnsupportedCapabilityError
|
||||
from backends.com_backend import ComBackend
|
||||
from backends.contracts.refs import (
|
||||
DWG_VERSIONS,
|
||||
XREF_ACTIONS,
|
||||
XREF_KINDS,
|
||||
RefsContract,
|
||||
)
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write")
|
||||
|
||||
|
||||
def _features(backend) -> dict:
|
||||
return backend.capabilities().to_dict()["features"]
|
||||
|
||||
|
||||
def test_the_contract_is_composed_into_both_backends():
|
||||
assert issubclass(AutoCADBackend, RefsContract)
|
||||
assert issubclass(EzdxfBackend, RefsContract)
|
||||
assert issubclass(ComBackend, RefsContract)
|
||||
for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"):
|
||||
assert callable(getattr(AutoCADBackend, name, None)), name
|
||||
|
||||
|
||||
def test_the_three_keys_are_declared_in_both_capability_maps():
|
||||
ezdxf_features = _features(EzdxfBackend())
|
||||
com_features = _features(ComBackend())
|
||||
for key in TRACK_G_KEYS:
|
||||
assert key in ezdxf_features, f"{key} undeclared on ezdxf"
|
||||
assert key in com_features, f"{key} undeclared on com"
|
||||
|
||||
|
||||
def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not():
|
||||
com_features = _features(ComBackend())
|
||||
ezdxf_features = _features(EzdxfBackend())
|
||||
assert com_features["dwg_write"]["supported"] is True
|
||||
assert com_features["xref_live"]["supported"] is True
|
||||
assert ezdxf_features["xref_live"]["supported"] is False
|
||||
assert "live" in (ezdxf_features["xref_live"]["reason"] or "")
|
||||
|
||||
|
||||
def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded():
|
||||
"""The same shape as viewport_render's matplotlib check: the flag follows
|
||||
the machine, and the reason names the way to change it."""
|
||||
from ezdxf.addons import odafc
|
||||
|
||||
feature = _features(EzdxfBackend())["dwg_write"]
|
||||
assert feature["supported"] is bool(odafc.is_installed())
|
||||
if not feature["supported"]:
|
||||
assert "oda" in (feature["reason"] or "").lower()
|
||||
|
||||
|
||||
def test_xlsx_write_follows_openpyxl_on_both_engines():
|
||||
present = find_spec("openpyxl") is not None
|
||||
for backend in (EzdxfBackend(), ComBackend()):
|
||||
feature = _features(backend)["xlsx_write"]
|
||||
assert feature["supported"] is present, backend.name
|
||||
if not present:
|
||||
assert "openpyxl" in (feature["reason"] or "")
|
||||
|
||||
|
||||
def test_the_vocabularies_are_closed():
|
||||
assert XREF_KINDS == ("attach", "overlay")
|
||||
assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path")
|
||||
assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
|
||||
|
||||
|
||||
# -- ezdxf -------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def external(tmp_path: Path) -> str:
|
||||
"""A real DXF on disk for the xref to point at."""
|
||||
import ezdxf
|
||||
|
||||
doc = ezdxf.new("R2018")
|
||||
doc.modelspace().add_circle((0, 0), 5)
|
||||
target = tmp_path / "BASE.dxf"
|
||||
doc.saveas(target)
|
||||
return str(target)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_creates_an_xref_block_and_an_insert(backend, external):
|
||||
result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach")
|
||||
assert result["ok"] is True
|
||||
assert result["name"] == "BASE"
|
||||
assert result["kind"] == "attach"
|
||||
insert = await backend.entity_get(result["handle"])
|
||||
assert insert.type == "INSERT"
|
||||
assert insert.properties["block_name"] == "BASE"
|
||||
|
||||
listing = await backend.xref_manage("", "list", None)
|
||||
assert [row["name"] for row in listing["xrefs"]] == ["BASE"]
|
||||
assert listing["xrefs"][0]["kind"] == "attach"
|
||||
assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf"
|
||||
assert listing["xrefs"][0]["inserts"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overlay_is_flagged_as_an_overlay(backend, external):
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay")
|
||||
listing = await backend.xref_manage("", "list", None)
|
||||
assert listing["xrefs"][0]["kind"] == "overlay"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external):
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
assert "BASE" in str(excinfo.value)
|
||||
assert len(await backend.entity_list()) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path):
|
||||
with pytest.raises(ValueError) as missing:
|
||||
await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
assert "nope.dxf" in str(missing.value)
|
||||
with pytest.raises(ValueError) as kind:
|
||||
await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay")
|
||||
assert "underlay" in str(kind.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_path_rewrites_the_saved_path_and_detach_removes_the_block(
|
||||
backend, external, tmp_path
|
||||
):
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
moved = str(tmp_path / "moved" / "BASE.dxf")
|
||||
changed = await backend.xref_manage("BASE", "path", moved)
|
||||
assert changed["ok"] is True
|
||||
assert changed["path"] == moved
|
||||
assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved
|
||||
|
||||
detached = await backend.xref_manage("BASE", "detach", None)
|
||||
assert detached["ok"] is True and detached["inserts_removed"] == 1
|
||||
assert (await backend.xref_manage("", "list", None))["xrefs"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external):
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
for action in ("reload", "bind"):
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.xref_manage("BASE", action, None)
|
||||
assert excinfo.value.capability == "xref_live"
|
||||
assert "com" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external):
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
with pytest.raises(ValueError) as action:
|
||||
await backend.xref_manage("BASE", "unload", None)
|
||||
assert "unload" in str(action.value)
|
||||
with pytest.raises(ValueError) as name:
|
||||
await backend.xref_manage("NOPE", "detach", None)
|
||||
assert "NOPE" in str(name.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
png = tmp_path / "logo.png"
|
||||
Image.new("RGB", (800, 400), "white").save(png)
|
||||
result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0)
|
||||
assert result["ok"] is True
|
||||
assert result["pixels"] == [800, 400]
|
||||
assert result["size_mm"] == [80.0, 40.0]
|
||||
image = await backend.entity_get(result["handle"])
|
||||
assert image.type == "IMAGE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_non_image_file_is_refused_before_writing(backend, external):
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0)
|
||||
assert "BASE.dxf" in str(excinfo.value)
|
||||
assert len(await backend.entity_list()) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent(
|
||||
backend, tmp_path
|
||||
):
|
||||
from ezdxf.addons import odafc
|
||||
|
||||
if odafc.is_installed():
|
||||
pytest.skip("the ODA File Converter is installed on this machine")
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018")
|
||||
assert excinfo.value.capability == "dwg_write"
|
||||
assert "oda" in str(excinfo.value).lower()
|
||||
assert not (tmp_path / "out.dwg").exists()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14")
|
||||
message = str(excinfo.value)
|
||||
assert "R14" in message
|
||||
assert "R2018" in message
|
||||
|
||||
|
||||
# -- COM, against fakes that model the measured ActiveX members --------------
|
||||
|
||||
|
||||
def _com_backend():
|
||||
b = ComBackend()
|
||||
|
||||
async def _run(func, *args, **kwargs): # bypass the single-thread executor
|
||||
return func(*args, **kwargs)
|
||||
|
||||
b._run = _run
|
||||
return b
|
||||
|
||||
|
||||
def _point(variant):
|
||||
"""The doubles a VARIANT point carries.
|
||||
|
||||
``_apoint`` hands ActiveX a ``win32com.client.VARIANT``, and that object is
|
||||
*not* iterable -- measured here:
|
||||
``tuple(VARIANT(VT_ARRAY | VT_R8, [1.0, 2.0, 0.0]))`` raises
|
||||
``TypeError: 'VARIANT' object is not iterable``. The real ModelSpace reads
|
||||
the array behind it, so the fakes read ``.value`` rather than pretending
|
||||
the argument is a plain sequence.
|
||||
"""
|
||||
return tuple(getattr(variant, "value", variant))
|
||||
|
||||
|
||||
class _Block:
|
||||
"""Measured members of an AcadBlock that is an xref (AutoCAD 2026)."""
|
||||
|
||||
def __init__(self, name, path="", is_xref=True):
|
||||
self.Name = name
|
||||
self.Path = path
|
||||
self.IsXRef = is_xref
|
||||
self.calls = []
|
||||
|
||||
def Reload(self):
|
||||
self.calls.append("Reload")
|
||||
|
||||
def Bind(self, bPrefixName):
|
||||
self.calls.append(("Bind", bPrefixName))
|
||||
|
||||
def Detach(self):
|
||||
self.calls.append("Detach")
|
||||
|
||||
|
||||
class _Blocks:
|
||||
def __init__(self, blocks):
|
||||
self._blocks = list(blocks)
|
||||
|
||||
def Item(self, index):
|
||||
if isinstance(index, int):
|
||||
return self._blocks[index]
|
||||
for block in self._blocks:
|
||||
if block.Name == index:
|
||||
return block
|
||||
raise KeyError(index)
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self._blocks)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._blocks)
|
||||
|
||||
|
||||
class _Ref:
|
||||
def __init__(self, handle="7F"):
|
||||
self.Handle = handle
|
||||
|
||||
|
||||
class _ModelSpace:
|
||||
def __init__(self):
|
||||
self.attached = []
|
||||
self.rasters = []
|
||||
|
||||
def AttachExternalReference(
|
||||
self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay
|
||||
):
|
||||
self.attached.append(
|
||||
(PathName, Name, _point(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay)
|
||||
)
|
||||
return _Ref("A1")
|
||||
|
||||
def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle):
|
||||
self.rasters.append((imageFileName, _point(InsertionPoint), ScaleFactor, RotationAngle))
|
||||
return _Ref("B2")
|
||||
|
||||
|
||||
class _ComDoc:
|
||||
def __init__(self, blocks=()):
|
||||
self.ModelSpace = _ModelSpace()
|
||||
self.Blocks = _Blocks(blocks)
|
||||
self.saved = []
|
||||
|
||||
def SaveAs(self, FullFileName, SaveAsType):
|
||||
self.saved.append((FullFileName, SaveAsType))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external):
|
||||
import math
|
||||
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc()
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay")
|
||||
assert result["handle"] == "A1"
|
||||
path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0]
|
||||
assert name == "BASE" and overlay is True
|
||||
assert (sx, sy, sz) == (2.0, 2.0, 2.0)
|
||||
assert rotation == pytest.approx(math.pi / 2)
|
||||
assert insert[:2] == (10.0, 20.0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch):
|
||||
import backends.com_backend as cb
|
||||
|
||||
block = _Block("BASE", path="C:/refs/BASE.dwg")
|
||||
doc = _ComDoc([block])
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
b = _com_backend()
|
||||
|
||||
assert (await b.xref_manage("BASE", "reload", None))["ok"] is True
|
||||
assert (await b.xref_manage("BASE", "bind", None))["ok"] is True
|
||||
assert (await b.xref_manage("BASE", "detach", None))["ok"] is True
|
||||
assert block.calls == ["Reload", ("Bind", False), "Detach"]
|
||||
|
||||
listing = await b.xref_manage("", "list", None)
|
||||
assert listing["xrefs"][0]["name"] == "BASE"
|
||||
assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path):
|
||||
import math
|
||||
|
||||
from PIL import Image
|
||||
|
||||
import backends.com_backend as cb
|
||||
|
||||
png = tmp_path / "logo.png"
|
||||
Image.new("RGB", (400, 200), "white").save(png)
|
||||
doc = _ComDoc()
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0)
|
||||
assert result["handle"] == "B2"
|
||||
name, insert, scale, rotation = doc.ModelSpace.rasters[0]
|
||||
assert Path(name).name == "logo.png"
|
||||
assert insert[:2] == (1.0, 2.0)
|
||||
assert scale == 0.5
|
||||
assert rotation == pytest.approx(math.pi)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path):
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc()
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
b = _com_backend()
|
||||
await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018")
|
||||
await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000")
|
||||
assert [entry[1] for entry in doc.saved] == [64, 12]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Parts-list rows to CSV (always) and XLSX (with openpyxl)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
from importlib.util import find_spec
|
||||
|
||||
import pytest
|
||||
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
from engineering.sheet.extract import EXTRACT_FORMATS, write_rows, xlsx_available
|
||||
|
||||
|
||||
def _rows():
|
||||
return (
|
||||
{
|
||||
"item": 1,
|
||||
"qty": 2,
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"material": "8.8",
|
||||
"handles": ("A1", "A2"),
|
||||
},
|
||||
{
|
||||
"item": 2,
|
||||
"qty": 1,
|
||||
"designation": "SHAFT 40h6",
|
||||
"standard": "",
|
||||
"material": "C45",
|
||||
"handles": ("B1",),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_the_formats_are_closed_and_csv_is_always_one_of_them():
|
||||
assert EXTRACT_FORMATS == ("csv", "xlsx")
|
||||
|
||||
|
||||
def test_csv_writes_the_heading_row_and_one_row_per_part(tmp_path):
|
||||
target = tmp_path / "bom.csv"
|
||||
result = write_rows(_rows(), str(target), fmt="csv")
|
||||
assert result["ok"] is True and result["rows"] == 2 and result["format"] == "csv"
|
||||
with open(target, newline="", encoding="utf-8") as handle:
|
||||
written = list(csv.reader(handle))
|
||||
assert written[0] == ["POS", "QTY", "DESIGNATION", "STANDARD", "MATERIAL"]
|
||||
assert written[1] == ["1", "2", "ISO 4014 - M12x60", "ISO 4014", "8.8"]
|
||||
assert written[2][2] == "SHAFT 40h6"
|
||||
assert len(written) == 3
|
||||
|
||||
|
||||
def test_a_chosen_column_set_is_honoured(tmp_path):
|
||||
target = tmp_path / "short.csv"
|
||||
write_rows(_rows(), str(target), columns=("item", "designation"), fmt="csv")
|
||||
with open(target, newline="", encoding="utf-8") as handle:
|
||||
written = list(csv.reader(handle))
|
||||
assert written[0] == ["POS", "DESIGNATION"]
|
||||
assert all(len(row) == 2 for row in written)
|
||||
|
||||
|
||||
def test_an_unknown_format_is_refused_with_the_list(tmp_path):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
write_rows(_rows(), str(tmp_path / "x.ods"), fmt="ods")
|
||||
message = str(excinfo.value)
|
||||
assert "ods" in message and "csv" in message and "xlsx" in message
|
||||
|
||||
|
||||
def test_no_rows_is_refused_rather_than_writing_an_empty_file(tmp_path):
|
||||
target = tmp_path / "empty.csv"
|
||||
with pytest.raises(ValueError):
|
||||
write_rows((), str(target), fmt="csv")
|
||||
assert not target.exists()
|
||||
|
||||
|
||||
def test_xlsx_available_agrees_with_the_installed_package():
|
||||
assert xlsx_available() is (find_spec("openpyxl") is not None)
|
||||
|
||||
|
||||
def test_xlsx_refuses_with_the_capability_key_and_names_the_extra(tmp_path):
|
||||
if xlsx_available():
|
||||
result = write_rows(_rows(), str(tmp_path / "bom.xlsx"), fmt="xlsx")
|
||||
assert result["ok"] is True and (tmp_path / "bom.xlsx").exists()
|
||||
return
|
||||
target = tmp_path / "bom.xlsx"
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
write_rows(_rows(), str(target), fmt="xlsx")
|
||||
assert excinfo.value.capability == "xlsx_write"
|
||||
assert "openpyxl" in str(excinfo.value)
|
||||
assert "csv" in str(excinfo.value), "a refusal that does not name the way out is half a refusal"
|
||||
assert not target.exists()
|
||||
@@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping():
|
||||
|
||||
|
||||
async def test_tool_group_sizes_are_unchanged():
|
||||
"""Frozen snapshot of the surface (210 tools, 23 groups).
|
||||
"""Frozen snapshot of the surface (215 tools, 23 groups).
|
||||
|
||||
Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when
|
||||
v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and
|
||||
@@ -537,6 +537,15 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
It moved 12 -> 16 when `revision_add`, `bom_extract`, `bom_table` and
|
||||
`balloon_add` joined the same section; `bom_extract` also carries `query`,
|
||||
but `engineering` outranks it in `_GROUP_TAG_PRIORITY`.
|
||||
The section's last five tools file elsewhere on purpose: they are
|
||||
whole-drawing and entity operations, not sheet furniture, and
|
||||
`_GROUP_TAG_PRIORITY` files them where a caller would look.
|
||||
`xref_attach` and `xref_manage` carry `drawing` *and* `block`, and `block`
|
||||
outranks `drawing`, so they file under `blocks` (9 -> 11) beside
|
||||
`block_insert` -- which is where an xref belongs: it is a block definition
|
||||
that lives in another file. `image_attach` carries `create`
|
||||
(entity_creation 18 -> 19), `data_extract` carries `engineering`
|
||||
(16 -> 17) and `drawing_export_dwg` carries `drawing` alone (11 -> 12).
|
||||
Every other number here has been unchanged
|
||||
since the snapshot was taken.
|
||||
"""
|
||||
@@ -544,12 +553,12 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
assert sizes == {
|
||||
"analysis": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
"blocks": 11,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"engineering": 16,
|
||||
"entity_creation": 18,
|
||||
"drawing": 12,
|
||||
"engineering": 17,
|
||||
"entity_creation": 19,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
"environment": 22,
|
||||
@@ -566,4 +575,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 210
|
||||
assert sum(sizes.values()) == 215
|
||||
|
||||
Reference in New Issue
Block a user