merge: tracks B+G group G
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
@@ -40,3 +40,31 @@ async def backend():
|
||||
await b.drawing_new()
|
||||
yield b
|
||||
await b.disconnect()
|
||||
|
||||
|
||||
class WithoutPrivateState:
|
||||
"""The real backend with its ezdxf-only private attributes taken away.
|
||||
|
||||
``EzdxfBackend`` tracks the current tab in ``self._current_space``;
|
||||
``ComBackend`` does not -- the live tab lives in AutoCAD
|
||||
(``doc.ActiveLayout``), so there is nothing of that name on it. Shared code
|
||||
that reaches for the private attribute therefore silently takes the
|
||||
``getattr`` default on every live seat, and no ezdxf-backed test can see
|
||||
it. Wrapping the real backend (rather than writing a fake that owns members
|
||||
the real object never had) keeps every other call honest.
|
||||
"""
|
||||
|
||||
def __init__(self, inner, hide=("_current_space",)):
|
||||
object.__setattr__(self, "_inner", inner)
|
||||
object.__setattr__(self, "_hidden", frozenset(hide))
|
||||
|
||||
def __getattr__(self, name):
|
||||
if name in self._hidden:
|
||||
raise AttributeError(f"{type(self._inner).__name__} has no attribute {name!r}")
|
||||
return getattr(self._inner, name)
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def backend_without_private_state(backend):
|
||||
"""`backend`, minus the private attributes only the headless engine has."""
|
||||
return WithoutPrivateState(backend)
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
"""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_an_xref_shown_on_a_sheet_is_counted_and_detached_with_it(backend, external):
|
||||
"""Model space is not the drawing.
|
||||
|
||||
An xref is most often *shown* on a paper-space sheet. Counting or deleting
|
||||
over `doc.modelspace()` alone reported 1 insert where the drawing held 2
|
||||
and then removed the definition with `safe=False`, leaving the sheet's
|
||||
INSERT with no BLOCK behind it -- which ezdxf's own recover reports as
|
||||
`FIX 103 UNDEFINED_BLOCK` and throws the geometry away.
|
||||
"""
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
doc = backend._doc
|
||||
doc.layout("Layout1").add_blockref("BASE", (10, 10))
|
||||
|
||||
listing = await backend.xref_manage("", "list", None)
|
||||
assert listing["xrefs"][0]["inserts"] == 2
|
||||
|
||||
detached = await backend.xref_manage("BASE", "detach", None)
|
||||
assert detached["inserts_removed"] == 2
|
||||
assert "BASE" not in doc.blocks
|
||||
assert [e for e in doc.layout("Layout1") if e.dxftype() == "INSERT"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detach_leaves_a_drawing_that_survives_a_round_trip(backend, external, tmp_path):
|
||||
"""The evidence a caller actually cares about: the saved file audits clean."""
|
||||
import ezdxf.recover
|
||||
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
backend._doc.layout("Layout1").add_blockref("BASE", (10, 10))
|
||||
await backend.xref_manage("BASE", "detach", None)
|
||||
|
||||
out = tmp_path / "detached.dxf"
|
||||
backend._doc.saveas(out)
|
||||
_, auditor = ezdxf.recover.readfile(str(out))
|
||||
assert [f.message for f in auditor.fixes] == []
|
||||
assert [e.message for e in auditor.errors] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_definition_is_dropped_with_the_in_use_check_on(backend, external):
|
||||
"""`safe=True` is the seatbelt, not decoration.
|
||||
|
||||
The broken version passed `safe=False`, the flag that *suppresses* the
|
||||
in-use check, so a missed insert became a corrupt file instead of an
|
||||
error. This pins the check: with the model-space insert gone but the
|
||||
sheet's still there -- exactly the state the old victim scan produced --
|
||||
`delete_block` refuses.
|
||||
"""
|
||||
from ezdxf.lldxf.const import DXFBlockInUseError
|
||||
|
||||
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
doc = backend._doc
|
||||
doc.layout("Layout1").add_blockref("BASE", (10, 10))
|
||||
msp = doc.modelspace()
|
||||
for entity in [e for e in msp if e.dxftype() == "INSERT"]:
|
||||
msp.delete_entity(entity)
|
||||
|
||||
with pytest.raises(DXFBlockInUseError):
|
||||
doc.blocks.delete_block("BASE", safe=True)
|
||||
assert "BASE" in doc.blocks
|
||||
|
||||
|
||||
@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_list_reports_kind_as_unknown_rather_than_inventing_attach(monkeypatch):
|
||||
"""IAcadBlock has no overlay indicator.
|
||||
|
||||
Measured from the seat's own registered type library (acax25*.tlb,
|
||||
"AutoCAD 2025 Type Library", installed with AutoCAD 2026): the xref
|
||||
members are IsXRef, Path, Name, Reload, Unload, Bind, Detach,
|
||||
XRefDatabase -- nothing says attach-vs-overlay. The fake therefore has no
|
||||
such member either (a fake that models one enshrines the bug), and the
|
||||
honest row says `None`, the way `inserts` already does.
|
||||
"""
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc([_Block("BASE", path="C:/refs/BASE.dwg")])
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
row = (await _com_backend().xref_manage("", "list", None))["xrefs"][0]
|
||||
assert row["kind"] is None
|
||||
assert row["inserts"] is None
|
||||
for invented in ("IsOverlay", "Overlay", "XRefType"):
|
||||
assert not hasattr(doc.Blocks.Item("BASE"), invented)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_attach_refuses_a_name_the_drawing_already_holds(monkeypatch, external):
|
||||
"""The refusal the contract and the tool docstring both promise.
|
||||
|
||||
It has to be on this engine too, and it has to fire *before*
|
||||
AttachExternalReference is dispatched -- a second attach under a held name
|
||||
re-points or half-writes the definition while the payload still claims the
|
||||
new path was attached.
|
||||
"""
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc([_Block("BASE", path="C:/other/BASE.dwg")])
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
assert "BASE" in str(excinfo.value)
|
||||
assert "detach" in str(excinfo.value)
|
||||
assert doc.ModelSpace.attached == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_attach_refuses_a_plain_block_of_the_same_name_case_insensitively(
|
||||
monkeypatch, external
|
||||
):
|
||||
"""AutoCAD symbol-table names are case-insensitive and a collision is a
|
||||
collision whether or not the block in the way is itself an xref."""
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc([_Block("Base", is_xref=False)])
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
with pytest.raises(ValueError):
|
||||
await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
assert doc.ModelSpace.attached == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypatch, external):
|
||||
import backends.com_backend as cb
|
||||
|
||||
doc = _ComDoc([_Block("OTHER", is_xref=True)])
|
||||
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
|
||||
result = await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
|
||||
assert result["ok"] is True and result["name"] == "BASE"
|
||||
assert len(doc.ModelSpace.attached) == 1
|
||||
|
||||
|
||||
@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,389 @@
|
||||
"""ISO 7573 parts lists and ISO 6433 item references (balloons)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.primitives import Circle, Line, Text
|
||||
from engineering.sheet.bom import (
|
||||
COLUMN_LABELS,
|
||||
DEFAULT_COLUMNS,
|
||||
LIST_WIDTH,
|
||||
ROW_HEIGHT,
|
||||
balloon_prims,
|
||||
rows_from_records,
|
||||
table_layout,
|
||||
table_prims,
|
||||
)
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def _records():
|
||||
return [
|
||||
{
|
||||
"handle": "A1",
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"material": "8.8",
|
||||
"qty": 1,
|
||||
},
|
||||
{
|
||||
"handle": "A2",
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"material": "8.8",
|
||||
"qty": 1,
|
||||
},
|
||||
{
|
||||
"handle": "B1",
|
||||
"designation": "SHAFT 40h6",
|
||||
"standard": "",
|
||||
"material": "C45",
|
||||
"qty": 1,
|
||||
},
|
||||
{
|
||||
"handle": "C1",
|
||||
"designation": "ISO 4032 - M12",
|
||||
"standard": "ISO 4032",
|
||||
"material": "8",
|
||||
"qty": 1,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_default_columns_are_the_iso_7573_minimum_plus_material():
|
||||
assert DEFAULT_COLUMNS == ("item", "qty", "designation", "standard", "material")
|
||||
for column in DEFAULT_COLUMNS:
|
||||
assert column in COLUMN_LABELS
|
||||
|
||||
|
||||
def test_rows_group_by_designation_sum_the_quantity_and_number_from_one():
|
||||
rows = rows_from_records(_records())
|
||||
assert [r["item"] for r in rows] == [1, 2, 3]
|
||||
by_designation = {r["designation"]: r for r in rows}
|
||||
assert by_designation["ISO 4014 - M12x60"]["qty"] == 2
|
||||
assert by_designation["ISO 4014 - M12x60"]["handles"] == ("A1", "A2")
|
||||
assert by_designation["SHAFT 40h6"]["qty"] == 1
|
||||
assert set(rows[0]) >= set(DEFAULT_COLUMNS) | {"handles"}
|
||||
|
||||
|
||||
def test_rows_are_ordered_deterministically_whatever_order_they_arrive_in():
|
||||
forward = rows_from_records(_records())
|
||||
backward = rows_from_records(list(reversed(_records())))
|
||||
assert [r["designation"] for r in forward] == [r["designation"] for r in backward]
|
||||
assert [r["item"] for r in forward] == [r["item"] for r in backward]
|
||||
|
||||
|
||||
def test_group_by_none_keeps_one_row_per_insert():
|
||||
rows = rows_from_records(_records(), group_by=None)
|
||||
assert [r["item"] for r in rows] == [1, 2, 3, 4]
|
||||
assert all(r["qty"] == 1 for r in rows)
|
||||
|
||||
|
||||
def test_an_unknown_column_is_refused_with_the_list():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
rows_from_records(_records(), columns=("item", "colour"))
|
||||
message = str(excinfo.value)
|
||||
assert "colour" in message
|
||||
assert "designation" in message
|
||||
|
||||
|
||||
def test_the_list_is_the_title_block_width_and_the_default_shares_sum_to_it():
|
||||
assert LIST_WIDTH == 180.0
|
||||
assert ROW_HEIGHT == 7.0
|
||||
layout = table_layout(rows_from_records(_records()), at=(230.0, 70.0))
|
||||
assert layout["width"] == pytest.approx(180.0, abs=EPS)
|
||||
assert sum(layout["widths"]) == pytest.approx(180.0, abs=EPS)
|
||||
assert layout["height"] == pytest.approx(4 * 7.0, abs=EPS) # 3 items + the heading
|
||||
|
||||
|
||||
def test_direction_up_puts_the_heading_at_the_bottom_and_item_1_above_it():
|
||||
rows = rows_from_records(_records())
|
||||
layout = table_layout(rows, at=(230.0, 70.0), direction="up")
|
||||
assert layout["cells"][-1][0] == COLUMN_LABELS["item"]
|
||||
assert layout["cells"][-2][0] == "1"
|
||||
assert layout["cells"][0][0] == "3"
|
||||
# the table is drawn downwards from its insertion point, so the insertion
|
||||
# sits a full table height above the anchor and the list grows upwards
|
||||
assert layout["insert"] == (230.0, 70.0 + 4 * 7.0)
|
||||
|
||||
|
||||
def test_direction_down_puts_the_heading_at_the_top():
|
||||
rows = rows_from_records(_records())
|
||||
layout = table_layout(rows, at=(230.0, 200.0), direction="down")
|
||||
assert layout["cells"][0][0] == COLUMN_LABELS["item"]
|
||||
assert layout["cells"][1][0] == "1"
|
||||
assert layout["insert"] == (230.0, 200.0)
|
||||
|
||||
|
||||
def test_an_unknown_direction_is_refused():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
table_layout(rows_from_records(_records()), at=(0.0, 0.0), direction="sideways")
|
||||
assert "sideways" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_table_prims_draw_the_same_rectangle_the_layout_declares():
|
||||
rows = rows_from_records(_records())
|
||||
layout = table_layout(rows, at=(230.0, 70.0))
|
||||
prims = table_prims(rows, at=(230.0, 70.0))
|
||||
xs = [p[0] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)]
|
||||
ys = [p[1] for prim in prims if isinstance(prim, Line) for p in (prim.p1, prim.p2)]
|
||||
assert min(xs) == pytest.approx(230.0, abs=EPS)
|
||||
assert max(xs) == pytest.approx(230.0 + layout["width"], abs=EPS)
|
||||
assert min(ys) == pytest.approx(70.0, abs=EPS)
|
||||
assert max(ys) == pytest.approx(70.0 + layout["height"], abs=EPS)
|
||||
assert any(isinstance(p, Text) and p.text == "ISO 4014 - M12x60" for p in prims)
|
||||
|
||||
|
||||
def test_an_unknown_parts_list_standard_is_refused_by_name():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
table_prims(rows_from_records(_records()), at=(0.0, 0.0), standard="ASME Y14.34")
|
||||
message = str(excinfo.value)
|
||||
assert "ASME Y14.34" in message
|
||||
assert "ISO 7573" in message
|
||||
|
||||
|
||||
def test_a_balloon_is_a_circle_a_leader_a_dot_and_the_numeral():
|
||||
prims = balloon_prims(7, (100.0, 100.0), (80.0, 90.0))
|
||||
circles = [p for p in prims if isinstance(p, Circle)]
|
||||
lines = [p for p in prims if isinstance(p, Line)]
|
||||
texts = [p for p in prims if isinstance(p, Text)]
|
||||
assert len(circles) == 2 and len(lines) == 1 and len(texts) == 1
|
||||
balloon = max(circles, key=lambda c: c.radius)
|
||||
dot = min(circles, key=lambda c: c.radius)
|
||||
assert balloon.center == (100.0, 100.0) and balloon.radius == 4.0
|
||||
assert dot.center == (80.0, 90.0)
|
||||
assert texts[0].text == "7"
|
||||
# ISO 6433: the leader starts on the balloon's boundary and ends on the item
|
||||
start_distance = ((lines[0].p1[0] - 100.0) ** 2 + (lines[0].p1[1] - 100.0) ** 2) ** 0.5
|
||||
assert start_distance == pytest.approx(4.0, abs=1e-9)
|
||||
assert lines[0].p2 == (80.0, 90.0)
|
||||
|
||||
|
||||
def test_a_leader_target_inside_the_balloon_is_refused():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
balloon_prims(3, (100.0, 100.0), (101.0, 100.0))
|
||||
assert "inside" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_non_positive_item_number_is_refused():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
balloon_prims(0, (0.0, 0.0), (20.0, 0.0))
|
||||
assert "0" in str(excinfo.value)
|
||||
|
||||
|
||||
# ── reading and drawing ─────────────────────────────────────────────────────
|
||||
|
||||
from engineering.mech.xdata import APP_ID, to_values # noqa: E402
|
||||
from engineering.sheet.bom import ( # noqa: E402
|
||||
add_balloon,
|
||||
draw_bom_table,
|
||||
extract_records,
|
||||
read_balloons,
|
||||
)
|
||||
|
||||
|
||||
async def _place_bolt(backend, tag: str, x: float) -> str:
|
||||
await backend.block_define(
|
||||
"ISO4014_M12",
|
||||
[{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 6.0}],
|
||||
[{"tag": "DESIGNATION", "x": 0.0, "y": -10.0, "height": 2.5, "default": ""}],
|
||||
overwrite=True,
|
||||
)
|
||||
insert = await backend.block_insert(
|
||||
"ISO4014_M12", x, 0.0, attributes={"DESIGNATION": "ISO 4014 - M12x60"}
|
||||
)
|
||||
await backend.entity_set_xdata(
|
||||
insert.handle,
|
||||
APP_ID,
|
||||
to_values(
|
||||
{
|
||||
"v": 1,
|
||||
"kind": "std_part",
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"size": "M12x60",
|
||||
"qty": 1,
|
||||
"material": "8.8",
|
||||
}
|
||||
),
|
||||
)
|
||||
return insert.handle
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reads_the_xdata_payload_and_never_touches_the_drawing(backend):
|
||||
handles = [await _place_bolt(backend, "a", 0.0), await _place_bolt(backend, "b", 50.0)]
|
||||
before = len(await backend.entity_list())
|
||||
records = await extract_records(backend)
|
||||
assert len(await backend.entity_list()) == before, "bom_extract must not modify the drawing"
|
||||
assert {r["handle"] for r in records} == set(handles)
|
||||
assert all(r["designation"] == "ISO 4014 - M12x60" for r in records)
|
||||
assert all(r["standard"] == "ISO 4014" for r in records)
|
||||
assert all(r["source"] == "xdata" for r in records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_falls_back_to_the_block_attributes_when_there_is_no_payload(backend):
|
||||
await backend.block_define(
|
||||
"PLATE",
|
||||
[{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}],
|
||||
[{"tag": "DESIGNATION", "x": 0.0, "y": -6.0, "height": 2.5, "default": ""}],
|
||||
overwrite=True,
|
||||
)
|
||||
await backend.block_insert("PLATE", 10.0, 10.0, attributes={"DESIGNATION": "PLATE 10"})
|
||||
records = await extract_records(backend)
|
||||
plate = next(r for r in records if r["designation"] == "PLATE 10")
|
||||
assert plate["source"] == "attributes"
|
||||
assert plate["standard"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_table_is_a_real_table_entity_sitting_on_its_anchor(backend):
|
||||
await _place_bolt(backend, "a", 0.0)
|
||||
rows = rows_from_records(await extract_records(backend))
|
||||
result = await draw_bom_table(backend, rows, at=(230.0, 70.0), direction="up")
|
||||
assert result["ok"] is True
|
||||
assert result["rows"] == 1
|
||||
assert result["insert"] == (230.0, 70.0 + 2 * 7.0)
|
||||
assert result["bbox"] == {"min": [230.0, 70.0], "max": [410.0, 84.0]}
|
||||
# Measured: `entity_create_table` returns EntityInfo(type="TABLE") whose
|
||||
# `handle` is the FIRST CHILD of a composite on the headless engine -- so
|
||||
# `entity_get(handle)` reads back a rule LINE, never an ACAD_TABLE. The
|
||||
# contract is pinned by tests/test_table_leader.py; `representation` says
|
||||
# which of the two renderings a caller got.
|
||||
assert result["entity_type"] == "TABLE"
|
||||
assert result["representation"] == "composite"
|
||||
assert result["handle"] in result["child_handles"]
|
||||
table = await backend.entity_get(result["handle"])
|
||||
assert table.layer == "TEXT"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_balloon_records_its_targets_and_a_rerun_renumbers_instead_of_duplicating(backend):
|
||||
target = await _place_bolt(backend, "a", 0.0)
|
||||
first = await add_balloon(
|
||||
backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,)
|
||||
)
|
||||
assert first["renumbered"] is False
|
||||
circles_after_first = len(await backend.entity_list(type_filter="CIRCLE"))
|
||||
|
||||
second = await add_balloon(
|
||||
backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(target,)
|
||||
)
|
||||
assert second["renumbered"] is True
|
||||
assert second["handle"] == first["handle"]
|
||||
assert len(await backend.entity_list(type_filter="CIRCLE")) == circles_after_first
|
||||
|
||||
balloons = await read_balloons(backend)
|
||||
assert len(balloons) == 1
|
||||
assert balloons[0]["item"] == 5
|
||||
assert balloons[0]["targets"] == [target]
|
||||
numeral = await backend.entity_get(balloons[0]["text"])
|
||||
assert numeral.properties["text"] == "5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reusing_an_item_number_for_different_targets_is_refused(backend):
|
||||
one = await _place_bolt(backend, "a", 0.0)
|
||||
two = await _place_bolt(backend, "b", 60.0)
|
||||
await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=(one,))
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await add_balloon(backend, item=1, at=(100.0, 40.0), leader_to=(60.0, 0.0), targets=(two,))
|
||||
assert "1" in str(excinfo.value)
|
||||
assert len(await backend.entity_list()) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_second_balloon_with_the_same_number_and_no_targets_is_refused(backend):
|
||||
"""`targets` is optional in the tool, so the clash test used to compare
|
||||
`() != ()` -- False -- and never fire. Two balloons carrying item 1."""
|
||||
first = await add_balloon(backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0))
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
await add_balloon(backend, item=1, at=(90.0, 40.0), leader_to=(60.0, 0.0))
|
||||
assert first["handle"] in str(excinfo.value)
|
||||
assert len(await backend.entity_list()) == before
|
||||
assert [b["item"] for b in await read_balloons(backend)] == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_balloon_guards_read_past_the_entity_list_page(backend):
|
||||
"""A routine hole pattern fills `entity_list`'s 200-entity default, which
|
||||
used to make `read_balloons` return nothing and every guard pass."""
|
||||
for index in range(200):
|
||||
await backend.entity_create_circle(float(index) * 10.0, 0.0, 2.0)
|
||||
await add_balloon(backend, item=1, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",))
|
||||
assert [b["item"] for b in await read_balloons(backend)] == [1]
|
||||
again = await add_balloon(
|
||||
backend, item=9, at=(40.0, 400.0), leader_to=(0.0, 380.0), targets=("AAA",)
|
||||
)
|
||||
assert again["renumbered"] is True, "the guard must see a balloon past the 200th circle"
|
||||
with pytest.raises(ValueError):
|
||||
await add_balloon(
|
||||
backend, item=9, at=(200.0, 400.0), leader_to=(160.0, 380.0), targets=("BBB",)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_reads_every_insert_not_the_first_page(backend):
|
||||
"""250 bolts must report QTY 250. The 200-entity `entity_list` default used
|
||||
to print 200 into a real TABLE with nothing saying it had been cut."""
|
||||
await backend.block_define("BOLT", [{"type": "circle", "cx": 0.0, "cy": 0.0, "r": 3.0}], [])
|
||||
for index in range(250):
|
||||
insert = await backend.block_insert("BOLT", float(index) * 10.0, 0.0)
|
||||
await backend.entity_set_xdata(
|
||||
insert.handle,
|
||||
APP_ID,
|
||||
to_values(
|
||||
{
|
||||
"v": 1,
|
||||
"kind": "std_part",
|
||||
"designation": "ISO 4014 - M12x60",
|
||||
"standard": "ISO 4014",
|
||||
"size": "M12x60",
|
||||
"qty": 1,
|
||||
"material": "8.8",
|
||||
}
|
||||
),
|
||||
)
|
||||
records = await extract_records(backend)
|
||||
assert len(records) == 250
|
||||
rows = rows_from_records(records)
|
||||
assert [(r["designation"], r["qty"]) for r in rows] == [("ISO 4014 - M12x60", 250)]
|
||||
# A cap is still honoured when one is asked for, explicitly.
|
||||
assert len(await extract_records(backend, limit=17)) == 17
|
||||
with pytest.raises(ValueError):
|
||||
await extract_records(backend, limit=0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_balloon_guards_read_the_layout_they_draw_on(backend):
|
||||
"""`read_balloons` follows the current space, so reading it before
|
||||
`enter_layout` inspected Model while the balloons went on the layout."""
|
||||
await backend.layout_create("SHEET1")
|
||||
first = await add_balloon(
|
||||
backend, item=1, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1"
|
||||
)
|
||||
again = await add_balloon(
|
||||
backend, item=5, at=(40.0, 40.0), leader_to=(0.0, 0.0), targets=("AAA",), layout="SHEET1"
|
||||
)
|
||||
assert again["renumbered"] is True
|
||||
assert again["handle"] == first["handle"]
|
||||
with pytest.raises(ValueError):
|
||||
await add_balloon(
|
||||
backend,
|
||||
item=5,
|
||||
at=(90.0, 40.0),
|
||||
leader_to=(60.0, 0.0),
|
||||
targets=("BBB",),
|
||||
layout="SHEET1",
|
||||
)
|
||||
assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back"
|
||||
await backend.layout_set_current("SHEET1")
|
||||
try:
|
||||
assert len(await backend.entity_list(type_filter="CIRCLE", limit=10_000)) == 2
|
||||
finally:
|
||||
await backend.layout_set_current("Model")
|
||||
@@ -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()
|
||||
@@ -0,0 +1,301 @@
|
||||
"""ISO 5457 sheet frames: sizes, margins, the zone grid, centring and trim marks.
|
||||
|
||||
The frame is the one thing every drawing this server makes carries, so a wrong
|
||||
margin or a wrong division count is visible on all of them. Every number
|
||||
asserted here is transcribed in `engineering/sheet/frames.py` with its clause.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.sheet.frames import (
|
||||
CENTRING_OVERSHOOT,
|
||||
EDGE_MARGIN,
|
||||
FILING_MARGIN,
|
||||
SHEETS,
|
||||
TRIM_MARK_LONG,
|
||||
TRIM_MARK_SHORT,
|
||||
ZONE_DIVISIONS,
|
||||
ZONE_MODULE,
|
||||
frame_metrics,
|
||||
sheet_size,
|
||||
zone_divisions,
|
||||
)
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def test_sheet_sizes_are_the_iso_216_a_series():
|
||||
assert SHEETS == {
|
||||
"A4": (210.0, 297.0),
|
||||
"A3": (297.0, 420.0),
|
||||
"A2": (420.0, 594.0),
|
||||
"A1": (594.0, 841.0),
|
||||
"A0": (841.0, 1189.0),
|
||||
}
|
||||
|
||||
|
||||
def test_sheet_sizes_agree_with_the_page_setup_table():
|
||||
"""Two tables of one standard in one repository is one too many unless
|
||||
they are pinned to each other."""
|
||||
from engineering.standards.papers import PAPER_SIZES
|
||||
|
||||
for name, (w, h) in SHEETS.items():
|
||||
assert PAPER_SIZES[f"ISO_{name}"] == (int(w), int(h))
|
||||
|
||||
|
||||
def test_landscape_swaps_the_portrait_pair():
|
||||
assert sheet_size("A3", "landscape") == (420.0, 297.0)
|
||||
assert sheet_size("A3", "portrait") == (297.0, 420.0)
|
||||
assert sheet_size("a3") == (420.0, 297.0)
|
||||
|
||||
|
||||
def test_an_unknown_sheet_is_refused_by_name_with_the_coverage():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
sheet_size("A5")
|
||||
message = str(excinfo.value)
|
||||
assert "A5" in message
|
||||
for name in SHEETS:
|
||||
assert name in message
|
||||
|
||||
|
||||
def test_an_unknown_orientation_is_refused():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
sheet_size("A3", "diagonal")
|
||||
assert "diagonal" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_margins_are_the_iso_5457_filing_20_and_edge_10():
|
||||
assert (FILING_MARGIN, EDGE_MARGIN) == (20.0, 10.0)
|
||||
m = frame_metrics("A3")
|
||||
assert m["margins"] == {"left": 20.0, "right": 10.0, "top": 10.0, "bottom": 10.0}
|
||||
assert m["frame"] == [20.0, 10.0, 410.0, 287.0]
|
||||
assert m["drawing_area"] == [390.0, 277.0]
|
||||
assert m["centre"] == [210.0, 148.5]
|
||||
|
||||
|
||||
def test_a4_portrait_drawing_area_is_exactly_the_titleblock_width():
|
||||
"""ISO 7200's 180 mm title block has to fit the smallest sheet."""
|
||||
m = frame_metrics("A4", orientation="portrait")
|
||||
assert m["drawing_area"][0] == pytest.approx(180.0, abs=EPS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("size", "orientation", "columns", "rows"),
|
||||
[
|
||||
("A4", "portrait", 4, 6),
|
||||
("A4", "landscape", 6, 4),
|
||||
("A3", "landscape", 8, 6),
|
||||
("A3", "portrait", 6, 8),
|
||||
("A2", "landscape", 12, 8),
|
||||
("A2", "portrait", 8, 12),
|
||||
("A1", "landscape", 16, 12),
|
||||
("A0", "landscape", 24, 16),
|
||||
("A0", "portrait", 16, 24),
|
||||
],
|
||||
)
|
||||
def test_zone_division_counts_match_the_authored_table(size, orientation, columns, rows):
|
||||
z = frame_metrics(size, orientation=orientation)["zones"]
|
||||
assert (z["columns"], z["rows"]) == (columns, rows)
|
||||
long_side, short_side = ZONE_DIVISIONS[size]
|
||||
if orientation == "landscape":
|
||||
assert (z["columns"], z["rows"]) == (long_side, short_side)
|
||||
else:
|
||||
assert (z["columns"], z["rows"]) == (short_side, long_side)
|
||||
|
||||
|
||||
def test_zone_divisions_follow_the_even_rule_over_50_mm():
|
||||
assert ZONE_MODULE == 50.0
|
||||
assert zone_divisions(390.0) == 8 # 7.80 -> 8
|
||||
assert zone_divisions(277.0) == 6 # 5.54 -> 6
|
||||
assert zone_divisions(564.0) == 12 # 11.28 -> 12
|
||||
assert zone_divisions(1159.0) == 24 # 23.18 -> 24
|
||||
assert zone_divisions(821.0) == 16 # 16.42 -> 16
|
||||
|
||||
|
||||
def test_zone_labels_cover_every_cell_and_letters_start_at_the_top():
|
||||
z = frame_metrics("A0")["zones"]
|
||||
assert z["numbers"][0] == "1" and z["numbers"][-1] == "24"
|
||||
assert len(z["numbers"]) == 24
|
||||
assert z["letters"][0] == "A" and z["letters"][-1] == "P"
|
||||
assert len(z["letters"]) == 16
|
||||
assert z["column_width"] == pytest.approx(1159.0 / 24.0, abs=EPS)
|
||||
assert z["row_height"] == pytest.approx(821.0 / 16.0, abs=EPS)
|
||||
|
||||
|
||||
def test_the_mark_constants_are_the_standard_s():
|
||||
assert CENTRING_OVERSHOOT == 5.0
|
||||
assert (TRIM_MARK_LONG, TRIM_MARK_SHORT) == (10.0, 5.0)
|
||||
|
||||
|
||||
# ── primitives ──────────────────────────────────────────────────────────────
|
||||
|
||||
from engineering.mech.primitives import Line, Poly, Text # noqa: E402
|
||||
from engineering.sheet.frames import frame_prims # noqa: E402
|
||||
|
||||
|
||||
def _polys(prims):
|
||||
return [p for p in prims if isinstance(p, Poly)]
|
||||
|
||||
|
||||
def _lines(prims):
|
||||
return [p for p in prims if isinstance(p, Line)]
|
||||
|
||||
|
||||
def _texts(prims):
|
||||
return [p for p in prims if isinstance(p, Text)]
|
||||
|
||||
|
||||
def test_the_first_two_primitives_are_the_trimmed_sheet_and_the_frame():
|
||||
prims = frame_prims("A3", zones=False, marks=False)
|
||||
assert len(prims) == 2
|
||||
sheet, frame = prims
|
||||
assert isinstance(sheet, Poly) and sheet.closed and sheet.role == "visible"
|
||||
assert sheet.points == ((0.0, 0.0), (420.0, 0.0), (420.0, 297.0), (0.0, 297.0))
|
||||
assert frame.points == ((20.0, 10.0), (410.0, 10.0), (410.0, 287.0), (20.0, 287.0))
|
||||
|
||||
|
||||
def test_the_zone_grid_has_a_tick_pair_per_interior_boundary_and_a_label_pair_per_cell():
|
||||
prims = frame_prims("A3", zones=True, marks=False)
|
||||
# 7 interior column boundaries x 2 edges + 5 interior row boundaries x 2 edges
|
||||
assert len(_lines(prims)) == (8 - 1) * 2 + (6 - 1) * 2
|
||||
# 8 columns x 2 edges + 6 rows x 2 edges
|
||||
assert len(_texts(prims)) == 8 * 2 + 6 * 2
|
||||
assert {t.text for t in _texts(prims)} == {
|
||||
"1",
|
||||
"2",
|
||||
"3",
|
||||
"4",
|
||||
"5",
|
||||
"6",
|
||||
"7",
|
||||
"8",
|
||||
"A",
|
||||
"B",
|
||||
"C",
|
||||
"D",
|
||||
"E",
|
||||
"F",
|
||||
}
|
||||
|
||||
|
||||
def test_zone_ticks_span_the_margin_band_only():
|
||||
prims = frame_prims("A3", zones=True, marks=False)
|
||||
for line in [ln for ln in _lines(prims) if ln.p1[0] == ln.p2[0]]:
|
||||
ys = sorted((line.p1[1], line.p2[1]))
|
||||
assert ys in ([0.0, 10.0], [287.0, 297.0]), f"tick {line} leaves the margin band"
|
||||
for line in [ln for ln in _lines(prims) if ln.p1[1] == ln.p2[1]]:
|
||||
xs = sorted((line.p1[0], line.p2[0]))
|
||||
assert xs in ([0.0, 20.0], [410.0, 420.0]), f"tick {line} leaves the margin band"
|
||||
|
||||
|
||||
def test_the_first_zone_letter_is_at_the_top_of_the_sheet():
|
||||
prims = frame_prims("A3", zones=True, marks=False)
|
||||
a_labels = [t for t in _texts(prims) if t.text == "A"]
|
||||
f_labels = [t for t in _texts(prims) if t.text == "F"]
|
||||
assert len(a_labels) == 2 and len(f_labels) == 2
|
||||
assert min(t.at[1] for t in a_labels) > max(t.at[1] for t in f_labels)
|
||||
|
||||
|
||||
def test_centring_marks_run_from_the_trimmed_edge_to_5_mm_inside_the_frame():
|
||||
prims = frame_prims("A3", zones=False, marks=True)
|
||||
centre_lines = [p for p in _lines(prims) if p.role == "center"]
|
||||
assert len(centre_lines) == 4
|
||||
spans = {(tuple(c.p1), tuple(c.p2)) for c in centre_lines}
|
||||
assert ((0.0, 148.5), (25.0, 148.5)) in spans
|
||||
assert ((420.0, 148.5), (405.0, 148.5)) in spans
|
||||
assert ((210.0, 0.0), (210.0, 15.0)) in spans
|
||||
assert ((210.0, 297.0), (210.0, 282.0)) in spans
|
||||
|
||||
|
||||
def test_each_corner_carries_two_overlapping_10_by_5_trim_rectangles():
|
||||
prims = frame_prims("A3", zones=False, marks=True)
|
||||
rects = _polys(prims)[2:] # after the trimmed sheet and the frame
|
||||
assert len(rects) == 8
|
||||
for rect in rects:
|
||||
xs = [p[0] for p in rect.points]
|
||||
ys = [p[1] for p in rect.points]
|
||||
span = (abs(max(xs) - min(xs)), abs(max(ys) - min(ys)))
|
||||
assert span in ((10.0, 5.0), (5.0, 10.0)), span
|
||||
assert rect.closed
|
||||
|
||||
|
||||
# ── drawing ─────────────────────────────────────────────────────────────────
|
||||
|
||||
from engineering.sheet.frames import draw_sheet_frame # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draw_sheet_frame_puts_the_border_on_titleblock_and_the_zones_on_text(backend):
|
||||
result = await draw_sheet_frame(backend, "A3", zones=True, marks=True)
|
||||
assert result["ok"] is True
|
||||
assert result["size"] == "A3"
|
||||
assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]}
|
||||
outer = await backend.entity_get(result["outer_border"])
|
||||
inner = await backend.entity_get(result["inner_border"])
|
||||
assert outer.layer == "TITLEBLOCK" and inner.layer == "TITLEBLOCK"
|
||||
texts = [
|
||||
e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "A"
|
||||
]
|
||||
assert texts and all(e.layer == "TEXT" for e in texts)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_draw_sheet_frame_offsets_every_primitive_by_the_origin(backend):
|
||||
result = await draw_sheet_frame(
|
||||
backend, "A4", orientation="portrait", zones=False, marks=False, origin=(100.0, 50.0)
|
||||
)
|
||||
assert result["bbox"] == {"min": [100.0, 50.0], "max": [310.0, 347.0]}
|
||||
assert len(result["handles"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_bad_size_is_refused_before_anything_is_drawn(backend):
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(ValueError):
|
||||
await draw_sheet_frame(backend, "A5")
|
||||
assert len(await backend.entity_list()) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_missing_layout_is_refused_before_anything_is_drawn(backend):
|
||||
before = len(await backend.entity_list())
|
||||
with pytest.raises(RuntimeError) as excinfo:
|
||||
await draw_sheet_frame(backend, "A3", layout="NoSuchSheet")
|
||||
assert "NoSuchSheet" in str(excinfo.value)
|
||||
assert "layout_create" in str(excinfo.value)
|
||||
assert len(await backend.entity_list()) == before
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_frame_puts_the_caller_back_on_the_tab_it_found_them_on(backend):
|
||||
await backend.layout_create("SheetA")
|
||||
await backend.layout_create("SheetB")
|
||||
await backend.layout_set_current("SheetB")
|
||||
await draw_sheet_frame(backend, "A3", zones=False, marks=False, layout="SheetA")
|
||||
assert (await backend.layout_list())["current"] == "SheetB"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_caller_s_tab_is_read_from_the_listing_not_a_private_attribute(
|
||||
backend, backend_without_private_state
|
||||
):
|
||||
"""`_current_space` exists on EzdxfBackend only. ComBackend keeps the live
|
||||
tab in AutoCAD, so reading the private attribute there fell through to the
|
||||
``getattr`` default and dropped a live caller onto Model after every
|
||||
border -- measured on AutoCAD 2026. `layout_list()["current"]` is the value
|
||||
both engines really report, and `enter_layout` already had it in hand.
|
||||
"""
|
||||
await backend.layout_create("SheetA")
|
||||
await backend.layout_create("SheetB")
|
||||
await backend.layout_set_current("SheetB")
|
||||
with pytest.raises(AttributeError):
|
||||
_ = backend_without_private_state._current_space
|
||||
|
||||
result = await draw_sheet_frame(
|
||||
backend_without_private_state, "A3", zones=False, marks=False, layout="SheetA"
|
||||
)
|
||||
|
||||
assert result["layout"] == "SheetA"
|
||||
assert (await backend.layout_list())["current"] == "SheetB"
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Revision rows, revision clouds and the triangular revision tags."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.primitives import Poly, Text
|
||||
from engineering.sheet.revision import (
|
||||
REVISION_COLUMNS,
|
||||
REVISION_LABELS,
|
||||
TAG_SIDE,
|
||||
add_revision,
|
||||
read_revisions,
|
||||
revision_block_prims,
|
||||
revision_heading_prims,
|
||||
revision_row_prims,
|
||||
revision_tag_prims,
|
||||
)
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def test_the_revision_columns_are_the_iso_7200_version_fields():
|
||||
assert REVISION_COLUMNS == ("rev", "description", "date", "by")
|
||||
for column in REVISION_COLUMNS:
|
||||
assert column in REVISION_LABELS
|
||||
|
||||
|
||||
def test_a_tag_is_an_equilateral_triangle_carrying_the_letter():
|
||||
prims = revision_tag_prims("B", (100.0, 200.0))
|
||||
triangle = next(p for p in prims if isinstance(p, Poly))
|
||||
text = next(p for p in prims if isinstance(p, Text))
|
||||
assert triangle.closed and len(triangle.points) == 3
|
||||
xs = [p[0] for p in triangle.points]
|
||||
assert max(xs) - min(xs) == pytest.approx(TAG_SIDE, abs=EPS)
|
||||
assert text.text == "B"
|
||||
|
||||
|
||||
def test_the_block_is_180_mm_wide_and_grows_downwards_from_its_anchor():
|
||||
rows = [
|
||||
{"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"},
|
||||
{"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"},
|
||||
]
|
||||
prims = revision_block_prims(rows, at=(230.0, 287.0))
|
||||
xs = [c[0] for p in prims if isinstance(p, Poly) for c in p.points]
|
||||
xs += [v for p in prims if hasattr(p, "p1") for v in (p.p1[0], p.p2[0])]
|
||||
assert min(xs) == pytest.approx(230.0, abs=EPS)
|
||||
assert max(xs) == pytest.approx(410.0, abs=EPS)
|
||||
ys = [v for p in prims if hasattr(p, "p1") for v in (p.p1[1], p.p2[1])]
|
||||
assert max(ys) == pytest.approx(287.0, abs=EPS)
|
||||
assert min(ys) == pytest.approx(287.0 - 3 * 7.0, abs=EPS) # heading + 2 rows
|
||||
|
||||
|
||||
def test_the_block_built_row_by_row_is_the_block_built_at_once():
|
||||
"""`add_revision` appends one band at a time; the whole-block builder is
|
||||
what the tests read. They must be the same geometry or the second revision
|
||||
would not line up with the first."""
|
||||
rows = [
|
||||
{"rev": "A", "description": "first issue", "date": "2026-01-05", "by": "UC"},
|
||||
{"rev": "B", "description": "bore 40H7", "date": "2026-03-11", "by": "UC"},
|
||||
]
|
||||
at = (230.0, 287.0)
|
||||
incremental = list(revision_heading_prims(at=at))
|
||||
for index, row in enumerate(rows):
|
||||
incremental.extend(revision_row_prims(row, at=at, index=index))
|
||||
assert tuple(incremental) == revision_block_prims(rows, at=at)
|
||||
|
||||
|
||||
def test_a_rows_first_text_primitive_is_its_rev_cell():
|
||||
"""`add_revision` hangs the payload on that primitive, so this is the link
|
||||
between the drawn row and the row a reader gets back."""
|
||||
band = revision_row_prims(
|
||||
{"rev": "B", "description": "", "date": "", "by": ""}, at=(230.0, 287.0), index=0
|
||||
)
|
||||
first_text = next(p for p in band if isinstance(p, Text))
|
||||
assert first_text.text == "B"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "revision b", "AB CD", "b!"])
|
||||
def test_a_malformed_revision_code_is_refused(bad):
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
revision_tag_prims(bad, (0.0, 0.0))
|
||||
assert "revision" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_revision_row_is_written_once_per_letter(backend):
|
||||
first = await add_revision(
|
||||
backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC"
|
||||
)
|
||||
assert first["created"] is True
|
||||
count = len(await backend.entity_list())
|
||||
again = await add_revision(
|
||||
backend, rev="B", description="bore 40H7 (again)", date="2026-03-12", by="UC"
|
||||
)
|
||||
assert again["created"] is False
|
||||
assert again["handle"] == first["handle"]
|
||||
assert len(await backend.entity_list()) == count, "a repeated revision must draw nothing"
|
||||
rows = await read_revisions(backend)
|
||||
assert [r["rev"] for r in rows] == ["B"]
|
||||
assert rows[0]["description"] == "bore 40H7"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_two_revisions_stack_and_read_back_in_order(backend):
|
||||
await add_revision(backend, rev="A", description="first issue", date="2026-01-05", by="UC")
|
||||
await add_revision(backend, rev="B", description="bore 40H7", date="2026-03-11", by="UC")
|
||||
rows = await read_revisions(backend)
|
||||
assert [r["rev"] for r in rows] == ["A", "B"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clouds_and_tags_are_drawn_when_asked(backend):
|
||||
result = await add_revision(
|
||||
backend,
|
||||
rev="C",
|
||||
description="slot widened",
|
||||
clouds=[[10.0, 10.0, 60.0, 40.0]],
|
||||
tags=[[65.0, 45.0]],
|
||||
)
|
||||
assert len(result["clouds"]) == 1
|
||||
assert len(result["tags"]) == 1
|
||||
tag_texts = [
|
||||
e for e in await backend.entity_list(type_filter="TEXT") if e.properties["text"] == "C"
|
||||
]
|
||||
assert tag_texts
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clouds_are_refused_before_any_row_when_the_engine_has_no_revcloud(
|
||||
backend, monkeypatch
|
||||
):
|
||||
from backends.base import CapabilityMap, FeatureCapability
|
||||
|
||||
original = backend.capabilities()
|
||||
features = dict(original.features)
|
||||
features["revcloud"] = FeatureCapability(False, reason="no_activex_member_command_only")
|
||||
monkeypatch.setattr(
|
||||
backend, "capabilities", lambda: CapabilityMap(backend=original.backend, features=features)
|
||||
)
|
||||
before = len(await backend.entity_list())
|
||||
from backends.base import UnsupportedCapabilityError
|
||||
|
||||
with pytest.raises(UnsupportedCapabilityError) as excinfo:
|
||||
await add_revision(backend, rev="D", description="x", clouds=[[0.0, 0.0, 10.0, 10.0]])
|
||||
assert excinfo.value.capability == "revcloud"
|
||||
assert len(await backend.entity_list()) == before
|
||||
assert (await read_revisions(backend)) == ()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_revision_on_a_layout_is_still_written_once_per_letter(backend):
|
||||
"""`read_revisions` follows the current space, so reading it before
|
||||
`enter_layout` inspected Model while the block went on the layout -- and
|
||||
every rerun stacked a second heading band on top of the first."""
|
||||
await backend.layout_create("SHEET1")
|
||||
first = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1")
|
||||
assert first["created"] is True
|
||||
again = await add_revision(backend, rev="B", description="bore 40H7", layout="SHEET1")
|
||||
assert again["created"] is False
|
||||
assert again["handle"] == first["handle"]
|
||||
assert (await backend.layout_list())["current"] == "Model", "the caller's tab must come back"
|
||||
await backend.layout_set_current("SHEET1")
|
||||
try:
|
||||
texts = [e.properties["text"] for e in await backend.entity_list(type_filter="TEXT")]
|
||||
assert texts == ["REV", "DESCRIPTION", "DATE", "BY", "B", "bore 40H7"]
|
||||
rows = await read_revisions(backend)
|
||||
assert [r["rev"] for r in rows] == ["B"]
|
||||
finally:
|
||||
await backend.layout_set_current("Model")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_revisions_are_read_past_the_entity_list_page(backend):
|
||||
"""200 notes fill `entity_list`'s default page. The idempotency guard used
|
||||
to see no revisions at all, and `index = len(existing)` restarted at 0."""
|
||||
for index in range(200):
|
||||
await backend.entity_create_text(text=f"NOTE {index}", x=float(index), y=500.0, height=2.5)
|
||||
first = await add_revision(backend, rev="A", description="first issue")
|
||||
assert first["row"] == 1
|
||||
assert (await add_revision(backend, rev="A", description="first issue"))["created"] is False
|
||||
second = await add_revision(backend, rev="B", description="bore 40H7")
|
||||
assert second["row"] == 2, "the second band must sit below the first, not on top of it"
|
||||
assert second["bbox"]["min"][1] < first["bbox"]["min"][1]
|
||||
assert [r["rev"] for r in await read_revisions(backend)] == ["A", "B"]
|
||||
@@ -0,0 +1,264 @@
|
||||
"""ISO 7200 title blocks for A4-A0, and the A3 geometry that must not move."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from engineering.mech.primitives import Circle, Line, Poly, Text
|
||||
from engineering.sheet.titleblock import (
|
||||
TB_HEIGHT,
|
||||
TB_WIDTH,
|
||||
TITLE_TEXT_INDEX,
|
||||
TITLEBLOCK_FIELDS,
|
||||
TITLEBLOCK_RULE_COUNT,
|
||||
VALUE_FIELDS,
|
||||
TitleBlockMetadata,
|
||||
apply_titleblock,
|
||||
projection_symbol_prims,
|
||||
titleblock_origin,
|
||||
titleblock_prims,
|
||||
value_text_indices,
|
||||
)
|
||||
|
||||
EPS = 1e-9
|
||||
|
||||
|
||||
def _meta(**overrides) -> TitleBlockMetadata:
|
||||
base = dict(
|
||||
title="HELICAL GEAR",
|
||||
drawing_no="AM-2026-001",
|
||||
part_no="GR-24T-M3",
|
||||
material="C45",
|
||||
scale="1:1",
|
||||
units="mm",
|
||||
drawn_by="Umutcan",
|
||||
checked_by="QA",
|
||||
date="2026-04-28",
|
||||
sheet="1/1",
|
||||
revision="A",
|
||||
company="Anka-Makine",
|
||||
)
|
||||
base.update(overrides)
|
||||
return TitleBlockMetadata(**base)
|
||||
|
||||
|
||||
def test_the_block_is_the_iso_7200_180_mm_wide_and_sits_in_the_frame_corner():
|
||||
assert TB_WIDTH == 180.0 and TB_HEIGHT == 60.0
|
||||
assert titleblock_origin("A3") == (230.0, 10.0)
|
||||
assert titleblock_origin("A4", orientation="portrait") == (20.0, 10.0)
|
||||
assert titleblock_origin("A0") == (999.0, 10.0)
|
||||
|
||||
|
||||
def test_every_declared_field_is_a_metadata_attribute():
|
||||
meta = _meta()
|
||||
for field in TITLEBLOCK_FIELDS:
|
||||
assert hasattr(meta, field), field
|
||||
|
||||
|
||||
def test_the_a3_rules_are_exactly_where_they_have_always_been():
|
||||
prims = titleblock_prims("A3", _meta(), projection=None)
|
||||
rules = prims[:TITLEBLOCK_RULE_COUNT]
|
||||
assert isinstance(rules[0], Poly) and rules[0].closed
|
||||
assert rules[0].points == ((230.0, 10.0), (410.0, 10.0), (410.0, 70.0), (230.0, 70.0))
|
||||
assert [(r.p1[1], r.p1[0], r.p2[0]) for r in rules[1:4]] == [
|
||||
(20.0, 230.0, 410.0),
|
||||
(35.0, 230.0, 410.0),
|
||||
(50.0, 230.0, 410.0),
|
||||
]
|
||||
assert [(r.p1[0], r.p1[1], r.p2[1]) for r in rules[4:]] == [
|
||||
(320.0, 35.0, 50.0),
|
||||
(365.0, 35.0, 50.0),
|
||||
(290.0, 20.0, 35.0),
|
||||
(350.0, 20.0, 35.0),
|
||||
(275.0, 10.0, 20.0),
|
||||
(320.0, 10.0, 20.0),
|
||||
(365.0, 10.0, 20.0),
|
||||
]
|
||||
|
||||
|
||||
def test_the_a3_value_texts_are_exactly_where_they_have_always_been():
|
||||
prims = titleblock_prims("A3", _meta(), projection=None)
|
||||
index = value_text_indices()
|
||||
assert prims[index["drawing_no"]] == Text(at=(233.0, 40.0), text="AM-2026-001", height=3.5)
|
||||
assert prims[index["revision"]] == Text(at=(323.0, 40.0), text="A", height=3.5)
|
||||
assert prims[index["sheet"]] == Text(at=(368.0, 40.0), text="1/1", height=3.5)
|
||||
assert prims[index["part_no"]] == Text(at=(233.0, 25.0), text="GR-24T-M3", height=3.5)
|
||||
assert prims[index["material"]] == Text(at=(293.0, 25.0), text="C45", height=3.5)
|
||||
assert prims[index["scale"]] == Text(at=(353.0, 25.0), text="1:1", height=3.5)
|
||||
assert prims[index["drawn_by"]] == Text(at=(233.0, 11.5), text="Umutcan", height=3.5)
|
||||
assert prims[index["checked_by"]] == Text(at=(278.0, 11.5), text="QA", height=3.5)
|
||||
assert prims[index["date"]] == Text(at=(323.0, 11.5), text="2026-04-28", height=3.5)
|
||||
assert prims[index["company"]] == Text(at=(368.0, 11.5), text="Anka-Makine", height=3.5)
|
||||
|
||||
|
||||
def test_the_title_sits_at_the_pinned_index_and_carries_the_text_verbatim():
|
||||
prims = titleblock_prims("A3", _meta(title="SPUR GEAR / M3 Z24"), projection=None)
|
||||
title = prims[TITLE_TEXT_INDEX]
|
||||
assert isinstance(title, Text)
|
||||
assert title.text == "SPUR GEAR / M3 Z24"
|
||||
assert title.height == 5.0
|
||||
assert title.at[1] == pytest.approx(57.5, abs=EPS)
|
||||
|
||||
|
||||
def test_projection_none_draws_no_symbol_and_first_draws_one():
|
||||
plain = titleblock_prims("A3", _meta(), projection=None)
|
||||
first = titleblock_prims("A3", _meta(), projection="first")
|
||||
assert len(plain) == TITLE_TEXT_INDEX + 1 + 2 * len(VALUE_FIELDS)
|
||||
assert len(first) == len(plain) + 4
|
||||
|
||||
|
||||
#: The ISO 5456-2 / ISO 128-30 projection symbol, transcribed from two
|
||||
#: independent reproductions of the standard's figure (ISO's own is paywalled
|
||||
#: and was not read). Each entry is that source's OWN coordinates, measured off
|
||||
#: the file, and the landmark order left-to-right is what the symbol means.
|
||||
#:
|
||||
#: 1. FreeCAD's ISO 5457 sheet template, first-angle symbol --
|
||||
#: src/Mod/TechDraw/Templates/ISO/A3_Landscape_ISO5457_advanced.svg,
|
||||
#: ids `first_angle_trapezoid`, `first_angle_inner circle`,
|
||||
#: `first_angle_outer circle`.
|
||||
#: 2. Wikimedia Commons `Convention placement vues dessin technique.svg`, which
|
||||
#: draws both symbols side by side, labelled FR (first) and US (third).
|
||||
#:
|
||||
#: A test that read these numbers out of `projection_symbol_prims` instead
|
||||
#: would pass whichever way round the symbol was drawn, which is exactly how
|
||||
#: the mirrored third-angle cone shipped in the first place.
|
||||
REFERENCE_FIGURES = {
|
||||
"first": (
|
||||
# source, short-side x, long-side x, end-view (circles) x
|
||||
("FreeCAD A3_Landscape_ISO5457_advanced.svg", 379.0, 389.0, 396.0),
|
||||
("Commons Convention placement vues dessin technique.svg (FR)", 13.343, 81.869, 148.005),
|
||||
),
|
||||
"third": (
|
||||
("Commons Convention placement vues dessin technique.svg (US)", 380.275, 448.801, 314.140),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
#: The three landmarks, in the order `_landmarks` and REFERENCE_FIGURES list them.
|
||||
LANDMARKS = ("short", "long", "circles")
|
||||
|
||||
|
||||
def _landmarks(angle: str) -> tuple[float, float, float]:
|
||||
"""(short-side x, long-side x, end-view x) of the symbol this module draws."""
|
||||
prims = projection_symbol_prims((0.0, 0.0), angle)
|
||||
cone = next(p for p in prims if isinstance(p, Poly))
|
||||
circles = [p for p in prims if isinstance(p, Circle)]
|
||||
left_x = min(x for x, _ in cone.points)
|
||||
right_x = max(x for x, _ in cone.points)
|
||||
left_h = max(y for x, y in cone.points if x == left_x) * 2
|
||||
right_h = max(y for x, y in cone.points if x == right_x) * 2
|
||||
short_x, long_x = (left_x, right_x) if left_h < right_h else (right_x, left_x)
|
||||
assert len({c.center[0] for c in circles}) == 1
|
||||
return short_x, long_x, circles[0].center[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("angle", ["first", "third"])
|
||||
def test_the_symbol_lands_the_landmarks_in_the_order_the_reference_figures_do(angle):
|
||||
"""The meaning of the symbol is the left-to-right order of three landmarks:
|
||||
the cone's short side, its long side and the end view. Assert that order
|
||||
against the measured figures, not against what the module computes."""
|
||||
mine = _landmarks(angle)
|
||||
for source, *reference in REFERENCE_FIGURES[angle]:
|
||||
expected = [name for _x, name in sorted(zip(reference, LANDMARKS, strict=True))]
|
||||
got = [name for _x, name in sorted(zip(mine, LANDMARKS, strict=True))]
|
||||
assert got == expected, f"{angle}-angle symbol disagrees with {source}"
|
||||
|
||||
|
||||
def test_the_short_side_points_away_from_the_circles_in_first_and_towards_in_third():
|
||||
"""The mirror-invariant half of the rule, and the one every source states
|
||||
in words: Wikipedia's Multiview orthographic projection, Symbol --
|
||||
"the first-angle symbol shows the trapezoid with its shortest side away
|
||||
from the circles", "the third-angle symbol shows the trapezoid with its
|
||||
shortest side towards the circles"."""
|
||||
short_x, long_x, circles_x = _landmarks("first")
|
||||
assert abs(short_x - circles_x) > abs(long_x - circles_x)
|
||||
short_x, long_x, circles_x = _landmarks("third")
|
||||
assert abs(short_x - circles_x) < abs(long_x - circles_x)
|
||||
|
||||
|
||||
def test_the_symbol_keeps_the_reference_figures_proportions_and_its_axis():
|
||||
"""Both reference figures draw the end view as two circles of ratio 1:2
|
||||
(FreeCAD r 2.5 / r 5) on a centre line; the absolute size is this
|
||||
module's."""
|
||||
first = projection_symbol_prims((0.0, 0.0), "first")
|
||||
circles = [p for p in first if isinstance(p, Circle)]
|
||||
assert {round(c.radius, 6) for c in circles} == {4.0, 2.0}
|
||||
assert any(isinstance(p, Line) and p.role == "center" for p in first)
|
||||
|
||||
|
||||
def test_the_two_symbols_are_not_the_same_picture():
|
||||
assert _landmarks("first") != _landmarks("third")
|
||||
|
||||
|
||||
def test_an_unknown_projection_angle_is_refused():
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
titleblock_prims("A3", _meta(), projection="second")
|
||||
assert "second" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_a_sheet_narrower_than_the_title_block_is_refused_by_name():
|
||||
"""There is no such size in A4-A0; the guard exists so a future size cannot
|
||||
ship a title block hanging off the paper."""
|
||||
from engineering.sheet import frames
|
||||
|
||||
original = dict(frames.SHEETS)
|
||||
frames.SHEETS["A9"] = (100.0, 140.0)
|
||||
try:
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
titleblock_origin("A9", orientation="portrait")
|
||||
assert "180" in str(excinfo.value)
|
||||
finally:
|
||||
frames.SHEETS.clear()
|
||||
frames.SHEETS.update(original)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_titleblock_returns_the_payload_the_a3_tool_always_returned(backend):
|
||||
result = await apply_titleblock(backend, size="A3", metadata=_meta(), projection=None)
|
||||
assert result["bbox"] == {"min": [0.0, 0.0], "max": [420.0, 297.0]}
|
||||
assert set(result["value_texts"]) == {
|
||||
"drawing_no",
|
||||
"revision",
|
||||
"sheet",
|
||||
"part_no",
|
||||
"material",
|
||||
"scale",
|
||||
"drawn_by",
|
||||
"checked_by",
|
||||
"date",
|
||||
"company",
|
||||
}
|
||||
assert len(result["titleblock_lines"]) == TITLEBLOCK_RULE_COUNT
|
||||
title = await backend.entity_get(result["title_text"])
|
||||
assert title.type == "TEXT" and title.properties["text"] == "HELICAL GEAR"
|
||||
assert title.layer == "TEXT"
|
||||
for handle in result["titleblock_lines"]:
|
||||
assert (await backend.entity_get(handle)).layer == "TITLEBLOCK"
|
||||
assert result["projection_symbol"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_titleblock_draws_every_size_with_its_symbol(backend):
|
||||
for size in ("A4", "A3", "A2", "A1", "A0"):
|
||||
orientation = "portrait" if size == "A4" else "landscape"
|
||||
result = await apply_titleblock(
|
||||
backend, size=size, metadata=_meta(), orientation=orientation
|
||||
)
|
||||
assert result["ok"] is True and result["size"] == size
|
||||
assert len(result["projection_symbol"]) == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_the_titleblock_puts_the_caller_back_on_the_tab_it_found_them_on(
|
||||
backend, backend_without_private_state
|
||||
):
|
||||
"""`apply_titleblock` routes through the same `enter_layout`, so the live
|
||||
engine dropped the caller onto Model here too."""
|
||||
await backend.layout_create("SheetA")
|
||||
await backend.layout_create("SheetB")
|
||||
await backend.layout_set_current("SheetB")
|
||||
result = await apply_titleblock(
|
||||
backend_without_private_state, size="A3", metadata=_meta(), layout="SheetA"
|
||||
)
|
||||
assert result["layout"] == "SheetA"
|
||||
assert (await backend.layout_list())["current"] == "SheetB"
|
||||
@@ -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 (209 tools, 23 groups).
|
||||
"""Frozen snapshot of the surface (229 tools, 24 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
|
||||
@@ -527,7 +527,26 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
merge task tagged them, the four groups had filed those 28 tools under
|
||||
`layouts` / `templates` / `drawing` / `layers` / `view` / `system` by
|
||||
their secondary tags; those counts returned to their pre-track-E values
|
||||
when the section tags landed.) Every other number here has been unchanged
|
||||
when the section tags landed.)
|
||||
`engineering` moved 10 -> 12 when v1.6's SECTION 24 opened with
|
||||
`sheet_frame` and `titleblock_apply` (the ISO 5457 frame and the ISO 7200
|
||||
title block for every size A4-A0). Both are tagged `engineering` and
|
||||
`sheet`, and `sheet` is not in `_GROUP_TAG_PRIORITY`, so they file under
|
||||
`engineering` beside `titleblock_apply_iso_a3` rather than opening a
|
||||
group of their own.
|
||||
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.
|
||||
|
||||
`mech` appeared when v1.6's tracks B+G landed. SECTION 21 opened it with
|
||||
@@ -545,18 +564,18 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
`weld_symbol` / `centre_marks` / `section_line` / `hatch_material`). They
|
||||
are tagged `engineering` and `mech`; with `mech` ranked ahead of
|
||||
`engineering` in `_GROUP_TAG_PRIORITY` they file under `mech` (14 in all,
|
||||
exactly `PACK_TOOL_NAMES["mech"]`), and `engineering` stays at 10.
|
||||
exactly `PACK_TOOL_NAMES["mech"]`) and leave `engineering` untouched.
|
||||
"""
|
||||
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||
assert sizes == {
|
||||
"analysis": 12,
|
||||
"batch": 3,
|
||||
"blocks": 9,
|
||||
"blocks": 11,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"engineering": 10,
|
||||
"entity_creation": 18,
|
||||
"drawing": 12,
|
||||
"engineering": 17,
|
||||
"entity_creation": 19,
|
||||
"entity_modification": 16,
|
||||
"entity_query": 9,
|
||||
"environment": 22,
|
||||
@@ -574,4 +593,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 218
|
||||
assert sum(sizes.values()) == 229
|
||||
|
||||
Reference in New Issue
Block a user