From 31daac022f3075eaee6aaf969c357bee1f34d0c6 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 23:37:21 +0300 Subject: [PATCH] =?UTF-8?q?fix(task-21):=20layout=5Fcreate,=20layout=5Fset?= =?UTF-8?q?=5Fcurrent=20and=20viewport=5Fcreate=20mark=20the=20headless=20?= =?UTF-8?q?document=20dirty=20=E2=80=94=20the=20close=20refusal=20and=20th?= =?UTF-8?q?e=20saved=20row=20now=20see=20them,=20and=20each=20gets=20an=20?= =?UTF-8?q?undo=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit document_close's promise (never drop unsaved work unless told to) and the `saved` row of document_list are only as good as _DocState.dirty, and three headless mutators never set it: a new layout tab, the $TILEMODE / active layout write, and a paper-space VIEWPORT all changed the file yet closed without refusal, reported discarded_changes: False, and were absent on disk. COM refused the same sequence (Saved is AutoCAD's DBMOD), so the two engines disagreed. Each now calls _mark_dirty() after its write, which also pushes the undo snapshot every other mutator already has. The regression test runs the save -> mutate -> list/close/undo/redo -> close with save sequence for all three and checks the change reached the file. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 6 +++ tests/test_documents.py | 87 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index b119a58..739cc25 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -2035,6 +2035,7 @@ class EzdxfBackend(AutoCADBackend): if existing is not None: return {"ok": False, "error": f"Layout already exists: {existing}"} doc.layouts.new(wanted) + self._mark_dirty() return {"ok": True, "layout": wanted} return await self._async(_sync) @@ -2056,6 +2057,10 @@ class EzdxfBackend(AutoCADBackend): # Stored in the canonical spelling so _msp and _resync_space # compare against one form. self._current_space = resolved + # $TILEMODE and the active-layout binding are persisted state: + # the file on disk differs from the document after this, so the + # close refusal and the ``saved`` row must know about it. + self._mark_dirty() return {"ok": True, "current": resolved} return await self._async(_sync) @@ -2273,6 +2278,7 @@ class EzdxfBackend(AutoCADBackend): view_center_point=(view_center_x, view_center_y), view_height=view_height, ) + self._mark_dirty() return { "ok": True, "handle": viewport.dxf.handle, diff --git a/tests/test_documents.py b/tests/test_documents.py index 4a159d3..9d07181 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -25,6 +25,7 @@ import types import pytest from fastmcp import Client +import config import server from backends.ezdxf_backend import EzdxfBackend @@ -334,6 +335,92 @@ async def test_close_unsaved_refuses_unless_discard(backend): } +async def _create_layout(backend): + assert (await backend.layout_create("A3-Sheet"))["ok"] is True + + +async def _set_current_layout(backend): + assert (await backend.layout_set_current("Layout1"))["ok"] is True + + +async def _create_viewport(backend): + result = await backend.viewport_create("Layout1", 150, 100, 200, 150, 0, 0, 1.0) + assert result["ok"] is True + + +def _layout_names(path): + import ezdxf + + return set(ezdxf.readfile(str(path)).layouts.names()) + + +def _layout1_has_viewport(path): + import ezdxf + + return any( + e.dxftype() == "VIEWPORT" and e.dxf.id != 1 + for e in ezdxf.readfile(str(path)).layouts.get("Layout1") + ) + + +def _tilemode(path): + import ezdxf + + return int(ezdxf.readfile(str(path)).header.get("$TILEMODE", 1)) + + +@pytest.mark.parametrize( + ("mutate", "changed_on_disk"), + [ + (_create_layout, lambda p: "A3-Sheet" in _layout_names(p)), + (_set_current_layout, lambda p: _tilemode(p) == 0), + (_create_viewport, _layout1_has_viewport), + ], + ids=["layout_create", "layout_set_current", "viewport_create"], +) +async def test_layout_and_viewport_mutators_mark_the_document_unsaved( + monkeypatch, tmp_path, mutate, changed_on_disk +): + """The close refusal is only as good as the dirty flag behind it. + + ``layout_create``, ``layout_set_current`` and ``viewport_create`` change + the file (a new layout tab, ``$TILEMODE`` plus the active layout, a + VIEWPORT) but used to leave ``_DocState.dirty`` untouched, so + ``document_list`` reported ``saved: True``, ``document_close`` closed + without refusing and reported ``discarded_changes: False`` while the work + was gone — and the live engine, whose ``Saved`` is AutoCAD's DBMOD, + refused the same sequence. Marking dirty also gives each an undo step, + which is why undo is switched on here before the document exists (the + S0 snapshot is seeded by ``drawing_new`` only when history is enabled). + """ + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_new() + path = tmp_path / "sheet.dxf" + await backend.drawing_save(str(path)) + assert (await backend.document_list())[0]["saved"] is True + assert not changed_on_disk(path) + + await mutate(backend) + + assert (await backend.document_list())[0]["saved"] is False + with pytest.raises(ValueError, match="unsaved changes"): + await backend.document_close(None) + assert len(await backend.document_list()) == 1, "a refusal closes nothing" + + # Every mutator pushes an undo snapshot; these must too. + assert (await backend.drawing_undo())["undo_depth"] == 0 + assert (await backend.drawing_redo())["ok"] is True + + result = await backend.document_close(None, save=True) + assert result["saved"] is True and result["discarded_changes"] is False + assert changed_on_disk(path) + finally: + await backend.disconnect() + + async def test_close_with_save_writes_the_file(backend, tmp_path): path = tmp_path / "saved.dxf" await backend.drawing_save(str(path))