From b54271f4ac29d681622801a8cc15519b7f0b0b75 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 13:07:31 +0300 Subject: [PATCH] =?UTF-8?q?fix(com):=20four=20live=20defects=20the=20setti?= =?UTF-8?q?ngs=20smoke=20measured=20on=20AutoCAD=202026=20=E2=80=94=20reje?= =?UTF-8?q?cted=20calls=20after=20a=20document=20switch,=20typelib=20spell?= =?UTF-8?q?ing=20of=20color/Lineweight,=20narrowed=20layer-state=20diction?= =?UTF-8?q?ary,=20layer=200=20Freeze=20put?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured 2026-09-22 by scripts/smoke_settings_com.py --build-dwt against the running AutoCAD 2026 (25.1s), each on a scratch Documents.Add() document: 1. RPC_E_CALL_REJECTED (-2147418111) on Documents.Count straight after Documents.Open, after Close(False) inside document_close, and in _ensure_document_state: AutoCAD's own message filter refuses incoming calls while it is still switching documents. pywin32 exposes no CoRegisterMessageFilter, so _wait_out_rejected_call waits the window out for read-only callables (0.25 s -> 1 s pauses, 20 s budget, inside COM_CALL_TIMEOUT) at those sites and after Documents.Add/Open; any other com_error is not retried. 2. layer_list raised "'IAcadLayer' object has no attribute 'Color'. Did you mean: 'color'?": acax25enu.tlb spells IAcadEntity/IAcadLayer `color` and `Lineweight`. Late-bound dispatch resolved .Color/.LineWeight case-insensitively; with a makepy cache present (this machine's gen_py was regenerated on 2026-09-17, and every object a method returns is then a generated wrapper) the lookup is exact. All 17 sites now use the typelib spelling, which works on both wrappers; the fakes model the measured names. 3. layer_state_restore raised "'IAcadObject' object has no attribute 'Count'": Dictionaries.Item and IAcadDictionary.Item are declared IAcadObject* (the narrowing _com_unnarrow already handles for MLEADER styles). The save path went through the typed Dictionaries.Add, so only the reopen paths failed. Both now un-narrow; the fakes narrow like pywin32. 4. The Freeze put on layer 0 raises 'Invalid layer' whatever the value, even False while thawed (color/Linetype/Lineweight/Plottable/LayerOn/Lock/ ActiveLayer all fine on it), so a state saved with 0 current restored with two warnings and 0 unapplied. Freeze is written only when it changes the layer. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 174 ++++++++++++++++++++++++-------- tests/test_com_backend.py | 2 +- tests/test_documents.py | 92 +++++++++++++++++ tests/test_layer_states.py | 64 ++++++++++-- tests/test_ocs_extrusion.py | 2 +- tests/test_pid_graph.py | 6 +- tests/test_pid_line_draw.py | 2 +- tests/test_styles.py | 2 +- tests/test_track_a_hardening.py | 8 +- 9 files changed, 291 insertions(+), 61 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index b26eba3..667bf8f 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -75,6 +75,39 @@ else: #: than the bug: it would silently swallow unrelated errors. _COM_ERROR: tuple[type[BaseException], ...] = (pywintypes.com_error,) if _COM_IMPORTS_OK else () +#: ``RPC_E_CALL_REJECTED``: the callee's message filter refused the incoming +#: call *before* running it, so nothing happened on the seat. AutoCAD answers +#: it for a moment after ``Documents.Open`` / ``Add`` / ``Close`` while it is +#: still switching documents — measured twice on 2026-09-22 (AutoCAD 2026, +#: ``scripts/smoke_settings_com.py --build-dwt``: ``Documents.Count`` right +#: after ``Documents.Open`` and right after ``Close(False)``) — and for as long +#: as an operator or another client leaves a command prompt open. pywin32 has +#: no ``CoRegisterMessageFilter``, the client-side hook that would retry this +#: per call, so the read-only reads that follow a switch wait it out themselves. +_RPC_E_CALL_REJECTED = -2147418111 + + +def _wait_out_rejected_call(read: Callable[[], Any], *, budget_s: float, first_pause_s: float): + """Run a read-only COM callable again while it answers ``RPC_E_CALL_REJECTED``. + + Only for callables that change nothing on the seat (the rejection means + the refused call did not run, but an earlier call inside ``read`` did). + Pauses double from ``first_pause_s`` up to one second and stop at + ``budget_s``; any other ``com_error`` is re-raised on the first attempt. + """ + deadline = time.monotonic() + budget_s + pause = first_pause_s + while True: + try: + return read() + except _COM_ERROR as exc: + hr = exc.args[0] if exc.args else 0 + if hr != _RPC_E_CALL_REJECTED or time.monotonic() + pause > deadline: + raise + time.sleep(pause) + pause = min(pause * 2, 1.0) + + try: from PIL import Image as PILImage @@ -440,7 +473,7 @@ def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: if layer is not None: entity.Layer = layer if color is not None: - entity.Color = int(color) + entity.color = int(color) if linetype is not None: _ensure_linetype_loaded(linetype) entity.Linetype = linetype @@ -1286,20 +1319,34 @@ def _entity_info(entity) -> EntityInfo: handle=entity.Handle, type=ent_type, layer=entity.Layer, - color=entity.Color, + color=entity.color, linetype=linetype, visible=bool(entity.Visible), properties=props, ) +#: Two ActiveX members this backend spells the way the type library does and +#: not the way the documentation capitalises them: ``IAcadEntity`` / ``IAcadLayer`` +#: declare ``color`` and ``Lineweight`` (acax25enu.tlb). Late-bound dispatch +#: resolves names case-insensitively, so ``.Color`` worked on a seat without a +#: makepy cache; once ``win32com.client.gencache`` has run against AutoCAD +#: (any tool calling ``EnsureDispatch`` leaves it behind, and every object a +#: method returns is then wrapped in the generated class) the lookup is exact +#: and ``.Color`` / ``.LineWeight`` raise ``AttributeError: ... no attribute +#: 'Color'. Did you mean: 'color'?`` — measured 2026-09-22 on AutoCAD 2026 by +#: ``scripts/smoke_settings_com.py`` (``layer_list`` on the reopened template) +#: and on a scratch document's ``AddLine``. The typelib spelling works on both +#: kinds of wrapper; the fakes in tests/ model the same names. + + def _layer_info(layer, current_layer_name: str) -> LayerInfo: """Convert a COM layer object to LayerInfo.""" return LayerInfo( name=layer.Name, - color=layer.Color, + color=layer.color, linetype=layer.Linetype, - lineweight=layer.LineWeight, + lineweight=layer.Lineweight, is_on=bool(layer.LayerOn), is_frozen=bool(layer.Freeze), is_locked=bool(layer.Lock), @@ -1397,6 +1444,11 @@ def _capture_window(hwnd: int) -> bytes | None: class ComBackend(AutoCADBackend): """Live AutoCAD control via COM API.""" + #: How long ``_ensure_document_state`` waits out ``RPC_E_CALL_REJECTED`` + #: after a document switch, well inside ``COM_CALL_TIMEOUT`` (60 s). + _REJECTED_RETRY_BUDGET_S = 20.0 + _REJECTED_RETRY_FIRST_PAUSE_S = 0.25 + def __init__(self): self._executor: ThreadPoolExecutor | None = None self._connected = False @@ -1518,16 +1570,32 @@ class ComBackend(AutoCADBackend): self._reset_document_state() self._connected = False - async def _ensure_document_state(self) -> None: - """Reset drawing-scoped metadata when AutoCAD's active document changes.""" + def _wait_out_rejected_call(self, read: Callable[[], Any]): + """``_wait_out_rejected_call`` with this backend's budget (COM thread only).""" + return _wait_out_rejected_call( + read, + budget_s=self._REJECTED_RETRY_BUDGET_S, + first_pause_s=self._REJECTED_RETRY_FIRST_PAUSE_S, + ) - def _sync(): + async def _ensure_document_state(self) -> None: + """Reset drawing-scoped metadata when AutoCAD's active document changes. + + Runs directly after ``Documents.Add`` / ``Open`` / ``Close`` — the + window in which AutoCAD still rejects incoming calls (see + ``_RPC_E_CALL_REJECTED``) — so the read waits that window out. It is + a pure read, which is what makes re-running it safe. + """ + + def _read_active_key(): app = _acad_app() if app.Documents.Count == 0: - key = None - else: - doc = app.ActiveDocument - key = (str(doc.Name), str(doc.FullName)) + return None + doc = app.ActiveDocument + return (str(doc.Name), str(doc.FullName)) + + def _sync(): + key = self._wait_out_rejected_call(_read_active_key) if key != self._document_scope_key: self._document_scope_key = key self._transaction_active = False @@ -1678,7 +1746,7 @@ class ComBackend(AutoCADBackend): app = _acad_app() if not template: doc = app.Documents.Add() - return {"ok": True, "name": doc.Name} + return {"ok": True, "name": self._wait_out_rejected_call(lambda: doc.Name)} source = Path(template) if not source.is_file(): raise FileNotFoundError( @@ -1692,7 +1760,7 @@ class ComBackend(AutoCADBackend): dwt, cached = _template_as_dwt(app, source) result["template_dwt"] = {"path": str(dwt), "cached": cached} doc = app.Documents.Add(str(dwt)) - result["name"] = doc.Name + result["name"] = self._wait_out_rejected_call(lambda: doc.Name) return result result = await self._run(_sync) @@ -1703,7 +1771,8 @@ class ComBackend(AutoCADBackend): def _sync(): app = _acad_app() doc = app.Documents.Open(path) - return {"ok": True, "name": doc.Name, "path": doc.FullName} + name, full_name = self._wait_out_rejected_call(lambda: (doc.Name, doc.FullName)) + return {"ok": True, "name": name, "path": full_name} result = await self._run(_sync) await self._ensure_document_state() @@ -2127,14 +2196,21 @@ class ComBackend(AutoCADBackend): # A clean or untitled document is never asked to save: Close(True) # on a clean file is a no-op write, on an untitled one a dialog. doc.Close(bool(save and dirty and path)) - count = int(app.Documents.Count) + + def _read_after_close(): + # AutoCAD still rejects calls for a moment after Close + # (see _RPC_E_CALL_REJECTED); this read changes nothing. + count = int(app.Documents.Count) + return count, (str(app.ActiveDocument.Name) if count else None) + + count, active = self._wait_out_rejected_call(_read_after_close) return { "ok": True, "closed": name, "path": path, "saved": bool(save and dirty and path), "discarded_changes": bool(dirty and not save), - "active": str(app.ActiveDocument.Name) if count else None, + "active": active, "open_documents": count, "backend": "com", } @@ -2147,15 +2223,25 @@ class ComBackend(AutoCADBackend): @staticmethod def _com_layer_states(doc, *, create: bool): + """The ``ACADMCP_LAYERSTATES`` dictionary, or None when absent. + + ``Dictionaries.Item`` is declared ``IAcadObject*`` (see + ``_com_unnarrow``): with a makepy cache the wrapper has no ``Count``, + ``Item`` or ``AddXRecord`` — measured 2026-09-22 on AutoCAD 2026 by + ``layer_state_restore`` in ``scripts/smoke_settings_com.py`` (the save + before it had gone through ``Dictionaries.Add``, which is typed, so + only the reopen paths failed). Every object the dictionary hands out + goes through ``_com_unnarrow`` for the same reason. + """ from engineering.environment.layer_states import DICT_NAME try: - return doc.Dictionaries.Item(DICT_NAME) + return _com_unnarrow(doc.Dictionaries.Item(DICT_NAME)) except Exception as exc: # absent log.debug("Dictionaries.Item(%s): %s", DICT_NAME, exc) if not create: return None - return doc.Dictionaries.Add(DICT_NAME) + return _com_unnarrow(doc.Dictionaries.Add(DICT_NAME)) @staticmethod def _com_dict_entries(states) -> list[tuple[str, Any]]: @@ -2163,7 +2249,7 @@ class ComBackend(AutoCADBackend): return [] out = [] for index in range(int(states.Count)): - obj = states.Item(index) + obj = _com_unnarrow(states.Item(index)) # IAcadDictionary.Item is IAcadObject* too out.append((str(states.GetName(obj)), obj)) return out @@ -2261,11 +2347,19 @@ class ComBackend(AutoCADBackend): # comes before the ActiveLayer write instead of after it in the # loop. Only a requested property is touched: without "frozen" the # refusal stands and lands in ``warnings`` like every other one. + # ``Freeze`` is written only when it changes the layer: layer 0 + # refuses the put with 'Invalid layer' whatever the value, even + # ``False`` while thawed (measured 2026-09-22, AutoCAD 2026), and + # a state saved with 0 current came back with two warnings. if "current" in props and state["current_layer"] not in missing: target_name = state["current_layer"] target = doc.Layers.Item(target_name) try: - if "frozen" in props and not bool(state["layers"][target_name]["frozen"]): + if ( + "frozen" in props + and not bool(state["layers"][target_name]["frozen"]) + and bool(target.Freeze) + ): target.Freeze = False doc.ActiveLayer = target current = target_name @@ -2278,17 +2372,17 @@ class ComBackend(AutoCADBackend): lyr = doc.Layers.Item(layer_name) try: if "color" in props: - lyr.Color = int(snap["color"]) + lyr.color = int(snap["color"]) if "linetype" in props: _ensure_linetype_loaded(snap["linetype"]) lyr.Linetype = snap["linetype"] if "lineweight" in props: - lyr.LineWeight = int(snap["lineweight"]) + lyr.Lineweight = int(snap["lineweight"]) if "plot" in props: lyr.Plottable = bool(snap["plot"]) if "on" in props: lyr.LayerOn = bool(snap["on"]) - if "frozen" in props: + if "frozen" in props and bool(lyr.Freeze) != bool(snap["frozen"]): lyr.Freeze = bool(snap["frozen"]) if "locked" in props: lyr.Lock = bool(snap["locked"]) @@ -3011,7 +3105,7 @@ class ComBackend(AutoCADBackend): continue if wanted_layer and entity.Layer != wanted_layer: continue - if color is not None and int(entity.Color) != int(color): + if color is not None and int(entity.color) != int(color): continue if wanted_linetype and entity.Linetype != wanted_linetype: continue @@ -3129,7 +3223,7 @@ class ComBackend(AutoCADBackend): "pattern": hatch.PatternName, "scale": float(hatch.PatternScale), "angle": float(hatch.PatternAngle), - "color": int(hatch.Color), + "color": int(hatch.color), "style": int(hatch.HatchStyle), } @@ -3141,7 +3235,7 @@ class ComBackend(AutoCADBackend): if angle is not None: hatch.PatternAngle = math.radians(float(angle)) if color is not None: - hatch.Color = int(color) + hatch.color = int(color) if wanted_style: hatch.HatchStyle = self._HATCH_STYLES.index(wanted_style) hatch.Evaluate() @@ -3181,7 +3275,7 @@ class ComBackend(AutoCADBackend): dump: dict = {} for member in ( "Layer", - "Color", + "color", "Linetype", "LinetypeScale", "Lineweight", @@ -4814,12 +4908,12 @@ class ComBackend(AutoCADBackend): if layer is not None: ent.Layer = layer if color is not None: - ent.Color = int(color) + ent.color = int(color) if linetype is not None: _ensure_linetype_loaded(linetype) ent.Linetype = linetype if lineweight is not None: - ent.LineWeight = normalize_lineweight(lineweight) + ent.Lineweight = normalize_lineweight(lineweight) if visible is not None: ent.Visible = bool(visible) return {"ok": True, "handle": handle} @@ -4999,13 +5093,13 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() lyr = doc.Layers.Add(name) - lyr.Color = int(color) + lyr.color = int(color) _ensure_linetype_loaded(linetype) try: lyr.Linetype = linetype except Exception as exc: log.warning("Failed to set linetype '%s' on layer '%s': %s", linetype, name, exc) - lyr.LineWeight = normalize_lineweight(lineweight) + lyr.Lineweight = normalize_lineweight(lineweight) return _layer_info(lyr, doc.ActiveLayer.Name) return await self._run(_sync) @@ -5039,12 +5133,12 @@ class ComBackend(AutoCADBackend): doc = _acad_doc() lyr = doc.Layers.Item(name) if color is not None: - lyr.Color = int(color) + lyr.color = int(color) if linetype is not None: _ensure_linetype_loaded(linetype) lyr.Linetype = linetype if lineweight is not None: - lyr.LineWeight = normalize_lineweight(lineweight) + lyr.Lineweight = normalize_lineweight(lineweight) return _layer_info(lyr, doc.ActiveLayer.Name) return await self._run(_sync) @@ -5245,7 +5339,7 @@ class ComBackend(AutoCADBackend): "Backward", "UpsideDown", ) - _ATTRIB_TEXT_MEMBERS = _ATTRIB_FRAME_MEMBERS + ("Alignment", "TextAlignmentPoint", "Color") + _ATTRIB_TEXT_MEMBERS = _ATTRIB_FRAME_MEMBERS + ("Alignment", "TextAlignmentPoint", "color") @classmethod def _capture_attrib_text_members(cls, attr) -> dict: @@ -5269,7 +5363,7 @@ class ComBackend(AutoCADBackend): continue if name == "Normal" and ocs.is_wcs_frame(raw): continue - elif name in ("Alignment", "Color"): + elif name in ("Alignment", "color"): try: raw = int(raw) except (TypeError, ValueError): @@ -5393,8 +5487,8 @@ class ComBackend(AutoCADBackend): ) mtext.InsertionPoint = point # after the attachment and the frame: both relocate it mtext.Layer = item["layer"] - if "Color" in members: - mtext.Color = members["Color"] + if "color" in members: + mtext.color = members["color"] if item["invisible"]: mtext.Visible = False return mtext @@ -5433,8 +5527,8 @@ class ComBackend(AutoCADBackend): tap = members["TextAlignmentPoint"] text.TextAlignmentPoint = _apoint(tap[0], tap[1], tap[2] if len(tap) > 2 else 0.0) text.Layer = item["layer"] - if "Color" in members: - text.Color = members["Color"] + if "color" in members: + text.color = members["color"] if item["invisible"]: text.Visible = False return text @@ -5802,7 +5896,7 @@ class ComBackend(AutoCADBackend): def _style(obj, layer: str) -> None: obj.Layer = layer - obj.Color = 0 # acByBlock + obj.color = 0 # acByBlock obj.Linetype = "ByBlock" def _sync(): diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 07abe1b..07cab5f 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -529,7 +529,7 @@ def _blockref(name, blocks, insertion=(0.0, 0.0, 0.0), sx=1.0, sy=1.0, rotation= Rotation=rotation, Handle="B1", Layer="0", - Color=256, + color=256, Linetype="ByLayer", Visible=True, **extra, diff --git a/tests/test_documents.py b/tests/test_documents.py index 16a9e08..c5809e8 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -815,3 +815,95 @@ async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend): with pytest.raises(ValueError, match="Save dialog"): await backend.drawing_close(save=True) assert app.Documents._docs[2].calls == [] + + +# ── the busy window after a document switch ────────────────────────────────── + + +class _BusyDocuments(_FakeDocuments): + """``Documents`` that answers ``RPC_E_CALL_REJECTED`` a fixed number of + times before ``Count`` works — AutoCAD's own message filter refusing the + incoming call while it is still switching documents (measured 2026-09-22 + by ``scripts/smoke_settings_com.py --build-dwt`` on AutoCAD 2026, twice: + ``Documents.Count`` right after ``Documents.Open`` and after ``Close``).""" + + def __init__(self, rejections: int): + super().__init__() + self.rejections_left = rejections + self.rejected = 0 + + @property + def Count(self): + if self.rejections_left > 0: + self.rejections_left -= 1 + self.rejected += 1 + pywintypes = pytest.importorskip("pywintypes") + raise pywintypes.com_error(-2147418111, "Call was rejected by callee.", None, None) + return super().Count + + +async def test_com_ensure_document_state_waits_out_a_rejected_call(com_backend, monkeypatch): + backend, app = com_backend + busy = _BusyDocuments(rejections=3) + busy._docs.extend(app.Documents._docs) + app.Documents = busy + monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001) + await backend._ensure_document_state() + assert busy.rejected == 3 + assert backend._document_scope_key == ("First.dwg", "C:/work/First.dwg") + + +async def test_com_ensure_document_state_gives_up_after_its_budget(com_backend, monkeypatch): + """A seat that keeps rejecting (an operator mid-command) still surfaces + the error — the retry is a window, not a wait forever.""" + backend, app = com_backend + busy = _BusyDocuments(rejections=10_000) + busy._docs.extend(app.Documents._docs) + app.Documents = busy + monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001) + monkeypatch.setattr(backend, "_REJECTED_RETRY_BUDGET_S", 0.02) + pywintypes = pytest.importorskip("pywintypes") + with pytest.raises(pywintypes.com_error) as excinfo: + await backend._ensure_document_state() + assert excinfo.value.args[0] == -2147418111 + assert 1 < busy.rejected < 100, "bounded by the budget, not by the rejection count" + + +async def test_com_ensure_document_state_does_not_retry_other_errors(com_backend): + backend, app = com_backend + pywintypes = pytest.importorskip("pywintypes") + + class _Broken(_FakeDocuments): + calls = 0 + + @property + def Count(self): + _Broken.calls += 1 + raise pywintypes.com_error(-2147417848, "disconnected", None, None) + + app.Documents = _Broken() + with pytest.raises(pywintypes.com_error): + await backend._ensure_document_state() + assert _Broken.calls == 1 + + +async def test_com_document_close_waits_out_the_rejection_after_close(com_backend, monkeypatch): + """The third measured site: ``Documents.Count`` straight after + ``doc.Close(False)`` inside ``document_close`` itself, before the + ``_ensure_document_state`` read is even reached.""" + backend, app = com_backend + busy = _BusyDocuments(rejections=0) + busy._docs.extend(app.Documents._docs) + app.Documents = busy + monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001) + untitled = busy._docs[2] + original_close = untitled.Close + + def _close_then_go_busy(save_changes): + original_close(save_changes) + busy.rejections_left = 2 + + untitled.Close = _close_then_go_busy + result = await backend.document_close("Drawing1.dwg", discard=True) + assert busy.rejected == 2 + assert result["open_documents"] == 2 and result["active"] == "Second.dwg" diff --git a/tests/test_layer_states.py b/tests/test_layer_states.py index ffb4b0a..3457e6b 100644 --- a/tests/test_layer_states.py +++ b/tests/test_layer_states.py @@ -290,6 +290,22 @@ class _FakeXRecord: self.deleted = True +class _Narrowed: + """What pywin32 hands back for ``IAcadDictionaries.Item`` and + ``IAcadDictionary.Item`` once a makepy cache exists: the gen_py + ``IAcadObject`` wrapper of the *declared* return type — no ``Count``, no + ``GetXRecordData`` — with the live object reachable only through + ``_oleobj_`` (measured 2026-09-22, AutoCAD 2026, ``layer_state_restore`` + in ``scripts/smoke_settings_com.py``: ``AttributeError: 'IAcadObject' + object has no attribute 'Count'``).""" + + def __init__(self, obj): + self._oleobj_ = obj + + def __getattr__(self, name): + raise AttributeError(f"'IAcadObject' object has no attribute {name!r}") + + class _FakeDict: def __init__(self, name): self.Name = name @@ -301,7 +317,7 @@ class _FakeDict: return len(self.entries) def Item(self, index): - return self.entries[index] + return _Narrowed(self.entries[index]) def GetName(self, obj): return obj.name @@ -328,7 +344,7 @@ class _FakeDictionaries: self.calls.append(("Item", (name,))) if name not in self.dicts: raise RuntimeError(f"no dictionary {name}") - return self.dicts[name] + return _Narrowed(self.dicts[name]) def Add(self, name): self.calls.append(("Add", (name,))) @@ -337,14 +353,19 @@ class _FakeDictionaries: class _FakeLayer: - """Models the two refusals measured live (AutoCAD 2026): the active layer - cannot be frozen, and a frozen layer cannot be made active.""" + """Models the three refusals measured live (AutoCAD 2026): the active layer + cannot be frozen, a frozen layer cannot be made active, and layer ``0`` + refuses the ``Freeze`` put with 'Invalid layer' whatever the value — even + ``False`` on the already-thawed layer (2026-09-22, scratch document: + ``Freeze=False`` on ``0`` → 'Invalid layer'; ``color`` / ``Linetype`` / + ``Lineweight`` / ``Plottable`` / ``LayerOn`` / ``Lock`` / ``ActiveLayer`` + all fine on it).""" def __init__(self, name, **props): self.Name = name - self.Color = props.get("color", 7) + self.color = props.get("color", 7) self.Linetype = props.get("linetype", "Continuous") - self.LineWeight = props.get("lineweight", -3) + self.Lineweight = props.get("lineweight", -3) self.LayerOn = props.get("on", True) self._frozen = props.get("frozen", False) self.Lock = props.get("locked", False) @@ -357,6 +378,8 @@ class _FakeLayer: @Freeze.setter def Freeze(self, value): + if self.Name == "0": + raise RuntimeError("AutoCAD COM error: Invalid layer") if value and self.document is not None and self.document.ActiveLayer is self: raise RuntimeError("AutoCAD COM error: cannot freeze the current layer") self._frozen = bool(value) @@ -447,13 +470,13 @@ async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_bac await backend.layer_state_save("S") hidden = document.Layers.Item("HIDDEN") hidden.Freeze = False - hidden.Color = 3 + hidden.color = 3 hidden.Plottable = True document.ActiveLayer = document.Layers.Item("0") result = await backend.layer_state_restore("S") assert result["applied"]["layers"] == 3 assert result["applied"]["current_layer"] == "GEOMETRY" - assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False + assert hidden.Freeze is True and hidden.color == 8 and hidden.Plottable is False assert document.ActiveLayer.Name == "GEOMETRY" assert result["missing_layers"] == [] and result["new_layers"] == [] @@ -488,17 +511,38 @@ async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(co await backend.layer_state_save("PLOT") geometry = document.Layers.Item("GEOMETRY") hidden = document.Layers.Item("HIDDEN") - hidden.Color = 3 + hidden.color = 3 document.ActiveLayer = document.Layers.Item("0") geometry.Freeze = True result = await backend.layer_state_restore("PLOT", properties=["color", "current"]) assert result["applied"]["current_layer"] is None - assert result["applied"]["layers"] == 3 and hidden.Color == 8 + assert result["applied"]["layers"] == 3 and hidden.color == 8 assert geometry.Freeze is True and document.ActiveLayer.Name == "0" (warning,) = result["warnings"] assert warning.startswith("GEOMETRY: cannot be made current: ") +async def test_com_restore_never_writes_freeze_on_layer_zero(com_backend): + """Measured live (see ``_FakeLayer``): the ``Freeze`` put on layer ``0`` + raises 'Invalid layer' even for ``False``, so a state saved with ``0`` + current used to come back with two warnings and ``0`` unapplied. A + ``Freeze`` write only happens when it would change the layer.""" + backend, document = com_backend + document.ActiveLayer = document.Layers.Item("0") + await backend.layer_state_save("ZERO") + hidden = document.Layers.Item("HIDDEN") + hidden.color = 3 + document.ActiveLayer = document.Layers.Item("GEOMETRY") + result = await backend.layer_state_restore("ZERO") + assert "warnings" not in result, result + assert result["applied"] == { + "layers": 3, + "properties": list(PROPERTIES), + "current_layer": "0", + } + assert document.ActiveLayer.Name == "0" and hidden.color == 8 + + async def test_com_list_and_delete(com_backend): backend, document = com_backend await backend.layer_state_save("A", description="one") diff --git a/tests/test_ocs_extrusion.py b/tests/test_ocs_extrusion.py index f5e9c60..ad9540e 100644 --- a/tests/test_ocs_extrusion.py +++ b/tests/test_ocs_extrusion.py @@ -262,7 +262,7 @@ def _fake_lwpolyline(coords, normal): Elevation=0.0, Handle="2F", Layer="0", - Color=256, + color=256, Linetype="ByLayer", Visible=True, Closed=False, diff --git a/tests/test_pid_graph.py b/tests/test_pid_graph.py index 1786c5d..57e7e38 100644 --- a/tests/test_pid_graph.py +++ b/tests/test_pid_graph.py @@ -582,7 +582,7 @@ def test_com_polyline_bulges_follow_points_into_wcs(): Elevation=0.0, Handle="2F", Layer="PROCESS-PIPING-MAIN", - Color=256, + color=256, Linetype="ByLayer", Visible=True, Closed=False, @@ -620,7 +620,7 @@ def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists(): Elevation=0.0, Handle="2F", Layer="0", - Color=256, + color=256, Linetype="ByLayer", Visible=True, Closed=False, @@ -885,7 +885,7 @@ def test_com_block_reference_reports_mirrored_from_its_normal(): Rotation=0.0, Handle="B1", Layer="0", - Color=256, + color=256, Linetype="ByLayer", Visible=True, Document=SimpleNamespace(Blocks=SimpleNamespace(Item=refuse)), diff --git a/tests/test_pid_line_draw.py b/tests/test_pid_line_draw.py index b8b7f84..9544d2d 100644 --- a/tests/test_pid_line_draw.py +++ b/tests/test_pid_line_draw.py @@ -234,7 +234,7 @@ def _com_pid_polyline(handle, coords, payload): "AcDbPolyline", Handle=handle, Layer="PROCESS-PIPING-MAIN", - Color=256, + color=256, Linetype="ByLayer", Visible=True, Closed=False, diff --git a/tests/test_styles.py b/tests/test_styles.py index 3d9c9ff..a0a9b41 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -795,7 +795,7 @@ class _NarrowedToIAcadEntity(_NarrowedToIAcadObject): IACADENTITY_MEMBERS = frozenset( { - "Color", + "color", "EntityTransparency", "Hyperlinks", "Layer", diff --git a/tests/test_track_a_hardening.py b/tests/test_track_a_hardening.py index ec145c6..1b3916e 100644 --- a/tests/test_track_a_hardening.py +++ b/tests/test_track_a_hardening.py @@ -483,7 +483,7 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com) UpsideDown=False, Alignment=4, TextAlignmentPoint=(-20.0, 13.0, 0.0), - Color=3, + color=3, ) ref = _FakeObject( "AcDbBlockReference", @@ -537,7 +537,7 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com) "Alignment": 4, "TextAlignmentPoint": (-20.0, 13.0, 0.0), "Layer": "PID-TAG", - "Color": 3, + "color": 3, } @@ -963,7 +963,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com): Rotation=0.5236, StyleName="ISO", Layer="PID-TAG", - Color=3, + color=3, Invisible=False, ) plain = _FakeObject( @@ -1010,7 +1010,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com): ("AttachmentPoint", 9), # acAttachmentPointBottomRight ("InsertionPoint", (7.0, 15.2, 0.0)), # re-asserted after the attachment moved it ("Layer", "PID-TAG"), - ("Color", 3), + ("color", 3), ] assert tuple(space.texts[0].InsertionPoint.value) == (7.0, 15.2, 0.0), ( "the MTEXT ends up anchored at the ATTRIB's alignment point"