Three findings, one family: a guard that reads the WRONG SET silently stops
guarding and the drawing gets a duplicate or a wrong number with no refusal.
1. Layout-scoped state was read from the wrong space. add_revision called
read_revisions and add_balloon called read_balloons BEFORE enter_layout;
both read entity_list, which follows the current space, so the guard
inspected Model while the rows and balloons lived on the layout -- and the
layout is the sheet workflow (sheet_frame and titleblock_apply both take
one). Measured headlessly: two revision_add(rev="B", layout="SHEET1") calls
both returned created=True, handles 46 and 5A, both bbox
{min [230,273], max [410,287]} -- a second heading band and a second B row
drawn exactly on top of the first, twelve TEXTs on the tab instead of six.
Both reads now happen inside the entered layout, and the whole body sits in
the try/ so the caller's tab comes back on the refusal path too.
2. bom_extract truncated the parts list at 200 INSERTs and printed a wrong QTY
into a real TABLE. extract_records passed its own limit=200 default straight
to entity_list, the tool exposed no limit, and nothing in the payload said
the list had been cut: 250 bolts reported [("ISO 4014 - M12x60", 200)].
all_entities now pages entity_list (offset loop, PAGE_SIZE 1000, plus a
no-new-handles guard so a backend that ignored offset cannot spin) and
extract_records(limit=None) reads the whole drawing. bom_extract exposes
limit (0 = no cap) and reports truncated measured, by asking for one record
past the cap.
3. The balloon clash guard did not fire in two routine cases. (a) targets is
optional in the tool, so the clash test compared () != () -- False -- and
two untargeted balloons carried item 1 with no refusal. The renumber arm
above has already returned for the balloon that stands for these targets, so
anything still carrying the number is a clash, whatever its targets; the
message names the handle and says "no targets" when it has none. (b)
read_balloons and read_revisions took entity_list's 200-entity default, so a
drawing whose first 200 CIRCLEs are a hole pattern read back no balloons at
all and every guard passed blind -- and index = len(existing) restarted at
0, stacking revision bands. Both readers now go through all_entities.
Evidence, headless (ezdxf), each finding's own repro before -> after:
1 created True/True, handles 46+5A, 12 TEXTs -> created True/False, one
handle 46, 6 TEXTs; renumbered False/False -> False/True, same handle 33
2 records 200, rows [(..., 200)] -> records 250, rows [(..., 250)]
3a two balloons handles 2F+35, no refusal -> refused, naming balloon 2F
3b read_balloons() == () with 206 circles -> the balloon past the 200th
circle is found, the rerun renumbers, the item-1/BBB clash is refused
3c 200 notes + rev A twice + rev B -> created True/False/True, row 1 then
row 2 (was: every band at row 1, on top of the last)
Six regression tests added: one per finding plus the layout-scoped twin of
each, which is what would have caught all three.
The live engine was UNAVAILABLE for this round:
win32com.client.GetActiveObject("AutoCAD.Application") did not return within
90 s against the running acad.exe (PID 40068), and the ComBackend probe timed
out on its first call at both COM_CALL_TIMEOUT=60 and 180. No second AutoCAD
was launched and no document was opened. No new ActiveX call is introduced by
this fix -- it only reorders existing calls and pages entity_list(offset=),
which both engines already implement.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
186 lines
7.6 KiB
Python
186 lines
7.6 KiB
Python
"""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"]
|