From 91804d5f5e6eb7c4a04ce5fe765be78ab4242ecb Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 09:19:59 +1300 Subject: [PATCH 01/16] Align: a group reduced to one shape lines up on its own centre A group whose only unlocked member is a shape (its other members locked) kept a centre of (0,0), so Centrer horizontalement and Centrer verticalement pulled every item toward the folio origin and the shape itself landed off the line. The rule that turns a group's members into one item now lives in Alignment::combined(), where every member, shapes included, brings its own centre, and tst_alignment covers it. Also comments why unitFor()'s returned reference is safe, as asked in the review of #1087. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01G2d2Zi8BfrYRPX88zhaoFG --- sources/alignment.h | 18 +++++++++++++ sources/undocommand/alignselectioncommand.cpp | 20 +++++--------- tests/qttest/tst_alignment.cpp | 27 +++++++++++++++++++ 3 files changed, 52 insertions(+), 13 deletions(-) diff --git a/sources/alignment.h b/sources/alignment.h index adc533877..c3a320c55 100644 --- a/sources/alignment.h +++ b/sources/alignment.h @@ -54,6 +54,24 @@ namespace Alignment QPointF ref; }; + /** + @return @a items taken as one piece, the way a group lines up: + their edges together, and the middle of that box as its centre. + A single item keeps its own centre. + */ + inline Item combined(const QList &items) + { + Item result; + for (const Item &item : items) + result.edges = result.edges.isNull() ? item.edges + : result.edges.united(item.edges); + if (items.size() == 1) + result.ref = items.first().ref; + else + result.ref = result.edges.center(); + return result; + } + /** @return true if aligning on @a edge moves items along x */ diff --git a/sources/undocommand/alignselectioncommand.cpp b/sources/undocommand/alignselectioncommand.cpp index 1efa08dfd..f3a63c4b7 100644 --- a/sources/undocommand/alignselectioncommand.cpp +++ b/sources/undocommand/alignselectioncommand.cpp @@ -97,12 +97,14 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC //group come along; shapes outside one are left out, as above. struct Unit { QList members; - Alignment::Item geometry; + QList parts; QGraphicsObject *snap_item = nullptr; ///< lands on its grid qreal divisor = 1; }; QList units; QHash group_units; + //Returns a reference into units, which a later call can grow: + //use it before calling again, never keep it auto unitFor = [&](QGraphicsObject *item) -> Unit & { const QUuid group = ItemGroups::groupOf(item); @@ -120,10 +122,7 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC { Unit &unit = unitFor(entry.item); unit.members << entry.item; - unit.geometry.edges = unit.geometry.edges.isNull() - ? entry.geometry.edges - : unit.geometry.edges.united(entry.geometry.edges); - unit.geometry.ref = entry.geometry.ref; + unit.parts << entry.geometry; //Symbols come first in entries, so a group with one snaps on it if (!unit.snap_item) { unit.snap_item = entry.item; @@ -136,16 +135,11 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC continue; Unit &unit = unitFor(shape); unit.members << shape; - unit.geometry.edges = unit.geometry.edges.isNull() - ? shape->sceneBoundingRect() - : unit.geometry.edges.united(shape->sceneBoundingRect()); + const QRectF rect = shape->sceneBoundingRect(); + unit.parts << Alignment::Item{rect, rect.center()}; if (!unit.snap_item) unit.snap_item = shape; } - for (Unit &unit : units) { - if (unit.members.size() > 1) - unit.geometry.ref = unit.geometry.edges.center(); - } m_item_count = units.size(); //Lining up a single item on itself would only snap it @@ -165,7 +159,7 @@ AlignSelectionCommand::AlignSelectionCommand(Diagram *diagram, Mode mode, QUndoC QList geometry; for (const Unit &unit : std::as_const(units)) - geometry << unit.geometry; + geometry << Alignment::combined(unit.parts); const QList offsets = Alignment::alignOffsets(geometry, edge); for (int i = 0 ; i < units.size() ; ++i) diff --git a/tests/qttest/tst_alignment.cpp b/tests/qttest/tst_alignment.cpp index f491ec439..5b8a8410a 100644 --- a/tests/qttest/tst_alignment.cpp +++ b/tests/qttest/tst_alignment.cpp @@ -155,6 +155,33 @@ private slots: QCOMPARE(offset.y(), 0.0); } + // A group lines up on the middle of its members' box; a unit with a + // single member, such as a shape whose group-mate is locked, keeps + // that member's own centre instead of the origin of the folio. + void combinedUnits() + { + const Alignment::Item a{QRectF(100, 200, 40, 20), QPointF(110, 210)}; + const Alignment::Item b{QRectF(300, 260, 20, 60), QPointF(310, 270)}; + + const Alignment::Item one = Alignment::combined({a}); + QCOMPARE(one.edges, a.edges); + QCOMPARE(one.ref, a.ref); + + const Alignment::Item both = Alignment::combined({a, b}); + QCOMPARE(both.edges, QRectF(100, 200, 220, 120)); + QCOMPARE(both.ref, QPointF(210, 260)); + + // a lone shape centred on x = 200 and a symbol at x = 400 meet + // half way, at 300; with the folio origin as the shape's centre + // they would meet at 200 and the shape would move 200 px + const QRectF shape(180, 50, 40, 40); + const QList offsets = Alignment::alignOffsets( + {Alignment::combined({{shape, shape.center()}}), {QRectF(390, 0, 20, 20), QPointF(400, 10)}}, + Alignment::HCenter); + QCOMPARE(offsets.at(0), QPointF(100, 0)); + QCOMPARE(offsets.at(1), QPointF(-100, 0)); + } + void emptySelection() { QVERIFY(Alignment::alignOffsets({}, Alignment::Left).isEmpty()); From c7f4a4f01d5fe30707945d5438527f20de43308c Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 11:30:08 +1300 Subject: [PATCH 02/16] Show element icons light on a dark palette in search results and the picker The collection tree draws element previews through ElementPreviewDelegate, which inverts the black line art on a dark palette. The ranked search list (#1051) and the Insert element picker (#1052) are separate views and never installed it, so their icons stayed black on a dark background. Install the delegate on the ranked list, the picker's list and the shortcut bar editor's lists, and adapt the pinned-element buttons' icons directly. Coloured icons and light palettes are unchanged. Reported in #1083. Co-Authored-By: Claude Opus 5.5 --- sources/ElementsCollection/elementpickerpopup.cpp | 11 ++++++++++- .../ElementsCollection/elementscollectionwidget.cpp | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index 5ebc14fd4..018c44cef 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -45,7 +45,9 @@ #include #include "../qetapp.h" +#include "../qetpalette.h" #include "../shortcutmanager.h" +#include "elementpreviewdelegate.h" #include "elementslocation.h" //The palette is read from disk each time the picker opens. It is meant as @@ -105,6 +107,7 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_edit_available->setIconSize(QSize(20, 20)); m_edit_available->setMinimumHeight(220); for (QListWidget *list : {m_edit_row, m_edit_available}) { + list->setItemDelegate(new ElementPreviewDelegate(list)); list->setDragDropMode(QAbstractItemView::DragDrop); list->setDefaultDropAction(Qt::MoveAction); list->setSelectionMode(QAbstractItemView::SingleSelection); @@ -166,6 +169,7 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_edit_symbols_search->setClearButtonEnabled(true); m_edit_symbols = new QListWidget(m_edit_symbols_box); m_edit_symbols->setIconSize(QSize(32, 32)); + m_edit_symbols->setItemDelegate(new ElementPreviewDelegate(m_edit_symbols)); m_edit_symbols->setMinimumHeight(220); m_edit_symbols->setDragDropMode(QAbstractItemView::DragOnly); m_edit_symbols->setDefaultDropAction(Qt::CopyAction); @@ -217,6 +221,8 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_model = new QStandardItemModel(this); m_view = new QListView(this); m_view->setModel(m_model); + //Element previews are black line art; adapt them to a dark palette + m_view->setItemDelegate(new ElementPreviewDelegate(m_view)); m_view->setIconSize(QSize(40, 40)); m_view->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); m_view->setEditTriggers(QAbstractItemView::NoEditTriggers); @@ -378,7 +384,10 @@ void ElementPickerPopup::setCommands(const QStringList &ids) auto *button = new QToolButton(m_commands); button->setAutoRaise(true); button->setIconSize(QSize(24, 24)); - button->setIcon(location.icon()); + //No item delegate on a button: adapt the preview here + button->setIcon(QIcon(QET::Palette::forPalette( + location.icon().pixmap(QSize(24, 24), devicePixelRatio()), + palette()))); button->setToolTip(location.name()); button->setFocusPolicy(Qt::NoFocus); connect(button, &QToolButton::clicked, this, [this, location]() { diff --git a/sources/ElementsCollection/elementscollectionwidget.cpp b/sources/ElementsCollection/elementscollectionwidget.cpp index 222425243..8183faaaa 100644 --- a/sources/ElementsCollection/elementscollectionwidget.cpp +++ b/sources/ElementsCollection/elementscollectionwidget.cpp @@ -27,6 +27,7 @@ #include "../qetmessagebox.h" #include "../qetproject.h" #include "elementcollectionitem.h" +#include "elementpreviewdelegate.h" #include "elementscollectionmodel.h" #include "elementslocation.h" #include "elementstreeview.h" @@ -258,6 +259,8 @@ void ElementsCollectionWidget::setUpWidget() m_search_model = new QStandardItemModel(this); m_search_results = new SearchResultsView(this); m_search_results->setModel(m_search_model); + //Same dark-palette icons as the tree the list stands in for + m_search_results->setItemDelegate(new ElementPreviewDelegate(m_search_results)); m_search_results->setDragDropMode(QAbstractItemView::DragOnly); m_search_results->setIconSize(QSize(50, 50)); m_search_results->setUniformItemSizes(false); From 9b09469a539ff10339af3f28101b902eea07f1dc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 11:53:13 +1300 Subject: [PATCH 03/16] Adapt only element icons in the shortcut bar editor, not its commands The editor's bar row and command list mix element previews with command icons. The command icons already follow the palette through the qet-dark icon theme, and running them through ElementPreviewDelegate again flattened them to one lightness (up to 16/255 per pixel off master; a two-tone icon would have had its tones swapped). Adapt the element icons once, where the items are built, instead of installing the delegate on those lists. This also covers an element dragged from the element list onto the bar, which copies the item's icon and so previously kept the dark one until the editor was reopened. Co-Authored-By: Claude Opus 5.5 --- .../ElementsCollection/elementpickerpopup.cpp | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index 018c44cef..ccc714e4e 100644 --- a/sources/ElementsCollection/elementpickerpopup.cpp +++ b/sources/ElementsCollection/elementpickerpopup.cpp @@ -55,6 +55,25 @@ //slow and the grid unusable. static const int max_palette_entries = 60; +/** + @brief elementIcon + @return the element preview @a icon, adapted to @a widget's palette at + @a size. The shortcut bar and its editor mix these with command icons, + which already follow the palette through the icon theme, so the + element icons are adapted once here rather than by an + ElementPreviewDelegate over the whole list. An item dragged from one + list to another keeps its adapted icon. +*/ +static QIcon elementIcon(const QIcon &icon, const QSize &size, const QWidget *widget) +{ + if (icon.isNull() || !QET::Palette::isDark(widget->palette())) { + return icon; + } + return QIcon(QET::Palette::forPalette( + icon.pixmap(size, widget->devicePixelRatio()), + widget->palette())); +} + /** @brief ElementPickerPopup::ElementPickerPopup @param source : the collection widget whose model the search runs against @@ -107,7 +126,6 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_edit_available->setIconSize(QSize(20, 20)); m_edit_available->setMinimumHeight(220); for (QListWidget *list : {m_edit_row, m_edit_available}) { - list->setItemDelegate(new ElementPreviewDelegate(list)); list->setDragDropMode(QAbstractItemView::DragDrop); list->setDefaultDropAction(Qt::MoveAction); list->setSelectionMode(QAbstractItemView::SingleSelection); @@ -169,7 +187,6 @@ ElementPickerPopup::ElementPickerPopup(ElementsCollectionWidget *source, m_edit_symbols_search->setClearButtonEnabled(true); m_edit_symbols = new QListWidget(m_edit_symbols_box); m_edit_symbols->setIconSize(QSize(32, 32)); - m_edit_symbols->setItemDelegate(new ElementPreviewDelegate(m_edit_symbols)); m_edit_symbols->setMinimumHeight(220); m_edit_symbols->setDragDropMode(QAbstractItemView::DragOnly); m_edit_symbols->setDefaultDropAction(Qt::CopyAction); @@ -384,10 +401,7 @@ void ElementPickerPopup::setCommands(const QStringList &ids) auto *button = new QToolButton(m_commands); button->setAutoRaise(true); button->setIconSize(QSize(24, 24)); - //No item delegate on a button: adapt the preview here - button->setIcon(QIcon(QET::Palette::forPalette( - location.icon().pixmap(QSize(24, 24), devicePixelRatio()), - palette()))); + button->setIcon(elementIcon(location.icon(), QSize(24, 24), this)); button->setToolTip(location.name()); button->setFocusPolicy(Qt::NoFocus); connect(button, &QToolButton::clicked, this, [this, location]() { @@ -551,7 +565,7 @@ QListWidgetItem *ElementPickerPopup::barItem(const QString &id, bool icon_only) const ElementsLocation location(id); if (location.exist()) { text = location.name(); - icon = location.icon(); + icon = elementIcon(location.icon(), QSize(24, 24), this); } } else if (QAction *action = commandAction(id)) { text = action->text().remove(QLatin1Char('&')); @@ -641,7 +655,8 @@ void ElementPickerPopup::runSymbolSearch() if (ElementsLocation(hit.path).isProject()) { continue; } - auto *item = new QListWidgetItem(hit.icon, hit.name); + auto *item = new QListWidgetItem( + elementIcon(hit.icon, m_edit_symbols->iconSize(), this), hit.name); item->setData(Qt::UserRole, hit.path); item->setToolTip(QStringLiteral("%1\n%2").arg(hit.name, hit.folder)); m_edit_symbols->addItem(item); From a73e5c462d55b3958d5488fce59875d285d43c23 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 11:58:05 +1300 Subject: [PATCH 04/16] qet-mcp: qet_continuity no longer reports a folio that does not exist as clean qet.checkContinuity() answers a folio index it has no folio for with an empty list, so qet_continuity returned "0 findings" for it -- the same answer as a clean folio. The index counts from 0 while qet_elements numbers folios from 1, so passing the last folio's number checked nothing and said so cleanly; any other folio's number checked the next folio instead. An index with no folio is now refused before QElectroTech is launched, with the valid range and the counting rule. Each finding also carries folio_number (counted from 1) beside the existing folio index. The qet_continuity and qet_conductors descriptions and the README say how each tool counts. Nothing changes for a valid index. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 8 ++++++++ misc/qet-mcp/qet_mcp.py | 27 +++++++++++++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6d29fe6dc..6457106b7 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -264,6 +264,14 @@ Python, plus the hang guard on `addConductor` and the database refresh in ## Notes and limits +- **Two ways of numbering folios.** Tools that read the file — + `qet_project_info`, `qet_elements`, `qet_conductors`, `qet_diff` — number + folios from 1, as the application does. Tools that pass a folio to + QElectroTech's scripting API — `qet_edit` and `qet_continuity` — take an + index counted from 0, so the folio `qet_elements` calls 1 is `0` there. + `qet_continuity` refuses an index with no folio instead of reporting it + clean, and each of its findings carries both `folio` (the index) and + `folio_number` (counted from 1). - **The project database is reachable now, through `qet_query`.** It was not when this server was written, which is why every other structural tool here re-derives its answer from the XML. Prefer the views — diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1c557067c..2372b28d0 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1582,6 +1582,17 @@ def tool_continuity(binary: str, project: str, folio: int | None = None, proj = Path(project).expanduser() if not proj.is_file(): raise ValueError(f"no such project: {proj}") + if folio is not None: + # qet.checkContinuity() answers an index it has no folio for with an + # empty list, which reads exactly like a clean folio. The index counts + # from 0 while qet_elements numbers folios from 1, so the likely + # mistake -- passing the last folio's number -- would pass silently. + count = len(list(_folios(_root(str(proj))))) + if not 0 <= folio < count: + raise ValueError( + f"folio {folio} does not exist: the project has {count} folio(s), " + f"indexed 0 to {count - 1} here. qet_continuity counts folios from 0; " + "the folio qet_elements calls N is N - 1.") folio_arg = -1 if folio is None else folio script = ("var out = qet.checkContinuity(%s);\n" @@ -1611,6 +1622,12 @@ def tool_continuity(binary: str, project: str, folio: int | None = None, result.setdefault("hint", "no findings came back at all -- this build's " "scripting API may predate qet.checkContinuity()") return result + for f in findings: + # "folio" is the 0-based index qet.checkContinuity() uses; add the + # number qet_elements and the application show, so the two can be + # matched without arithmetic. + if isinstance(f, dict) and isinstance(f.get("folio"), int): + f["folio_number"] = f["folio"] + 1 result["findings"] = findings result["finding_count"] = len(findings) result["errors"] = sum(1 for f in findings if f.get("severity") == "error") @@ -2162,7 +2179,8 @@ TOOLS = [ "type": "object", "properties": { "path": {"type": "string"}, - "folio": {"type": "integer"}, + "folio": {"type": "integer", + "description": "folio number counted from 1, as qet_elements and the application show it"}, "attribute": {"type": "string", "description": "an XML attribute of , e.g. cable"}, "non_empty": {"type": "boolean", "default": False}, @@ -2512,7 +2530,12 @@ TOOLS = [ "properties": { "binary": {"type": "string", "description": "path to the qelectrotech executable"}, "project": {"type": "string", "description": "the .qet to check; never modified"}, - "folio": {"type": "integer", "description": "check one folio only; omit for the whole project"}, + "folio": {"type": "integer", "description": + "check one folio only; omit for the whole project. An index " + "counted from 0, like qet_edit: the folio qet_elements calls 1 " + "is 0 here. An index with no folio is refused, not reported " + "clean. Each finding carries both \"folio\" (this index) and " + "\"folio_number\" (counted from 1)."}, "elements_dir": {"type": "string"}, "timeout": {"type": "integer", "default": 180}, }, diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f0827225b..e446ed3bc 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1259,6 +1259,25 @@ class QueryGuard(unittest.TestCase): m.tool_query("/x", "/no/such.qet", "SELECT 1") +class ContinuityFolioGuard(unittest.TestCase): + """qet.checkContinuity() answers a folio index it has no folio for with an + empty list, which looks like a clean folio; refuse it before launch.""" + + def test_an_index_with_no_folio_is_refused_before_launch(self): + with tempfile.TemporaryDirectory() as d: + qet = Path(d) / "p.qet" + qet.write_text("") + for folio in (2, 5, -1): + with self.subTest(folio=folio): + with self.assertRaisesRegex(ValueError, r"2 folio\(s\), indexed 0 to 1"): + m.tool_continuity("/nonexistent", str(qet), folio=folio) + + def test_the_descriptions_say_how_folios_are_counted(self): + props = {t["name"]: t["inputSchema"]["properties"] for t in m.TOOLS} + self.assertIn("counted from 0", props["qet_continuity"]["folio"]["description"]) + self.assertIn("counted from 1", props["qet_conductors"]["folio"]["description"]) + + class ProjectNewValidation(unittest.TestCase): def test_refuses_overwrite_and_bad_arguments(self): with tempfile.TemporaryDirectory() as tmp: @@ -2470,6 +2489,15 @@ class Integration(unittest.TestCase): self.assertGreater(both["finding_count"], 0) self.assertEqual(only_1["finding_count"], 0, "folio 1 has no elements at all") + def test_continuity_findings_carry_the_folio_number_qet_elements_uses(self): + base = self.sb.new(folios=2) + r = self.ok(self.sb.edit(base, [ + {"op": "add_element", "id": "a", "folio": 1, "path": COIL, "x": 100, "y": 100}])) + found = m.tool_continuity(BINARY, r["output"], folio=1, elements_dir=ELEMENTS)["findings"] + self.assertTrue(found) + self.assertEqual({(f["folio"], f["folio_number"]) for f in found}, {(1, 2)}) + self.assertEqual({e["folio"] for e in m.tool_elements(r["output"])["elements"]}, {2}) + def test_continuity_finds_a_report_link_colour_mismatch(self): """Reproduces qelectrotech/qelectrotech-source-mirror#974: a folio-jump conductor drawn in two different colours on either side From 18e5c30bedfa1a2e0ae4da57d682356adedd8f71 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 12:09:58 +1300 Subject: [PATCH 05/16] qet-mcp: a folio's wire-numbering rule is not a wire A folio's conductor auto-numbering rule is saved as . qet_project_info and qet_conductors counted every tag under the folio, so the rule appeared as a wire with no ends and no number: schema_indus.qet folio 1 reported 70 conductors where QElectroTech holds 69. Only children of are wires now. A new corpus test compares each folio's element and conductor counts with QElectroTech's own elementCount()/conductorCount() after loading the file, over every shipped example; it failed only on schema_indus.qet before this change. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 11 ++++++++-- misc/qet-mcp/test_qet_mcp.py | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1c557067c..5e937f8b5 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -118,10 +118,17 @@ def _elements(root: ET.Element): yield i, el +def _wires(diagram: ET.Element): + """The folio's conductors: children of only. A folio's wire + numbering rule is also saved as a tag, under , and + is not a wire.""" + return diagram.findall("conductors/conductor") + + def _conductors(root: ET.Element): for i, d in _folios(root): index = _terminal_index(d) - for c in d.iter("conductor"): + for c in _wires(d): yield i, c, index @@ -245,7 +252,7 @@ def tool_project_info(path: str) -> dict: "index": i, "title": d.get("title", ""), "elements": sum(1 for _ in d.iter("element")), - "conductors": sum(1 for _ in d.iter("conductor")), + "conductors": len(_wires(d)), }) return { "file": str(Path(path).expanduser()), diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f0827225b..4d7720d33 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1289,6 +1289,23 @@ class ReadTools(unittest.TestCase): # (value, how many nodes carry it) self.assertEqual([tuple(v) for v in scan["distinct_values"]], [("C1", 1)]) + def test_a_wire_numbering_rule_is_not_a_wire(self): + """A folio's conductor numbering rule is saved as ; + counting every tag reported it as a wire with no ends.""" + with tempfile.TemporaryDirectory() as tmp: + rule = ('' + '') + wire = '' + a, b = Path(tmp) / "a.qet", Path(tmp) / "b.qet" + a.write_text(f'{rule}{wire}') + b.write_text(f'{wire}') + self.assertEqual(m.tool_project_info(str(a))["folios"][0]["conductors"], 1) + self.assertEqual(m.tool_project_info(str(a))["conductor_count"], 1) + self.assertEqual([c["num"] for c in m.tool_conductors(str(a))["conductors"]], ["W1"]) + # Removing only the rule changes no wire. + c = m.tool_diff(str(a), str(b))["conductors"] + self.assertEqual((c["added"], c["removed"]), ([], [])) + # ========================================================================== # protocol @@ -2776,6 +2793,28 @@ class PlcIntegration(unittest.TestCase): @needs_examples class CorpusIntegration(unittest.TestCase): + def test_folio_counts_match_what_qelectrotech_itself_holds(self): + """Element and conductor counts per folio, from the file, against + QElectroTech's own counts after loading it -- over every example. + schema_indus.qet caught a numbering rule counted as a wire.""" + script = ('var o = [];' + 'for (var f = 0; f < qet.folioCount(); f++)' + ' o.push([qet.elementCount(f), qet.conductorCount(f)]);' + 'qet.log("COUNTS " + JSON.stringify(o));') + checked = 0 + for f in sorted(Path(EXAMPLES).glob("*.qet")): + with self.subTest(project=f.name): + r = m._run_qet(BINARY, [str(f)], elements_dir=ELEMENTS or None, + script=script, tail=200_000) + line = next(ln for ln in (r["stdout"] + "\n" + r["stderr"]).splitlines() + if "COUNTS " in ln) + qet = json.loads(line.split("COUNTS ", 1)[1]) + mcp = [[x["elements"], x["conductors"]] + for x in m.tool_project_info(str(f))["folios"]] + self.assertEqual(mcp, qet) + checked += 1 + self.assertGreater(checked, 20) + def test_conductor_keys_never_collide_across_the_shipped_examples(self): """Keying on terminal geometry alone merged nine distinct conductors of schema_indus.qet; the shipped corpus is the check.""" From c1b93a8892659ef79a326c698192e1d4f2576ce7 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 12:42:01 +1300 Subject: [PATCH 06/16] qet-mcp: pin the read tools' and qet_diff's exact answers in tests A mutation audit -- planting one small bug at a time in the server's read and diff code (a flipped comparison, a skipped branch, a dropped output field, a list cap off by one) and running this suite -- caught 78 of 241 planted bugs with the tests that need no QElectroTech binary. qet_elements, qet_conductors and their row builders caught none: a filter that never applied, a missing field or a wrong default limit all passed. Two new classes pin exact output on small hand-made projects: ReadToolContracts (qet_project_info, qet_elements, qet_conductors: every field, every filter, limit and truncation, legacy and current conductor keys) and DiffContracts (every qet_diff section: moves, relabels, info, conductors and the unstable-key warning, texts/shapes/pictures by uuid and by position, element text fields, folio and project fields, terminal strips, and every list cap). The same audit now catches 240 of 241; the one left is equivalent (rsplit("/", 1) vs rsplit("/", 2) then [-1]). Tests only; no change to the server. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 393 +++++++++++++++++++++++++++++++++++ 1 file changed, 393 insertions(+) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f0827225b..b42d5e42e 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -707,6 +707,399 @@ class WrongFolioHint(unittest.TestCase): self.assertIn("counted from 0", tool["inputSchema"]["properties"]["operations"]["description"]) +class ReadToolContracts(unittest.TestCase): + """Exact answers of the read tools on a small hand-made project. + + A mutation audit (planting small bugs in the server and running this + suite) found qet_elements, qet_conductors and qet_project_info almost + untested: a dropped output field, a filter that never applied or a + count off by one all passed. Every value here is pinned exactly.""" + + A = "{aaaaaaaa-0000-4000-8000-000000000001}" + B = "{bbbbbbbb-0000-4000-8000-000000000002}" + C = "{cccccccc-0000-4000-8000-000000000003}" + D = "{dddddddd-0000-4000-8000-000000000004}" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.qet = str(Path(self.tmp.name) / "p.qet") + A, B, C, D = self.A, self.B, self.C, self.D + Path(self.qet).write_text( + '' + '' + f'' + '' + '' + '' + 'K1' + ' spaced ' + 'no name, ignored' + '' + f'' + f'' + '' + '' + f'' + '' + '' + f'' + '' + f'' + '') + + def tearDown(self): + self.tmp.cleanup() + + def test_project_info_exact(self): + self.assertEqual(m.tool_project_info(self.qet), { + "file": self.qet, "title": "T", "version": "0.100", + "folio_count": 2, "element_count": 4, "conductor_count": 3, + "folios": [{"index": 1, "title": "One", "elements": 3, "conductors": 2}, + {"index": 2, "title": "Two", "elements": 1, "conductors": 1}]}) + + def test_elements_rows_exact(self): + r = m.tool_elements(self.qet) + self.assertEqual((r["count"], r["truncated"]), (4, False)) + self.assertEqual(r["elements"][0], { + "folio": 1, "uuid": self.A, "type": "embed://import/x/Coil.elmt", "name": "Coil", + "x": "10", "y": "20", "label": "K1", + # values as QElectroTech saves them (trimmed); a field with no name is skipped + "info": {"label": "K1", "comment": "spaced"}}) + self.assertEqual([(e["folio"], e["name"], e["label"], e["info"]) for e in r["elements"][1:]], + [(1, "relay_coil", "", {}), (1, "lamp", "", {}), (2, "Coil", "", {})]) + + def test_elements_filters_and_limit(self): + names = lambda r: [e["name"] for e in r["elements"]] + self.assertEqual(names(m.tool_elements(self.qet, folio=2)), ["Coil"]) + self.assertEqual(names(m.tool_elements(self.qet, folio=1)), ["Coil", "relay_coil", "lamp"]) + # case-insensitive substring of the definition's file name + self.assertEqual(names(m.tool_elements(self.qet, name_contains="COIL")), + ["Coil", "relay_coil", "Coil"]) + self.assertEqual(names(m.tool_elements(self.qet, folio=1, name_contains="coil")), + ["Coil", "relay_coil"]) + r = m.tool_elements(self.qet, limit=2) + self.assertEqual((r["count"], r["truncated"], len(r["elements"])), (4, True, 2)) + r = m.tool_elements(self.qet, limit=4) + self.assertEqual((r["count"], r["truncated"], len(r["elements"])), (4, False, 4)) + + def test_conductor_rows_exact(self): + r = m.tool_conductors(self.qet) + self.assertEqual((r["count"], r["truncated"]), (3, False)) + first, second, third = r["conductors"] + self.assertEqual(first, { + "folio": 1, "uuid": "", "num": "W1", "formula": "F", "cable": "C1", "bus": "B", + "function": "L1", "color": "red", "section": "1.5", "type": "multi", + # legacy ends resolved through the folio's terminal ids + "key": f"1:{self.A}@0,-10,n--{self.A}@0,10,s"}) + # current ends: instance/terminal; an empty terminal falls back to its name + self.assertEqual(second["key"], f"1:{self.B}/A1--{self.C}/{{t2}}") + self.assertEqual((second["num"], second["cable"]), ("W2", " ")) + self.assertEqual((third["folio"], third["key"]), (2, f"2:{self.D}/{{t1}}--{self.D}/{{t2}}")) + + def test_conductor_filters_and_limit(self): + nums = lambda r: [c["num"] for c in r["conductors"]] + self.assertEqual(nums(m.tool_conductors(self.qet, folio=1)), ["W1", "W2"]) + self.assertEqual(nums(m.tool_conductors(self.qet, folio=2)), [""]) + r = m.tool_conductors(self.qet, attribute="cable") + self.assertEqual([c["value"] for c in r["conductors"]], ["C1", " ", ""]) + # non_empty drops empty and whitespace-only values + r = m.tool_conductors(self.qet, attribute="cable", non_empty=True) + self.assertEqual([c["value"] for c in r["conductors"]], ["C1"]) + # non_empty alone, with no attribute, filters nothing + self.assertEqual(m.tool_conductors(self.qet, non_empty=True)["count"], 3) + self.assertNotIn("value", m.tool_conductors(self.qet)["conductors"][0]) + r = m.tool_conductors(self.qet, limit=2) + self.assertEqual((r["count"], r["truncated"], len(r["conductors"])), (3, True, 2)) + r = m.tool_conductors(self.qet, limit=3) + self.assertEqual((r["count"], r["truncated"], len(r["conductors"])), (3, False, 3)) + + def test_default_limit_is_200_rows(self): + els = "".join(f'' for i in range(201)) + wires = "".join(f'' for i in range(201)) + big = Path(self.tmp.name) / "big.qet" + big.write_text(f'{els}' + f'{wires}') + for tool, key in ((m.tool_elements, "elements"), (m.tool_conductors, "conductors")): + with self.subTest(tool=key): + r = tool(str(big)) + self.assertEqual((r["count"], r["truncated"], len(r[key])), (201, True, 200)) + + def test_conductor_row_without_an_index(self): + c = ET.fromstring('') + self.assertEqual(m._conductor_row(3, c)["key"], "3:#7--#8") + + def test_plain_text(self): + self.assertEqual(m._plain_text("

a & b

\n

c

"), + "a & b c") + # no : the whole string is the text + self.assertEqual(m._plain_text("plain x"), "plain x") + self.assertEqual(m._plain_text(None), "") + + +class DiffContracts(unittest.TestCase): + """Exact qet_diff output, section by section, on hand-made pairs. + + The mutation audit found most of tool_diff's output unchecked: a dropped + field of a move, of a conductor change or of a text record, a skipped + comparison, or a list cap off by one all passed the suite.""" + + A = "{aaaaaaaa-0000-4000-8000-000000000001}" + B = "{bbbbbbbb-0000-4000-8000-000000000002}" + C = "{cccccccc-0000-4000-8000-000000000003}" + D = "{dddddddd-0000-4000-8000-000000000004}" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.n = 0 + + def tearDown(self): + self.tmp.cleanup() + + def qet(self, body, title="T"): + self.n += 1 + p = Path(self.tmp.name) / f"p{self.n}.qet" + p.write_text(f'{body}') + return str(p) + + @staticmethod + def el(uuid, x, y, label=None, comment=None, type_="x/coil.elmt", texts=""): + info = "".join(f'{v}' + for n, v in (("label", label), ("comment", comment)) if v is not None) + u = f' uuid="{uuid}"' if uuid else "" + return (f'' + f'{info}' + f'{texts}
') + + @staticmethod + def wire(e1, e2, **attrs): + a = "".join(f' {k}="{v}"' for k, v in attrs.items()) + return f'' + + def folio(self, elements="", wires="", extra="", **attrs): + a = "".join(f' {k}="{v}"' for k, v in attrs.items()) + return (f'{elements}' + f'{wires}{extra}') + + def test_elements_section_exact(self): + A, B, C, D = self.A, self.B, self.C, self.D + before = self.qet(self.folio( + self.el(A, 10, 20, "K1", "c") + self.el(B, 0, 0) + self.el(C, 5, 5) + + self.el("", 1, 1, type_="x/old.elmt"))) + after = self.qet(self.folio( + self.el(A, 15, 20, "K2", "d") + self.el(B, 0, 0) + self.el(D, 7, 7) + + self.el("", 2, 2, type_="x/old.elmt"))) + self.assertEqual(m.tool_diff(before, after)["elements"], { + "before": 4, "after": 4, + # no uuid: keyed on folio, position and name, so a move is remove + add + "added": sorted([D, "1:2,2:old"]), "removed": sorted([C, "1:1,1:old"]), + "moved": [{"uuid": A, "name": "coil", "folio": 1, "from": ["10", "20"], + "to": ["15", "20"], "delta": [5.0, 0.0]}], + "moved_count": 1, "distinct_move_deltas": [[5.0, 0.0]], + "relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}], + "info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"}, + "to": {"label": "K2", "comment": "d"}}]}) + + def test_an_unchanged_element_reports_nothing(self): + p = self.qet(self.folio(self.el(self.A, 1, 2, "K1", "c"))) + e = m.tool_diff(p, p)["elements"] + self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"]), + ([], [], [], [], [])) + + def test_conductors_section_exact(self): + A, B, C = self.A, self.B, self.C + els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0) + before = self.qet(self.folio(els, self.wire(A, B, num="W1", cable="C1") + + self.wire(B, C, num="W2"))) + after = self.qet(self.folio(els, self.wire(A, B, num="W9", cable="C1", conductor_color="red") + + self.wire(A, C, num="W3"))) + k = lambda x, y: f"1:{x}/{{t1}}--{y}/{{t2}}" + self.assertEqual(m.tool_diff(before, after)["conductors"], { + "before": 2, "after": 2, "added": [k(A, C)], "removed": [k(B, C)], + "changed": [{"key": k(A, B), "changed": {"num": ["W1", "W9"], "color": ["", "red"]}}], + "changed_count": 1}) + + def test_unresolvable_conductor_ends_are_flagged(self): + p = self.qet(self.folio(self.el("", 0, 0), + '')) + c = m.tool_diff(p, p)["conductors"] + self.assertEqual(c["unstable_keys"], 1) + self.assertIn("renumbers on save", c["warning"]) + q = self.qet(self.folio(self.el(self.A, 0, 0) + self.el(self.B, 0, 0), + self.wire(self.A, self.B))) + self.assertNotIn("unstable_keys", m.tool_diff(q, q)["conductors"]) + + def test_list_caps(self): + many = lambda n, x: "".join(self.el(f"{{{i:08d}-0000-4000-8000-000000000000}}", x, 0) + for i in range(n)) + e = m.tool_diff(self.qet(self.folio(many(101, 0))), self.qet(self.folio(many(101, 5))))["elements"] + self.assertEqual((len(e["moved"]), e["moved_count"]), (100, 101)) + e = m.tool_diff(self.qet(self.folio(many(51, 0))), self.qet(self.folio("")))["elements"] + self.assertEqual((len(e["removed"]), e["before"]), (50, 51)) + e = m.tool_diff(self.qet(self.folio("")), self.qet(self.folio(many(51, 0))))["elements"] + self.assertEqual(len(e["added"]), 50) + + def test_texts_shapes_images_by_uuid(self): + def extras(x, color, uuid=True): + u = lambda s: f' uuid="{{{s}}}"' if uuid else "" + return (f'' + f'' + f'' + f'') + d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000"))), + self.qet(self.folio(extra=extras(4, "#f00")))) + # A uuid-keyed item is labelled as it is after the edit; "changed" has both. + self.assertEqual(d["texts"]["changed"], [{ + "item": {"folio": 1, "x": "4", "y": "2", "text": "note", "uuid": "{t}"}, + "changed": {"x": ["1", "4"], "color": ["#000", "#f00"]}}]) + self.assertEqual(d["shapes"]["changed"], [{ + "item": {"folio": 1, "type": "Rectangle", "from": ["4", "0"], "to": ["9", "9"], "uuid": "{s}"}, + "changed": {"from": [["1", "0"], ["4", "0"]], "line_color": ["#000", "#f00"]}}]) + self.assertEqual(d["images"]["changed"], [{ + "item": {"folio": 1, "x": "4", "y": "3", "uuid": "{i}"}, "changed": {"x": ["1", "4"]}}]) + for kind in ("texts", "shapes", "images"): + self.assertEqual((d[kind]["keyed_by"], d[kind]["before"], d[kind]["after"]), ("uuid", 1, 1)) + # without uuids a move is a removal plus an addition, keyed by position + d = m.tool_diff(self.qet(self.folio(extra=extras(1, "#000", uuid=False))), + self.qet(self.folio(extra=extras(4, "#000", uuid=False)))) + self.assertEqual((d["images"]["keyed_by"], len(d["images"]["added"]), len(d["images"]["removed"])), + ("position", 1, 1)) + + def test_shape_style_fields(self): + def shape(style, width, brush): + return (f'' + f'{brush}') + d = m.tool_diff( + self.qet(self.folio(extra=shape("SolidLine", 1, ''))), + self.qet(self.folio(extra=shape("DashLine", 2, '')))) + self.assertEqual(d["shapes"]["changed"][0]["changed"], + {"line_style": ["SolidLine", "DashLine"], "line_width": ["1", "2"], + "fill": ["none", "#0f0"]}) + + def test_element_text_fields(self): + def field(x, size, shows): + return (f'{shows}' + 'label') + d = m.tool_diff(self.qet(self.folio(self.el(self.A, 0, 0, texts=field(3, 9, "K1")))), + self.qet(self.folio(self.el(self.A, 0, 0, texts=field(6, 9, "K2"))))) + self.assertEqual(d["element_texts"]["changed"], [{ + "item": {"element": self.A, "source": "ElementInfo", "bound_to": "label", "n": 1}, + "changed": {"x": ["3", "6"], "shows": ["K1", "K2"]}}]) + + def test_folio_and_project_fields(self): + d = m.tool_diff(self.qet(self.folio(title="A", cols="10"), title="P"), + self.qet(self.folio(title="B", cols="10"), title="Q")) + self.assertEqual(d["project"], {"changed": {"title": ["P", "Q"]}}) + self.assertEqual(d["folios"], {"before": 1, "after": 1, + "changed": [{"folio": 1, "changed": {"title": ["A", "B"]}}]}) + d = m.tool_diff(self.qet(self.folio(title="A")), + self.qet(self.folio(title="B") + self.folio(title="C"))) + self.assertIn("folio count changed", d["folios"]["note"]) + d = m.tool_diff(self.qet(self.folio(title="A")), + self.qet(self.folio(title="A") + self.folio(title="C"))) + self.assertNotIn("note", d["folios"]) + + def test_every_compared_field_is_reported_on_its_own(self): + """Change one attribute at a time; each must show as exactly that field.""" + def changed_fields(before, after, kind): + d = m.tool_diff(self.qet(self.folio(extra=before)), self.qet(self.folio(extra=after))) + return [list(c["changed"]) for c in d[kind]["changed"]] + def text(**v): + a = {"rotation": "0", "font": "f", "color": "c", **v} + return ('') + def shape(rot): + return (f'') + def image(**v): + a = {"size": "1", "rotation": "0", **v} + return ('') + for kind, before, after, field in ( + ("texts", text(), text(rotation="90"), "rotation"), + ("texts", text(), text(font="g"), "font"), + ("shapes", shape(0), shape(90), "rotation"), + ("images", image(), image(rotation="90"), "rotation"), + ("images", image(), image(size="2"), "scale")): + with self.subTest(kind=kind, field=field): + self.assertEqual(changed_fields(before, after, kind), [[field]]) + + def test_every_element_text_field_attribute(self): + def field(**v): + a = {"x": "0", "y": "0", "frame": "false", "rotation": "0", "text_width": "-1", + "font": "Sans,9,-1", **v} + return self.el(self.A, 0, 0, texts=( + "K1' + '%{label}')) + for attr, value, reported in (("y", "5", "y"), ("rotation", "90", "rotation"), + ("text_width", "40", "width"), ("frame", "true", "frame"), + ("font", "Sans,12,-1", "size")): + with self.subTest(attr=attr): + d = m.tool_diff(self.qet(self.folio(field())), + self.qet(self.folio(field(**{attr: value})))) + ch = d["element_texts"]["changed"] + self.assertEqual([list(c["changed"]) for c in ch], [[reported]]) + # a composite field is identified by its formula + self.assertEqual(ch[0]["item"]["bound_to"], "%{label}") + + def test_an_unchanged_conductor_is_not_listed_as_changed(self): + A, B, C = self.A, self.B, self.C + els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0) + before = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W2"))) + after = self.qet(self.folio(els, self.wire(A, B, num="W1") + self.wire(B, C, num="W5"))) + c = m.tool_diff(before, after)["conductors"] + self.assertEqual([x["changed"] for x in c["changed"]], [{"num": ["W2", "W5"]}]) + + def test_a_strip_without_its_data_is_skipped(self): + p = self.qet(self.folio() + '') + self.assertEqual(m.tool_diff(p, p)["terminal_strips"]["before"], 0) + + def test_every_list_is_capped(self): + U = lambda i: f"{{{i:08d}-0000-4000-8000-000000000000}}" + many = lambda n, label: "".join(self.el(U(i), 0, 0, label) for i in range(n)) + e = m.tool_diff(self.qet(self.folio(many(51, "a"))), + self.qet(self.folio(many(51, "b"))))["elements"] + self.assertEqual((len(e["relabelled"]), len(e["info_changed"])), (50, 50)) + els = many(102, None) + wires = lambda n, num: "".join(self.wire(U(i), U(i + 1), num=num) for i in range(n)) + c = m.tool_diff(self.qet(self.folio(els, wires(101, "a"))), + self.qet(self.folio(els, wires(101, "b"))))["conductors"] + self.assertEqual((len(c["changed"]), c["changed_count"]), (100, 101)) + c = m.tool_diff(self.qet(self.folio(els, wires(51, "a"))), self.qet(self.folio(els)))["conductors"] + self.assertEqual(len(c["removed"]), 50) + c = m.tool_diff(self.qet(self.folio(els)), self.qet(self.folio(els, wires(51, "a"))))["conductors"] + self.assertEqual(len(c["added"]), 50) + folios = lambda t: "".join(self.folio(title=f"{t}{i}") for i in range(51)) + f = m.tool_diff(self.qet(folios("a")), self.qet(folios("b")))["folios"] + self.assertEqual(len(f["changed"]), 50) + # _diff_keyed's own caps, through the text records + texts = lambda n, x: "" + "".join( + f'' for i in range(n)) + "" + t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))), + self.qet(self.folio(extra=texts(51, 1))))["texts"] + self.assertEqual(len(t["changed"]), 50) + t = m.tool_diff(self.qet(self.folio(extra=texts(51, 0))), self.qet(self.folio()))["texts"] + self.assertEqual(len(t["removed"]), 50) + t = m.tool_diff(self.qet(self.folio()), self.qet(self.folio(extra=texts(51, 0))))["texts"] + self.assertEqual(len(t["added"]), 50) + + def test_terminal_strips(self): + def strip(name, n): + terms = "".join(f'' for _ in range(n)) + return ('' + 'I' + 'L' + f'{name}' + f'{terms}') + d = m.tool_diff(self.qet(self.folio() + strip("X1", 2)), self.qet(self.folio() + strip("X1", 3))) + self.assertEqual(d["terminal_strips"]["changed"], + [{"item": "I L X1", "changed": {"terminals": [2, 3]}}]) + + class Diff(unittest.TestCase): def setUp(self): self.tmp = tempfile.TemporaryDirectory() From e64ce0a6ba4a43f9392be7e77674a4ff5a3619cc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 12:55:33 +1300 Subject: [PATCH 07/16] qet-mcp: qet_diff reports rotated symbols; equal angles compare equal Rotating a symbol changes only its orientation attribute (quarter turns), which qet_diff did not read, so a pure rotation reported no change in any section. The elements section now has "rotated": each symbol whose orientation changed, with the value before and after. Rotating and undoing leaves QElectroTech writing text-field rotations as "-270" where they were "90" (or "-90" for "270"); compared as strings that read as a change. Rotations of texts, shapes, pictures and element text fields are now compared reduced to [0, 360). Found by a seeded-edit invariant run over the shipped examples: 51 rotations unreported across 20 projects, and 7 undo sequences reported as changes. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 37 ++++++++++++++++++++++++++++++------ misc/qet-mcp/test_qet_mcp.py | 36 ++++++++++++++++++++++++++++++++--- 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1c557067c..756ee9738 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -312,6 +312,21 @@ def _plain_text(html: str) -> str: return " ".join(inner.split()) +def _angle(value: str) -> str: + """A rotation in degrees, reduced to [0, 360) so equal angles compare equal. + + QElectroTech writes the same angle in more than one way: rotating a symbol + and undoing it leaves its text fields at "-270" where they were "90", or + "-90" where they were "270". Compared as written, that read as a change. + Anything that is not a number is returned unchanged. + """ + try: + deg = float(value) % 360 + except (TypeError, ValueError): + return value + return f"{deg:g}" + + def _extras(root: ET.Element) -> dict: """Everything a folio holds besides elements and conductors. @@ -333,7 +348,7 @@ def _extras(root: ET.Element) -> dict: texts.append(record( t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""), "text": _plain_text(t.get("text", ""))}, - {"rotation": t.get("rotation", "0"), + {"rotation": _angle(t.get("rotation", "0")), "font": t.get("font", ""), "color": t.get("color", "")})) for sh in d.iter("shape"): pen, brush = sh.find("pen"), sh.find("brush") @@ -346,11 +361,11 @@ def _extras(root: ET.Element) -> dict: "line_width": pen.get("widthF", "") if pen is not None else "", "fill": (brush.get("color", "") if brush is not None and brush.get("style", "") != "NoBrush" else "none"), - "rotation": sh.get("rotation", "0")})) + "rotation": _angle(sh.get("rotation", "0"))})) for im in d.iter("image"): images.append(record( im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")}, - {"scale": im.get("size", ""), "rotation": im.get("rotation", "")})) + {"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))})) element_texts = {} for n, d in _folios(root): @@ -367,7 +382,7 @@ def _extras(root: ET.Element) -> dict: fs = (t.get("font", "").split(",") + ["", ""])[1] element_texts[base + (seen[base],)] = { "x": t.get("x", ""), "y": t.get("y", ""), "size": fs, - "frame": t.get("frame", ""), "rotation": t.get("rotation", ""), + "frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")), "width": t.get("text_width", ""), "shows": t.findtext("text") or ""} @@ -472,16 +487,24 @@ def tool_diff(before: str, after: str) -> dict: a_el, b_el = {}, {} for i, e in _elements(_root(before)): r = _element_row(i, e) + # Rotation is saved as "orientation", in quarter turns (0-3); it is + # the only thing a rotation changes, so without it a rotated symbol + # reads as untouched. + r["orientation"] = e.get("orientation", "0") a_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r for i, e in _elements(_root(after)): r = _element_row(i, e) + r["orientation"] = e.get("orientation", "0") b_el[r["uuid"] or f"{i}:{r['x']},{r['y']}:{r['name']}"] = r - moved, relabelled, changed_info = [], [], [] + moved, rotated, relabelled, changed_info = [], [], [], [] for k, a in a_el.items(): b = b_el.get(k) if b is None: continue + if a["orientation"] != b["orientation"]: + rotated.append({"uuid": k, "name": a["name"], "folio": a["folio"], + "orientation": [a["orientation"], b["orientation"]]}) if (a["x"], a["y"]) != (b["x"], b["y"]): moved.append({ "uuid": k, "name": a["name"], "folio": a["folio"], @@ -540,6 +563,7 @@ def tool_diff(before: str, after: str) -> dict: "distinct_move_deltas": [list(d) for d in deltas], "relabelled": relabelled[:50], "info_changed": changed_info[:50], + "rotated": rotated[:50], }, "conductors": { "before": len(a_co), "after": len(b_co), @@ -2178,7 +2202,8 @@ TOOLS = [ { "name": "qet_diff", "description": "Structurally diff two .qet files: which elements moved and " - "by what delta, which were added, removed or relabelled, and " + "by what delta, which were rotated (orientation in quarter " + "turns, 0-3), which were added, removed or relabelled, and " "which conductor fields changed. Use this to verify what an " "edit actually did, rather than reading a screenshot.", "inputSchema": { diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index b42d5e42e..66d96db74 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -899,13 +899,39 @@ class DiffContracts(unittest.TestCase): "moved_count": 1, "distinct_move_deltas": [[5.0, 0.0]], "relabelled": [{"uuid": A, "name": "coil", "from": "K1", "to": "K2"}], "info_changed": [{"uuid": A, "name": "coil", "from": {"label": "K1", "comment": "c"}, - "to": {"label": "K2", "comment": "d"}}]}) + "to": {"label": "K2", "comment": "d"}}], + "rotated": []}) def test_an_unchanged_element_reports_nothing(self): p = self.qet(self.folio(self.el(self.A, 1, 2, "K1", "c"))) e = m.tool_diff(p, p)["elements"] - self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"]), - ([], [], [], [], [])) + self.assertEqual((e["moved"], e["relabelled"], e["info_changed"], e["added"], e["removed"], + e["rotated"]), ([], [], [], [], [], [])) + + def test_equal_angles_written_differently_are_the_same(self): + self.assertEqual([m._angle(v) for v in ("-270", "90", "-90", "270", "360", "0", "450", "12.5", "", "x")], + ["90", "90", "270", "270", "0", "0", "90", "12.5", "", "x"]) + def field(rot): + return self.el(self.A, 0, 0, texts=( + f't' + '')) + d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("-270")))) + self.assertEqual(d["element_texts"]["changed"], []) + d = m.tool_diff(self.qet(self.folio(field("90"))), self.qet(self.folio(field("180")))) + self.assertEqual(d["element_texts"]["changed"][0]["changed"], {"rotation": ["90", "180"]}) + + def test_a_rotation_is_reported(self): + """Rotating a symbol changes only its orientation (quarter turns); a diff + that ignored it reported a rotation as no change at all.""" + rot = lambda o: self.qet(self.folio( + self.el(self.A, 1, 2).replace('x="1"', f'orientation="{o}" x="1"'))) + d = m.tool_diff(rot(0), rot(1)) + self.assertEqual(d["elements"]["rotated"], + [{"uuid": self.A, "name": "coil", "folio": 1, "orientation": ["0", "1"]}]) + self.assertEqual((d["elements"]["moved"], d["elements"]["relabelled"]), ([], [])) + # no attribute is orientation 0, as QElectroTech reads it + plain = self.qet(self.folio(self.el(self.A, 1, 2))) + self.assertEqual(m.tool_diff(plain, rot(0))["elements"]["rotated"], []) def test_conductors_section_exact(self): A, B, C = self.A, self.B, self.C @@ -1064,6 +1090,10 @@ class DiffContracts(unittest.TestCase): e = m.tool_diff(self.qet(self.folio(many(51, "a"))), self.qet(self.folio(many(51, "b"))))["elements"] self.assertEqual((len(e["relabelled"]), len(e["info_changed"])), (50, 50)) + turned = lambda o: "".join(self.el(U(i), 0, 0).replace('x="0"', f'orientation="{o}" x="0"') + for i in range(51)) + e = m.tool_diff(self.qet(self.folio(turned(0))), self.qet(self.folio(turned(2))))["elements"] + self.assertEqual(len(e["rotated"]), 50) els = many(102, None) wires = lambda n, num: "".join(self.wire(U(i), U(i + 1), num=num) for i in range(n)) c = m.tool_diff(self.qet(self.folio(els, wires(101, "a"))), From f5888e8b5663fc74c5b570f6524201a384b503c0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:03:06 +1300 Subject: [PATCH 08/16] qet-mcp: qet_diff matches conductors, folios, text fields and tables by uuid QElectroTech now saves a uuid on conductors (those created since, or loaded with one), folios, symbol text fields and tables, but qet_diff still matched them by position or by ends: - conductors: a rewire read as one wire removed and another added; by uuid it is that conductor with changed "ends". - folios: a reorder read as every later folio changing its fields; by uuid it is one "reordered" entry, plus "added"/"removed" folios. - symbol text fields: deleting the first of two read as the second changing; by uuid it is that field removed. - tables () were not compared at all; now a section of their own. Each is matched by uuid only when every item of that kind on both sides has one; otherwise the old matching is kept, and each section says which in "keyed_by", since older files and their first re-save mix the two. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 140 ++++++++++++++++++++++++++--------- misc/qet-mcp/test_qet_mcp.py | 70 +++++++++++++++++- 2 files changed, 174 insertions(+), 36 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 756ee9738..9cf5d64e2 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -336,7 +336,7 @@ def _extras(root: ET.Element) -> dict: Older files have only position to go on, and there a change reads as the old one removed and a new one added, with both shown. """ - folios, texts, shapes, images = {}, [], [], [] + folios, folio_uuids, texts, shapes, images, tables = {}, {}, [], [], [], [] def record(el, label, value): return {"uuid": el.get("uuid", ""), "key": tuple(label.values()), @@ -344,6 +344,12 @@ def _extras(root: ET.Element) -> dict: for n, d in _folios(root): folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS} + folio_uuids[n] = d.get("uuid", "") + for tb in d.iter("graphics_table"): + tables.append(record( + tb, {"folio": n, "name": tb.get("name", "")}, + {"x": tb.get("x", ""), "y": tb.get("y", ""), "width": tb.get("width", ""), + "height": tb.get("height", ""), "rows_shown": tb.get("display_n_row", "")})) for t in d.iter("input"): texts.append(record( t, {"folio": n, "x": t.get("x", ""), "y": t.get("y", ""), @@ -367,7 +373,7 @@ def _extras(root: ET.Element) -> dict: im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")}, {"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))})) - element_texts = {} + element_texts, element_text_uuids = {}, {} for n, d in _folios(root): for el in d.iter("element"): uuid = el.get("uuid", "") @@ -380,6 +386,7 @@ def _extras(root: ET.Element) -> dict: base = (uuid, src, what) seen[base] = seen.get(base, 0) + 1 fs = (t.get("font", "").split(",") + ["", ""])[1] + element_text_uuids[base + (seen[base],)] = t.get("uuid", "") element_texts[base + (seen[base],)] = { "x": t.get("x", ""), "y": t.get("y", ""), "size": fs, "frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")), @@ -397,8 +404,9 @@ def _extras(root: ET.Element) -> dict: "location": info.get("location", ""), "name": info.get("name", ""), "terminals": sum(1 for _ in st.iter("real_terminal"))} - return {"folios": folios, "texts": texts, "shapes": shapes, - "images": images, "strips": strips, "element_texts": element_texts} + return {"folios": folios, "folio_uuids": folio_uuids, "texts": texts, "shapes": shapes, + "images": images, "tables": tables, "strips": strips, + "element_texts": element_texts, "element_text_uuids": element_text_uuids} def _diff_keyed(a: dict, b: dict, label) -> dict: @@ -440,34 +448,84 @@ def _diff_items(a: list, b: list) -> dict: return out +def _diff_folios(a: dict, b: dict) -> dict: + """Folio fields, keyed by the folio's uuid when every folio has one. + + By uuid, a folio moved to another position is reported once, under + "reordered", instead of as every folio after it changing its fields. + Without uuids (older files) folios are keyed by position, and a + removal or reorder in the middle shifts every later index -- the note + says so when the count changed. + """ + ua, ub = a["folio_uuids"], b["folio_uuids"] + by_uuid = bool(ua or ub) and all(ua.values()) and all(ub.values()) + changed, reordered, added, removed = [], [], [], [] + if by_uuid: + pos_a = {u: n for n, u in ua.items()} + pos_b = {u: n for n, u in ub.items()} + for u in sorted(set(pos_a) & set(pos_b), key=lambda u: pos_b[u]): + fa, fb = a["folios"][pos_a[u]], b["folios"][pos_b[u]] + delta = {f: [fa[f], fb[f]] for f in _FOLIO_FIELDS if fa[f] != fb[f]} + if delta: + changed.append({"folio": pos_b[u], "uuid": u, "changed": delta}) + if pos_a[u] != pos_b[u]: + reordered.append({"uuid": u, "title": fb["title"], + "from": pos_a[u], "to": pos_b[u]}) + added = [{"folio": pos_b[u], "uuid": u, "title": b["folios"][pos_b[u]]["title"]} + for u in sorted(set(pos_b) - set(pos_a), key=lambda u: pos_b[u])] + removed = [{"folio": pos_a[u], "uuid": u, "title": a["folios"][pos_a[u]]["title"]} + for u in sorted(set(pos_a) - set(pos_b), key=lambda u: pos_a[u])] + else: + for n in sorted(set(a["folios"]) & set(b["folios"])): + delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS + if a["folios"][n][f] != b["folios"][n][f]} + if delta: + changed.append({"folio": n, "changed": delta}) + out = {"before": len(a["folios"]), "after": len(b["folios"]), + "keyed_by": "uuid" if by_uuid else "position", "changed": changed[:50]} + if by_uuid: + out.update(added=added[:50], removed=removed[:50], reordered=reordered[:50]) + elif len(a["folios"]) != len(b["folios"]) and changed: + out["note"] = ("the folio count changed, so changes listed here may be " + "later folios shifting position rather than edits") + return out + + +def _diff_element_texts(a: dict, b: dict) -> dict: + """Element text fields, keyed by their own uuid when every field has one. + + Otherwise by element, what the field is bound to, and the nth such field + -- which cannot tell a field that was removed from one that moved down + the list. A field's own text is also compared ("shows"), so relabelling + an element shows up here as well as in the element's information. + """ + ka, kb = a["element_text_uuids"], b["element_text_uuids"] + by_uuid = bool(ka or kb) and all(ka.values()) and all(kb.values()) + def label(k): + return {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]} + if not by_uuid: + out = _diff_keyed(a["element_texts"], b["element_texts"], label) + out["keyed_by"] = "position" + return out + labels = {u: {**label(k), "uuid": u} for side in (ka, kb) for k, u in side.items()} + out = _diff_keyed({ka[k]: v for k, v in a["element_texts"].items()}, + {kb[k]: v for k, v in b["element_texts"].items()}, + lambda u: labels[u]) + out["keyed_by"] = "uuid" + return out + + def _diff_extras(before: ET.Element, after: ET.Element) -> dict: a, b = _extras(before), _extras(after) out = {} ta, tb = before.get("title", ""), after.get("title", "") out["project"] = {"changed": {"title": [ta, tb]} if ta != tb else {}} - # Folios are keyed by position. A reorder or a removal in the middle - # shifts every later index, so a folio "changing" its title alongside a - # folio count change can just be the shift -- the count says which. - folio_changes = [] - for n in sorted(set(a["folios"]) & set(b["folios"])): - delta = {f: [a["folios"][n][f], b["folios"][n][f]] for f in _FOLIO_FIELDS - if a["folios"][n][f] != b["folios"][n][f]} - if delta: - folio_changes.append({"folio": n, "changed": delta}) - out["folios"] = {"before": len(a["folios"]), "after": len(b["folios"]), - "changed": folio_changes[:50]} - if len(a["folios"]) != len(b["folios"]) and folio_changes: - out["folios"]["note"] = ("the folio count changed, so changes listed here may be " - "later folios shifting position rather than edits") + out["folios"] = _diff_folios(a, b) out["texts"] = _diff_items(a["texts"], b["texts"]) out["shapes"] = _diff_items(a["shapes"], b["shapes"]) out["images"] = _diff_items(a["images"], b["images"]) - # Keyed by element, what the field is bound to, and the nth such field. - # A field's own text is also compared ("shows"), so relabelling an - # element shows up here as well as in the element's information. - out["element_texts"] = _diff_keyed( - a["element_texts"], b["element_texts"], - lambda k: {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]}) + out["tables"] = _diff_items(a["tables"], b["tables"]) + out["element_texts"] = _diff_element_texts(a, b) out["terminal_strips"] = _diff_keyed( a["strips"], b["strips"], lambda k: (lambda v: f"{v['installation']} {v['location']} {v['name']}".strip())( @@ -524,15 +582,21 @@ def tool_diff(before: str, after: str) -> dict: changed_info.append({"uuid": k, "name": a["name"], "from": a_info, "to": b_info}) - a_co = {r["key"]: r for i, c, ix in _conductors(_root(before)) - for r in [_conductor_row(i, c, ix)]} - b_co = {r["key"]: r for i, c, ix in _conductors(_root(after)) - for r in [_conductor_row(i, c, ix)]} + a_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(before))] + b_rows = [_conductor_row(i, c, ix) for i, c, ix in _conductors(_root(after))] + # Keyed by the conductor's own uuid when every conductor on both sides + # has one, so a rewired conductor is that conductor, changed ("ends"). + # QElectroTech keeps a uuid only on conductors that were loaded with one + # or created since, so an older file keys on its two ends instead. + co_by_uuid = bool(a_rows or b_rows) and all(r["uuid"] for r in a_rows + b_rows) + co_id = (lambda r: r["uuid"]) if co_by_uuid else (lambda r: r["key"]) + a_co = {co_id(r): r for r in a_rows} + b_co = {co_id(r): r for r in b_rows} # An end that could not be resolved to an element is keyed on the # folio-scoped integer id, which QElectroTech reassigns on every write. # Say so rather than presenting the result as if it were comparable: # in such a file an untouched conductor can read as removed and re-added. - shaky = sum(1 for k in set(a_co) | set(b_co) if "#" in k) + shaky = 0 if co_by_uuid else sum(1 for k in set(a_co) | set(b_co) if "#" in k) unstable = {} if not shaky else { "unstable_keys": shaky, "warning": "some conductors sit on elements with no persisted uuid, so " @@ -549,8 +613,11 @@ def tool_diff(before: str, after: str) -> dict: ("num", "formula", "cable", "bus", "color", "section", "function", "type") if a[f] != b[f]} + if a["key"] != b["key"]: + fields["ends"] = [a["key"], b["key"]] if fields: - conductor_changes.append({"key": k, "changed": fields}) + conductor_changes.append({"key": b["key"], **({"uuid": k} if co_by_uuid else {}), + "changed": fields}) deltas = sorted({tuple(m["delta"]) for m in moved}) return { @@ -567,8 +634,9 @@ def tool_diff(before: str, after: str) -> dict: }, "conductors": { "before": len(a_co), "after": len(b_co), - "added": sorted(set(b_co) - set(a_co))[:50], - "removed": sorted(set(a_co) - set(b_co))[:50], + "keyed_by": "uuid" if co_by_uuid else "ends", + "added": sorted(b_co[k]["key"] for k in set(b_co) - set(a_co))[:50], + "removed": sorted(a_co[k]["key"] for k in set(a_co) - set(b_co))[:50], "changed": conductor_changes[:100], "changed_count": len(conductor_changes), **unstable, @@ -2204,8 +2272,12 @@ TOOLS = [ "description": "Structurally diff two .qet files: which elements moved and " "by what delta, which were rotated (orientation in quarter " "turns, 0-3), which were added, removed or relabelled, and " - "which conductor fields changed. Use this to verify what an " - "edit actually did, rather than reading a screenshot.", + "which conductor fields changed; also folio fields, texts, shapes, " + "pictures, tables, symbol text fields and terminal strips. Items are " + "matched by their uuid when every one of a kind has one (each " + "section says so in \"keyed_by\"), otherwise by position or ends. " + "Use this to verify what an edit actually did, rather than reading " + "a screenshot.", "inputSchema": { "type": "object", "properties": { diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 66d96db74..a549581d4 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -942,7 +942,7 @@ class DiffContracts(unittest.TestCase): + self.wire(A, C, num="W3"))) k = lambda x, y: f"1:{x}/{{t1}}--{y}/{{t2}}" self.assertEqual(m.tool_diff(before, after)["conductors"], { - "before": 2, "after": 2, "added": [k(A, C)], "removed": [k(B, C)], + "before": 2, "after": 2, "keyed_by": "ends", "added": [k(A, C)], "removed": [k(B, C)], "changed": [{"key": k(A, B), "changed": {"num": ["W1", "W9"], "color": ["", "red"]}}], "changed_count": 1}) @@ -1019,7 +1019,7 @@ class DiffContracts(unittest.TestCase): d = m.tool_diff(self.qet(self.folio(title="A", cols="10"), title="P"), self.qet(self.folio(title="B", cols="10"), title="Q")) self.assertEqual(d["project"], {"changed": {"title": ["P", "Q"]}}) - self.assertEqual(d["folios"], {"before": 1, "after": 1, + self.assertEqual(d["folios"], {"before": 1, "after": 1, "keyed_by": "position", "changed": [{"folio": 1, "changed": {"title": ["A", "B"]}}]}) d = m.tool_diff(self.qet(self.folio(title="A")), self.qet(self.folio(title="B") + self.folio(title="C"))) @@ -1117,6 +1117,72 @@ class DiffContracts(unittest.TestCase): t = m.tool_diff(self.qet(self.folio()), self.qet(self.folio(extra=texts(51, 0))))["texts"] self.assertEqual(len(t["added"]), 50) + def test_conductors_by_uuid(self): + """Every conductor has a uuid: a rewire is that conductor with new ends, + not one removed and another added.""" + A, B, C = self.A, self.B, self.C + els = self.el(A, 0, 0) + self.el(B, 0, 0) + self.el(C, 0, 0) + before = self.qet(self.folio(els, self.wire(A, B, uuid="{w1}", num="W1") + + self.wire(B, C, uuid="{w2}"))) + after = self.qet(self.folio(els, self.wire(A, C, uuid="{w1}", num="W1") + + self.wire(A, B, uuid="{w3}"))) + k = lambda x, y: f"1:{x}/{{t1}}--{y}/{{t2}}" + c = m.tool_diff(before, after)["conductors"] + self.assertEqual(c["keyed_by"], "uuid") + self.assertEqual(c["changed"], [{"key": k(A, C), "uuid": "{w1}", + "changed": {"ends": [k(A, B), k(A, C)]}}]) + # added/removed are still named by their ends, as in the ends mode + self.assertEqual((c["added"], c["removed"]), ([k(A, B)], [k(B, C)])) + # one conductor without a uuid puts the whole comparison back on ends + mixed = self.qet(self.folio(els, self.wire(A, B, uuid="{w1}", num="W1") + self.wire(B, C))) + self.assertEqual(m.tool_diff(before, mixed)["conductors"]["keyed_by"], "ends") + + def test_folios_by_uuid(self): + """With folio uuids a reorder is one 'reordered' entry, not every later + folio changing title.""" + f = lambda u, t: self.folio(title=t, uuid=u) + before = self.qet(f("{f1}", "One") + f("{f2}", "Two") + f("{f3}", "Three")) + after = self.qet(f("{f2}", "Two") + f("{f1}", "One") + f("{f4}", "Four")) + d = m.tool_diff(before, after)["folios"] + self.assertEqual(d["keyed_by"], "uuid") + self.assertEqual(d["changed"], []) + self.assertEqual(d["reordered"], [{"uuid": "{f2}", "title": "Two", "from": 2, "to": 1}, + {"uuid": "{f1}", "title": "One", "from": 1, "to": 2}]) + self.assertEqual(d["added"], [{"folio": 3, "uuid": "{f4}", "title": "Four"}]) + self.assertEqual(d["removed"], [{"folio": 3, "uuid": "{f3}", "title": "Three"}]) + self.assertNotIn("note", d) + renamed = self.qet(f("{f1}", "Uno") + f("{f2}", "Two") + f("{f3}", "Three")) + self.assertEqual(m.tool_diff(before, renamed)["folios"]["changed"], + [{"folio": 1, "uuid": "{f1}", "changed": {"title": ["One", "Uno"]}}]) + + def test_element_text_fields_by_uuid(self): + """By their own uuid, deleting the first of two label fields is that + field removed -- by position it read as the second one changing.""" + def fields(*items): + return self.el(self.A, 0, 0, texts="".join( + f'{t}' + '' for u, x, t in items)) + before = self.qet(self.folio(fields(("{e1}", 1, "a"), ("{e2}", 2, "b")))) + after = self.qet(self.folio(fields(("{e2}", 2, "b")))) + d = m.tool_diff(before, after)["element_texts"] + self.assertEqual(d["keyed_by"], "uuid") + self.assertEqual((d["changed"], [r["uuid"] for r in d["removed"]]), ([], ["{e1}"])) + # the same pair without uuids: the position-based reading, flagged as such + plain = lambda *items: self.qet(self.folio(self.el(self.A, 0, 0, texts="".join( + f'{t}' + for x, t in items)))) + d = m.tool_diff(plain((1, "a"), (2, "b")), plain((2, "b")))["element_texts"] + self.assertEqual(d["keyed_by"], "position") + + def test_tables(self): + table = lambda x, rows: ('') + d = m.tool_diff(self.qet(self.folio(extra=table(0, 10))), + self.qet(self.folio(extra=table(20, 12))))["tables"] + self.assertEqual((d["keyed_by"], d["before"], d["after"]), ("uuid", 1, 1)) + self.assertEqual(d["changed"], [{"item": {"folio": 1, "name": "Parts", "uuid": "{tb}"}, + "changed": {"x": ["0", "20"], "rows_shown": ["10", "12"]}}]) + def test_terminal_strips(self): def strip(name, n): terms = "".join(f'' for _ in range(n)) From 8fd692783c31792ebc7790d3588d049b264351e2 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:15:12 +1300 Subject: [PATCH 09/16] qet-mcp: key symbol text fields by symbol and field uuid; refuse shared uuids A symbol text field's uuid is unique only within its symbol: copying a symbol keeps them, so 7 of the 24 shipped examples repeat one, up to 20 times (2612_ats_singlephase.qet). Keyed on the field uuid alone, those fields merged and an edit to one copy could be reported on another. They are now keyed on (symbol uuid, field uuid). More generally, uuids are used as keys only when present on every item and unique on each side; otherwise the old position/ends matching is kept, for texts, shapes, pictures, tables, conductors and folios alike. Found by the seeded-edit invariants (an untouched symbol's field reported changed). Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 23 ++++++++--- misc/qet-mcp/test_qet_mcp.py | 76 ++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+), 6 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 9cf5d64e2..dfb320bd9 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -409,6 +409,14 @@ def _extras(root: ET.Element) -> dict: "element_texts": element_texts, "element_text_uuids": element_text_uuids} +def _usable_ids(*sides) -> bool: + """Whether uuids can identify items: present on every item, unique on each + side. Copying a symbol keeps its text fields' uuids, so a project can + hold the same field uuid twenty times; keying on it would merge them.""" + return (any(sides) and all(all(s) for s in sides) + and all(len(set(s)) == len(s) for s in sides)) + + def _diff_keyed(a: dict, b: dict, label) -> dict: """added / removed / changed for two dicts keyed by identity.""" changed = [] @@ -432,7 +440,7 @@ def _diff_items(a: list, b: list) -> dict: and re-added. On uuid, position is part of what is compared, so a move is a change to that item. """ - by_uuid = all(r["uuid"] for r in a + b) + by_uuid = _usable_ids([r["uuid"] for r in a], [r["uuid"] for r in b]) def key(r): return r["uuid"] if by_uuid else str(r["key"]) @@ -458,7 +466,7 @@ def _diff_folios(a: dict, b: dict) -> dict: says so when the count changed. """ ua, ub = a["folio_uuids"], b["folio_uuids"] - by_uuid = bool(ua or ub) and all(ua.values()) and all(ub.values()) + by_uuid = _usable_ids(list(ua.values()), list(ub.values())) changed, reordered, added, removed = [], [], [], [] if by_uuid: pos_a = {u: n for n, u in ua.items()} @@ -499,15 +507,18 @@ def _diff_element_texts(a: dict, b: dict) -> dict: the list. A field's own text is also compared ("shows"), so relabelling an element shows up here as well as in the element's information. """ - ka, kb = a["element_text_uuids"], b["element_text_uuids"] - by_uuid = bool(ka or kb) and all(ka.values()) and all(kb.values()) + # A field's uuid is unique only within its symbol (copies keep them), so + # a field is identified by its symbol's uuid and its own. + ka = {k: (k[0], u) if k[0] and u else "" for k, u in a["element_text_uuids"].items()} + kb = {k: (k[0], u) if k[0] and u else "" for k, u in b["element_text_uuids"].items()} + by_uuid = _usable_ids(list(ka.values()), list(kb.values())) def label(k): return {"element": k[0], "source": k[1], "bound_to": k[2], "n": k[3]} if not by_uuid: out = _diff_keyed(a["element_texts"], b["element_texts"], label) out["keyed_by"] = "position" return out - labels = {u: {**label(k), "uuid": u} for side in (ka, kb) for k, u in side.items()} + labels = {u: {**label(k), "uuid": u[1]} for side in (ka, kb) for k, u in side.items()} out = _diff_keyed({ka[k]: v for k, v in a["element_texts"].items()}, {kb[k]: v for k, v in b["element_texts"].items()}, lambda u: labels[u]) @@ -588,7 +599,7 @@ def tool_diff(before: str, after: str) -> dict: # has one, so a rewired conductor is that conductor, changed ("ends"). # QElectroTech keeps a uuid only on conductors that were loaded with one # or created since, so an older file keys on its two ends instead. - co_by_uuid = bool(a_rows or b_rows) and all(r["uuid"] for r in a_rows + b_rows) + co_by_uuid = _usable_ids([r["uuid"] for r in a_rows], [r["uuid"] for r in b_rows]) co_id = (lambda r: r["uuid"]) if co_by_uuid else (lambda r: r["key"]) a_co = {co_id(r): r for r in a_rows} b_co = {co_id(r): r for r in b_rows} diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a549581d4..9dc45b65b 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1174,6 +1174,82 @@ class DiffContracts(unittest.TestCase): d = m.tool_diff(plain((1, "a"), (2, "b")), plain((2, "b")))["element_texts"] self.assertEqual(d["keyed_by"], "position") + def test_copied_symbols_keep_their_text_field_uuids(self): + """Copying a symbol keeps its text fields' uuids (20 copies of one in + 2612_ats_singlephase.qet), so a field is its symbol's uuid plus its own: + editing one copy's field must not be read as another copy's.""" + field = lambda x: ('t') + pair = lambda xa, xb: self.qet(self.folio(self.el(self.A, 0, 0, texts=field(xa)) + + self.el(self.B, 0, 0, texts=field(xb)))) + for before, after, which in ((pair(1, 1), pair(5, 1), self.A), (pair(1, 1), pair(1, 5), self.B)): + with self.subTest(edited=which): + d = m.tool_diff(before, after)["element_texts"] + self.assertEqual(d["keyed_by"], "uuid") + self.assertEqual([(c["item"]["element"], c["item"]["uuid"], c["changed"]) + for c in d["changed"]], [(which, "{same}", {"x": ["1", "5"]})]) + + def test_repeated_uuids_fall_back_rather_than_merge(self): + # the same field uuid twice inside one symbol + twice = lambda x: self.qet(self.folio(self.el(self.A, 0, 0, texts="".join( + f'{v}' + '' for v in (x, 9))))) + self.assertEqual(m.tool_diff(twice(1), twice(2))["element_texts"]["keyed_by"], "position") + # two shapes sharing a uuid: both must still be counted + shapes = self.qet(self.folio(extra='' + ''.join( + f'' for i in (0, 5)) + '')) + d = m.tool_diff(shapes, shapes)["shapes"] + self.assertEqual((d["keyed_by"], d["before"]), ("position", 2)) + # two conductors sharing a uuid + els = self.el(self.A, 0, 0) + self.el(self.B, 0, 0) + self.el(self.C, 0, 0) + wires = self.qet(self.folio(els, self.wire(self.A, self.B, uuid="{w}") + + self.wire(self.B, self.C, uuid="{w}"))) + c = m.tool_diff(wires, wires)["conductors"] + self.assertEqual((c["keyed_by"], c["before"]), ("ends", 2)) + + def test_uuid_matching_when_one_side_has_none_of_a_kind(self): + """No folios, fields or wires on one side is not a reason to fall back + to position: the other side's uuids are all there is to match.""" + empty = self.qet("") + full = self.qet(self.folio( + self.el(self.A, 0, 0, texts='t') + + self.el(self.B, 0, 0), self.wire(self.A, self.B, uuid="{w1}"), uuid="{f1}")) + d = m.tool_diff(empty, full) + self.assertEqual((d["folios"]["keyed_by"], d["element_texts"]["keyed_by"], + d["conductors"]["keyed_by"]), ("uuid", "uuid", "uuid")) + self.assertEqual(d["folios"]["added"], [{"folio": 1, "uuid": "{f1}", "title": ""}]) + # matched by uuid, a conductor is never on the renumbered-id footing + self.assertNotIn("unstable_keys", d["conductors"]) + + def test_a_folio_that_kept_its_place_is_not_reordered(self): + f = lambda u, t: self.folio(title=t, uuid=u) + before = self.qet(f("{f1}", "One") + f("{f2}", "Two") + f("{f3}", "Three")) + after = self.qet(f("{f1}", "One") + f("{f3}", "Three") + f("{f2}", "Two")) + self.assertEqual([r["uuid"] for r in m.tool_diff(before, after)["folios"]["reordered"]], + ["{f3}", "{f2}"]) + + def test_folio_lists_are_capped(self): + f = lambda i, t: self.folio(title=t, uuid=f"{{{i:04d}}}") + many = lambda rng, t: "".join(f(i, t) for i in rng) + d = m.tool_diff(self.qet(""), self.qet(many(range(51), "x")))["folios"] + self.assertEqual(len(d["added"]), 50) + d = m.tool_diff(self.qet(many(range(51), "x")), self.qet(""))["folios"] + self.assertEqual(len(d["removed"]), 50) + d = m.tool_diff(self.qet(many(range(52), "x")), self.qet(many(reversed(range(52)), "x")))["folios"] + self.assertEqual(len(d["reordered"]), 50) + + def test_table_fields(self): + def table(**v): + a = {"x": "0", "y": "0", "width": "100", "height": "50", "display_n_row": "10", **v} + return ('') + for attr, reported in (("y", "y"), ("width", "width"), ("height", "height")): + with self.subTest(attr=attr): + d = m.tool_diff(self.qet(self.folio(extra=table())), + self.qet(self.folio(extra=table(**{attr: "7"}))))["tables"] + self.assertEqual([list(c["changed"]) for c in d["changed"]], [[reported]]) + def test_tables(self): table = lambda x, rows: ('') From 279c98f10a68886781aba876f7253f4f72795ffc Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:15:21 +1300 Subject: [PATCH 10/16] qet-mcp: add qet_items, listing drawn items with their uuids qet_edit addresses free texts, shapes, pictures and tables by uuid, and qet_diff reports them by uuid, but no tool listed them: the only way to learn an item's uuid was to read the .qet. qet_items lists every free text, shape, picture, table and symbol text field per folio (counted from 1, as qet_elements), with its uuid and main fields; filter by folio and kind; default limit 500. Also a test that every tool argument holding a data path is in the workspace policy (_DATA_PATHS). Nothing checked that direction: a new tool left out of the policy would have read or written anywhere with every test passing, as removing qet_items' entry showed. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/README.md | 1 + misc/qet-mcp/qet_mcp.py | 57 +++++++++++++++++++++++++- misc/qet-mcp/test_qet_mcp.py | 79 +++++++++++++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6d29fe6dc..dd555a59a 100644 --- a/misc/qet-mcp/README.md +++ b/misc/qet-mcp/README.md @@ -30,6 +30,7 @@ here read the model. | `qet_project_info` | title, format version, folios, element and conductor counts | | `qet_elements` | placed elements: uuid, type, position, label, information bag | | `qet_conductors` | conductors and their documentation fields; filter by attribute | +| `qet_items` | free texts, shapes, pictures, tables and symbol text fields, each with its uuid | | `qet_diff` | **what an edit actually changed** — element moves, adds, removes, relabels; conductor changes; and folio fields, texts, shapes, images, symbol text fields and terminal strips | | `qet_scan` | sweep a directory of projects, counting nodes carrying an attribute | | `qet_element_info` | a `.elmt`: translated names, terminals, info fields, part counts | diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 9cf5d64e2..a33630123 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -272,6 +272,36 @@ def tool_elements(path: str, folio: int | None = None, "elements": rows[:limit]} +ITEM_KINDS = ["text", "shape", "image", "table", "element_text"] + + +def tool_items(path: str, folio: int | None = None, kind: str | None = None, + limit: int = 500) -> dict: + """Every drawn item that is not a symbol or a wire, with its uuid. + + Free texts, shapes, pictures, tables and the text fields of symbols -- + the items a qet_edit op or a qet_diff entry names by uuid. Folios are + numbered from 1, as in qet_elements. An item saved before these items + carried a uuid has "" here; QElectroTech gives it one on the next save. + """ + if kind is not None and kind not in ITEM_KINDS: + raise ValueError(f"kind must be one of {ITEM_KINDS}, not {kind!r}") + ex = _extras(_root(path)) + rows = [] + for name, records in (("text", ex["texts"]), ("shape", ex["shapes"]), + ("image", ex["images"]), ("table", ex["tables"])): + for r in records: + rows.append({"kind": name, "uuid": r["uuid"], **r["label"], **r["value"]}) + for k, v in ex["element_texts"].items(): + rows.append({"kind": "element_text", "uuid": ex["element_text_uuids"][k], + "folio": ex["element_text_folios"][k], "element": k[0], + "source": k[1], "bound_to": k[2], "n": k[3], **v}) + rows = [r for r in rows if (folio is None or r["folio"] == folio) + and (kind is None or r["kind"] == kind)] + rows.sort(key=lambda r: (r["folio"], ITEM_KINDS.index(r["kind"]))) + return {"count": len(rows), "truncated": len(rows) > limit, "items": rows[:limit]} + + def tool_conductors(path: str, folio: int | None = None, attribute: str | None = None, non_empty: bool = False, limit: int = 200) -> dict: @@ -373,7 +403,7 @@ def _extras(root: ET.Element) -> dict: im, {"folio": n, "x": im.get("x", ""), "y": im.get("y", "")}, {"scale": im.get("size", ""), "rotation": _angle(im.get("rotation", ""))})) - element_texts, element_text_uuids = {}, {} + element_texts, element_text_uuids, element_text_folios = {}, {}, {} for n, d in _folios(root): for el in d.iter("element"): uuid = el.get("uuid", "") @@ -387,6 +417,7 @@ def _extras(root: ET.Element) -> dict: seen[base] = seen.get(base, 0) + 1 fs = (t.get("font", "").split(",") + ["", ""])[1] element_text_uuids[base + (seen[base],)] = t.get("uuid", "") + element_text_folios[base + (seen[base],)] = n element_texts[base + (seen[base],)] = { "x": t.get("x", ""), "y": t.get("y", ""), "size": fs, "frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")), @@ -406,7 +437,8 @@ def _extras(root: ET.Element) -> dict: "terminals": sum(1 for _ in st.iter("real_terminal"))} return {"folios": folios, "folio_uuids": folio_uuids, "texts": texts, "shapes": shapes, "images": images, "tables": tables, "strips": strips, - "element_texts": element_texts, "element_text_uuids": element_text_uuids} + "element_texts": element_texts, "element_text_uuids": element_text_uuids, + "element_text_folios": element_text_folios} def _diff_keyed(a: dict, b: dict, label) -> dict: @@ -2225,6 +2257,26 @@ TOOLS = [ }, "handler": lambda a: tool_project_info(a["path"]), }, + { + "name": "qet_items", + "description": "List the drawn items that are not symbols or wires -- free texts, " + "shapes, pictures, tables and the text fields of symbols -- with " + "each one's uuid, folio (counted from 1) and main fields. Use the " + "uuid to address an item in qet_edit or to find it in qet_diff. " + "Reads the file directly; does not launch QElectroTech.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "folio": {"type": "integer", "description": "folio number counted from 1"}, + "kind": {"type": "string", "enum": ITEM_KINDS}, + "limit": {"type": "integer", "default": 500}, + }, + "required": ["path"], + }, + "handler": lambda a: tool_items(a["path"], a.get("folio"), a.get("kind"), + a.get("limit", 500)), + }, { "name": "qet_elements", "description": "List placed elements with uuid, type, position, label and " @@ -2798,6 +2850,7 @@ _BY_NAME = {t["name"]: t for t in TOOLS} _DATA_PATHS = { "qet_project_info": {"read": ("path",)}, "qet_elements": {"read": ("path",)}, + "qet_items": {"read": ("path",)}, "qet_conductors": {"read": ("path",)}, "qet_diff": {"read": ("before", "after")}, "qet_scan": {"read": ("directory",)}, diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index a549581d4..3d90e9d13 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -153,7 +153,7 @@ class ToolRegistry(unittest.TestCase): "qet_project_info", "qet_elements", "qet_conductors", "qet_diff", "qet_scan", "qet_element_info", "qet_export", "qet_edit", "qet_query", "qet_project_new", "qet_element_search", "qet_check", "qet_element_build", - "qet_continuity"}) + "qet_continuity", "qet_items"}) class EditValidation(unittest.TestCase): @@ -838,6 +838,69 @@ class ReadToolContracts(unittest.TestCase): self.assertEqual(m._plain_text(None), "") +class ItemsTool(unittest.TestCase): + """qet_items: every drawn item that is not a symbol or a wire, with its uuid.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.qet = str(Path(self.tmp.name) / "p.qet") + Path(self.qet).write_text( + '' + '' + 'K1label' + '' + '' + '' + '' + '' + '' + '' + '') + + def tearDown(self): + self.tmp.cleanup() + + def test_every_kind_with_its_uuid(self): + r = m.tool_items(self.qet) + self.assertEqual((r["count"], r["truncated"]), (5, False)) + self.assertEqual(r["items"], [ + {"kind": "text", "uuid": "{tx}", "folio": 1, "x": "1", "y": "2", "text": "note", + "rotation": "0", "font": "f", "color": "c"}, + {"kind": "shape", "uuid": "{sh}", "folio": 1, "type": "Line", "from": ["0", "0"], + "to": ["9", "9"], "line_color": "c", "line_style": "s", "line_width": "1", + "fill": "none", "rotation": "0"}, + {"kind": "element_text", "uuid": "{et}", "folio": 1, "element": "{el}", + "source": "ElementInfo", "bound_to": "label", "n": 1, "x": "5", "y": "6", + "size": "9", "frame": "false", "rotation": "0", "width": "-1", "shows": "K1"}, + # saved before pictures carried a uuid: "" until the next save + {"kind": "image", "uuid": "", "folio": 2, "x": "3", "y": "4", "scale": "1", + "rotation": "0"}, + {"kind": "table", "uuid": "{tb}", "folio": 2, "name": "Parts", "x": "0", "y": "0", + "width": "100", "height": "50", "rows_shown": "10"}]) + + def test_filters_and_limit(self): + kinds = lambda r: [i["kind"] for i in r["items"]] + self.assertEqual(kinds(m.tool_items(self.qet, folio=2)), ["image", "table"]) + self.assertEqual(kinds(m.tool_items(self.qet, kind="shape")), ["shape"]) + self.assertEqual(kinds(m.tool_items(self.qet, folio=2, kind="shape")), []) + r = m.tool_items(self.qet, limit=2) + self.assertEqual((r["count"], r["truncated"], len(r["items"])), (5, True, 2)) + r = m.tool_items(self.qet, limit=5) + self.assertEqual((r["count"], r["truncated"]), (5, False)) + with self.assertRaisesRegex(ValueError, "kind must be one of"): + m.tool_items(self.qet, kind="wire") + + def test_default_limit_is_500(self): + many = "".join(f'' for i in range(501)) + big = Path(self.tmp.name) / "big.qet" + big.write_text(f'{many}') + r = m.tool_items(str(big)) + self.assertEqual((r["count"], r["truncated"], len(r["items"])), (501, True, 500)) + + class DiffContracts(unittest.TestCase): """Exact qet_diff output, section by section, on hand-made pairs. @@ -1572,6 +1635,20 @@ class PathPolicy(unittest.TestCase): if "overwrite" in t["inputSchema"].get("properties", {})} self.assertEqual(guarded, advertised) + def test_every_data_path_argument_is_guarded(self): + """The other direction: a tool whose schema takes a data path must be + in the policy, or that path is read or written with no workspace + check at all -- and nothing fails. binary and elements_dir are + configuration, deliberately not confined (see the README).""" + pathish = {"path", "project", "before", "after", "output", "directory"} + for t in m.TOOLS: + with self.subTest(tool=t["name"]): + props = t["inputSchema"].get("properties", {}) + spec = m._DATA_PATHS.get(t["name"], {}) + guarded = set(spec.get("read", ())) | set(spec.get("write", ())) + self.assertEqual(pathish & set(props), guarded & pathish, + f"{t['name']}: data path arguments not in _DATA_PATHS") + def test_the_policy_names_only_real_tools_and_arguments(self): by_name = {t["name"]: t for t in m.TOOLS} for name, spec in m._DATA_PATHS.items(): From bbe1903ec8e973064d3cdd4ca9a0075e72b8ca91 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:20:22 +1300 Subject: [PATCH 11/16] qet-mcp: read a folio's tables from its block only (as #1097 does for texts) Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 2 +- misc/qet-mcp/test_qet_mcp.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index dfb320bd9..435f2e6b9 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -345,7 +345,7 @@ def _extras(root: ET.Element) -> dict: for n, d in _folios(root): folios[n] = {f: d.get(f, "") for f in _FOLIO_FIELDS} folio_uuids[n] = d.get("uuid", "") - for tb in d.iter("graphics_table"): + for tb in d.findall("tables/graphics_table"): tables.append(record( tb, {"folio": n, "name": tb.get("name", "")}, {"x": tb.get("x", ""), "y": tb.get("y", ""), "width": tb.get("width", ""), diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 9dc45b65b..2677d7450 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1242,8 +1242,8 @@ class DiffContracts(unittest.TestCase): def test_table_fields(self): def table(**v): a = {"x": "0", "y": "0", "width": "100", "height": "50", "display_n_row": "10", **v} - return ('') + return ('') for attr, reported in (("y", "y"), ("width", "width"), ("height", "height")): with self.subTest(attr=attr): d = m.tool_diff(self.qet(self.folio(extra=table())), @@ -1251,8 +1251,8 @@ class DiffContracts(unittest.TestCase): self.assertEqual([list(c["changed"]) for c in d["changed"]], [[reported]]) def test_tables(self): - table = lambda x, rows: ('') + table = lambda x, rows: ('') d = m.tool_diff(self.qet(self.folio(extra=table(0, 10))), self.qet(self.folio(extra=table(20, 12))))["tables"] self.assertEqual((d["keyed_by"], d["before"], d["after"]), ("uuid", 1, 1)) From fd8b816475fb2214ab4cfecf31951b86f44b4649 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:20:23 +1300 Subject: [PATCH 12/16] qet-mcp: qet_items test fixture wraps its table in , as saved files do Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index bfda00152..1ab3c2c25 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -856,8 +856,8 @@ class ItemsTool(unittest.TestCase): '' '' '' - '' + '' '') def tearDown(self): From fd962619b5721fa2da938f0ccc2db845c427dba4 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:29:24 +1300 Subject: [PATCH 13/16] Scripting: look up a table or a symbol text field by its uuid qet.textIndex(), shapeIndex() and imageIndex() turn a free text's, shape's or picture's uuid into the index the other calls take. Tables and symbol text fields had no such lookup, so a script could only name them by index, and an index shifts when an earlier item is deleted: a script that deletes table 0 and then moves "table 1" moves the wrong table. - qet.tableIndex(folio, uuid): the table's current index in tables(folio), or -1. - qet.elementTextIndex(folio, elementUuid, textUuid): the field's current index in elementTexts(folio, elementUuid), or -1. The element is part of the address because a field's uuid is unique only within its element: copying an element keeps its fields' uuids (2612_ats_singlephase.qet has one field uuid on 20 copies). Tests in misc/qet-mcp's integration suite, which drives these through --run: a table followed across a deletion of the one before it, and the same field uuid resolving on two copies to each copy's own field. Both fail against a build without this change. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 70 ++++++++++++++++++++++++++++++ sources/scripting/qetscriptapi.cpp | 21 +++++++++ sources/scripting/qetscriptapi.h | 3 ++ 3 files changed, 94 insertions(+) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index f0827225b..e19ea989f 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -2774,6 +2774,76 @@ class PlcIntegration(unittest.TestCase): self.assertTrue(r["operations"][-1]["succeeded"]) +@needs_examples +class UuidIndexLookups(unittest.TestCase): + """qet.tableIndex() and qet.elementTextIndex(): a uuid in, the index the + other calls take out, as qet.textIndex() does for free texts. The index is + what shifts when an earlier item is deleted; the uuid is what holds.""" + + def run_script(self, project, body): + r = m._run_qet(BINARY, [str(project)], elements_dir=ELEMENTS or None, + script=body + '\nqet.log("OUT " + JSON.stringify(out));', tail=200_000) + line = next((ln for ln in (r["stdout"] + "\n" + r["stderr"]).splitlines() if "OUT " in ln), None) + self.assertIsNotNone(line, r.get("stderr", "")[-500:]) + return json.loads(line.split("OUT ", 1)[1]) + + def test_table_index_follows_the_table_across_a_deletion(self): + root = ET.parse(Path(EXAMPLES) / "industrial.qet").getroot() + folio, table = next((i, d.find("tables/graphics_table")) for i, d in enumerate(root.iter("diagram")) + if d.find("tables/graphics_table") is not None) + twin = ET.fromstring(ET.tostring(table)) + twin.set("uuid", "{11111111-2222-4333-8444-555555555555}") + twin.set("x", str(float(table.get("x")) + 900)) + list(root.iter("diagram"))[folio].find("tables").append(twin) + with tempfile.TemporaryDirectory() as tmp: + p = Path(tmp) / "two_tables.qet" + ET.ElementTree(root).write(p, encoding="utf-8") + first, second = table.get("uuid"), twin.get("uuid") + out = self.run_script(p, f""" + var out = {{}}; + out.first = qet.tableIndex({folio}, "{first}"); + out.second = qet.tableIndex({folio}, "{second}"); + out.bogus = qet.tableIndex({folio}, "{{00000000-0000-4000-8000-000000000000}}"); + out.not_uuid = qet.tableIndex({folio}, "table"); + out.list = qet.tables({folio}); + qet.deleteTable({folio}, out.first); + out.second_after = qet.tableIndex({folio}, "{second}"); + out.first_after = qet.tableIndex({folio}, "{first}");""") + self.assertEqual(sorted([out["first"], out["second"]]), [0, 1]) + # each index names the right table: the twin sits 900 further right + at = lambda i: float(re.search(r"at \(([-\d.]+),", out["list"][i]).group(1)) + self.assertEqual(at(out["second"]) - at(out["first"]), 900) + self.assertEqual((out["bogus"], out["not_uuid"]), (-1, -1)) + self.assertEqual((out["second_after"], out["first_after"]), (0, -1)) + + def test_element_text_index_needs_the_element_as_well(self): + """Copies of a symbol share their text fields' uuids (2612_ats_singlephase.qet): + the same field uuid resolves on each copy to that copy's own field.""" + root = ET.parse(Path(EXAMPLES) / "2612_ats_singlephase.qet").getroot() + owners = {} + for i, d in enumerate(root.iter("diagram")): + for el in d.iter("element"): + for t in el.iter("dynamic_elmt_text"): + if t.get("uuid"): + owners.setdefault((i, t.get("uuid")), []).append((el.get("uuid"), t.findtext("text"))) + (folio, field), copies = next((k, v) for k, v in owners.items() + if len(v) >= 2 and len({s for _, s in v}) >= 2) + (a, shows_a), (b, shows_b) = copies[:2] + out = self.run_script(Path(EXAMPLES) / "2612_ats_singlephase.qet", f""" + var out = {{}}; + out.ia = qet.elementTextIndex({folio}, "{a}", "{field}"); + out.ib = qet.elementTextIndex({folio}, "{b}", "{field}"); + out.la = qet.elementTexts({folio}, "{a}"); + out.lb = qet.elementTexts({folio}, "{b}"); + out.bogus = qet.elementTextIndex({folio}, "{a}", "{{00000000-0000-4000-8000-000000000000}}"); + out.no_element = qet.elementTextIndex({folio}, "{{00000000-0000-4000-8000-000000000001}}", "{field}");""") + self.assertGreaterEqual(out["ia"], 0) + self.assertGreaterEqual(out["ib"], 0) + self.assertIn(f"shows='{shows_a}'", out["la"][out["ia"]]) + self.assertIn(f"shows='{shows_b}'", out["lb"][out["ib"]]) + self.assertEqual((out["bogus"], out["no_element"]), (-1, -1)) + + @needs_examples class CorpusIntegration(unittest.TestCase): def test_conductor_keys_never_collide_across_the_shipped_examples(self): diff --git a/sources/scripting/qetscriptapi.cpp b/sources/scripting/qetscriptapi.cpp index 04a696d68..b3d083a3f 100644 --- a/sources/scripting/qetscriptapi.cpp +++ b/sources/scripting/qetscriptapi.cpp @@ -2891,6 +2891,27 @@ int QetScriptApi::imageIndex(int folioIndex, const QString &uuid) const return indexOfUuid(sortedImages(folioIndex), uuid); } +/// @brief QetScriptApi::tableIndex as textIndex(), for tables(folioIndex). +int QetScriptApi::tableIndex(int folioIndex, const QString &uuid) const +{ + return indexOfUuid(sortedTables(folioIndex), uuid); +} + +/** + @brief QetScriptApi::elementTextIndex + The current index in elementTexts() of the text field carrying + @p textUuid, on the element @p elementUuid, or -1. + A field's uuid is unique only within its element: copying an element + keeps its fields' uuids, so the element is part of the address. +*/ +int QetScriptApi::elementTextIndex(int folioIndex, const QString &elementUuid, + const QString &textUuid) const +{ + Element *element = findElement(folioIndex, elementUuid); + if (!element) return -1; + return indexOfUuid(element->dynamicTextItems(), textUuid); +} + /** @brief QetScriptApi::addImage Place a picture from a file, as the "add image" tool does after its file diff --git a/sources/scripting/qetscriptapi.h b/sources/scripting/qetscriptapi.h index 14f1b4459..533b70830 100644 --- a/sources/scripting/qetscriptapi.h +++ b/sources/scripting/qetscriptapi.h @@ -483,6 +483,7 @@ class QetScriptApi : public QObject // -- a BOM/nomenclature or summary table placed on a folio -- Q_INVOKABLE QStringList tables(int folioIndex) const; + Q_INVOKABLE int tableIndex(int folioIndex, const QString &uuid) const; Q_INVOKABLE int addTable(int folioIndex, const QString &kind, const QString &name, const QString &query); Q_INVOKABLE bool deleteTable(int folioIndex, int tableIndex); @@ -508,6 +509,8 @@ class QetScriptApi : public QObject // -- the text fields shown on a symbol (label, terminal names, ...) -- Q_INVOKABLE QStringList elementTexts(int folioIndex, const QString &elementUuid) const; + Q_INVOKABLE int elementTextIndex(int folioIndex, const QString &elementUuid, + const QString &textUuid) const; Q_INVOKABLE int addElementText(int folioIndex, const QString &elementUuid, const QString &source, const QString &value, double x, double y); From d92a0781b3f7f5eec60f2b5fe18e454a39a695a8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:31:54 +1300 Subject: [PATCH 14/16] qet-mcp: test the text-field uuid guard on each side of a diff A field with a uuid on a symbol with none cannot be keyed by (symbol, field); the mutation audit showed either side's guard could be dropped unnoticed. Now tested with the symbol uuid missing on one side, then the other. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/test_qet_mcp.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 2677d7450..fa7e9808e 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -1189,6 +1189,20 @@ class DiffContracts(unittest.TestCase): self.assertEqual([(c["item"]["element"], c["item"]["uuid"], c["changed"]) for c in d["changed"]], [(which, "{same}", {"x": ["1", "5"]})]) + def test_a_field_on_a_symbol_without_a_uuid_is_matched_by_position(self): + """The field has a uuid but its symbol has none (an older file): with no + symbol to pair it with, the whole kind falls back to position.""" + field = lambda x: ('t') + on = lambda sym, x: self.qet(self.folio(self.el(sym, 0, 0, texts=field(x)))) + d = m.tool_diff(on("", 1), on("", 2))["element_texts"] + self.assertEqual(d["keyed_by"], "position") + self.assertEqual([c["changed"] for c in d["changed"]], [{"x": ["1", "2"]}]) + # the symbol has a uuid on one side only: each side is checked on its own + for before, after in ((on("", 1), on(self.A, 1)), (on(self.A, 1), on("", 1))): + with self.subTest(before=before, after=after): + self.assertEqual(m.tool_diff(before, after)["element_texts"]["keyed_by"], "position") + def test_repeated_uuids_fall_back_rather_than_merge(self): # the same field uuid twice inside one symbol twice = lambda x: self.qet(self.folio(self.el(self.A, 0, 0, texts="".join( From 503c8c69b5680d812d31e876a8dff9abeeea84e0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:49:36 +1300 Subject: [PATCH 15/16] qet-mcp: qet_edit addresses tables and symbol text fields by uuid set_table_position and delete_table took a table's index, and set_element_text / delete_element_text a text field's index. An index shifts when an earlier item is deleted, so a run that deletes table 0 and then moves "table 1" moved the wrong table. Each now also takes the item's uuid, resolved at run time through qet.tableIndex() and qet.elementTextIndex() (the previous commit), as texts, shapes and pictures already are through textIndex() and friends. A text field's lookup is scoped to the op's element, since copies of a symbol share their fields' uuids. An index still works, and a build without the lookups is refused with the usual missing-methods hint only when a uuid is actually used. Tests: the generated script (no binary), and end to end: delete one table then move the other, both by uuid; and edit one copy's shared field by uuid, leaving the other copy's untouched. Co-Authored-By: Claude Opus 5.5 --- misc/qet-mcp/qet_mcp.py | 35 ++++++++++++++-- misc/qet-mcp/test_qet_mcp.py | 79 ++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index 1c557067c..8366a1b60 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -1128,10 +1128,10 @@ OPS = { ("source", "str"), ("value", "str"), ("x", "num"), ("y", "num")]), "set_element_text": ("setElementTextProperty", [("folio", "folio"), ("element", "elmt"), - ("index", "folio"), ("property", "str"), + ("index", "element_text"), ("property", "str"), ("value", "str")]), "delete_element_text": ("deleteElementText", [("folio", "folio"), ("element", "elmt"), - ("index", "folio")]), + ("index", "element_text")]), # Returns the uuids of the copies IN THE ORDER the elements were named, # so "$copies[0]" is the copy of the first one. Conductors between the # copied elements are copied with them; copies arrive without labels or @@ -1191,9 +1191,9 @@ OPS = { ("closed", "bool")]), "add_table": ("addTable", [("folio", "folio"), ("kind", "str"), ("name", "str"), ("query", "str")]), - "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "folio"), + "set_table_position": ("setTablePosition", [("folio", "folio"), ("table", "table"), ("x", "num"), ("y", "num")]), - "delete_table": ("deleteTable", [("folio", "folio"), ("table", "folio")]), + "delete_table": ("deleteTable", [("folio", "folio"), ("table", "table")]), } SHAPES = ["line", "rectangle", "ellipse", "polygon"] @@ -1247,6 +1247,7 @@ def _build_script(operations: list, output: str) -> str: # an index-only edit still runs on a build that predates them. uuid_methods: set[str] = set() folio_js = "0" + element_js = None # the op's element, for lookups scoped to it lines = [ "// generated by qet-mcp; do not edit", "var R = {};", # $name -> value from an earlier op @@ -1300,6 +1301,26 @@ def _build_script(operations: list, output: str) -> str: raise ValueError(f"operation {op_index}: {key!r} must be a non-empty list of " f"integer indices, got {value!r}") return _js(value) + if kind == "table": + # As for texts below: a uuid is resolved to the current index at + # run time, since deleting an earlier table shifts every index. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + uuid_methods.add("tableIndex") + return f"qet.tableIndex({folio_js}, {_js(value)})" + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a table index " + f"or its uuid, got {value!r}") + return _js(value) + if kind == "element_text": + # A field's uuid is unique only within its element (copies keep + # them), so the lookup takes the op's element too. + if isinstance(value, str) and _UUID_RE.fullmatch(value): + uuid_methods.add("elementTextIndex") + return f"qet.elementTextIndex({folio_js}, {element_js}, {_js(value)})" + if not isinstance(value, int) or isinstance(value, bool): + raise ValueError(f"operation {op_index}: {key!r} must be a text field index " + f"or its uuid, got {value!r}") + return _js(value) if kind in ("text", "shape", "image"): # A uuid names the item for good; it is turned into the index # the call takes at run time, by the item's own folio. @@ -1399,6 +1420,7 @@ def _build_script(operations: list, output: str) -> str: if key not in op: raise ValueError(f"operation {i} ({name}) is missing {key!r}") folio_js = args[0] if args else "0" + element_js = args[1] if len(args) > 1 else None args.append(ref_or(op[key], kind, i, key)) ident = op.get("id") @@ -2415,6 +2437,11 @@ TOOLS = [ "shape's points can reorder it relative to the others -- re-list " "before addressing one by index again if more than one is being " "edited in the same run, or address it by uuid. " + "set_table_position/delete_table take a table's index or its uuid, " + "and set_element_text/delete_element_text a text field's index or " + "its uuid (the field's own, looked up within the op's element) " + "-- a uuid still names the right item after an " + "earlier one is deleted. " "Tables: add_table places a BOM/nomenclature or summary table " "(kind is \"nomenclature\" or \"summary\") built from a query " "against a project database view -- run qet.query() (the " diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index e19ea989f..d6f11aad7 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -167,6 +167,33 @@ class EditValidation(unittest.TestCase): def build(self, ops): return m._build_script(ops, "/tmp/out.qet") + def test_tables_and_text_fields_by_uuid(self): + """A table or a symbol text field named by uuid is looked up at run + time; a field's lookup is scoped to the op's element.""" + U = "{11111111-2222-4333-8444-555555555555}" + E = "{aaaaaaaa-0000-4000-8000-000000000001}" + s = self.build([{"op": "set_table_position", "folio": 2, "table": U, "x": 1, "y": 2}]) + self.assertIn(f'qet.setTablePosition(2, qet.tableIndex(2, "{U}"), 1, 2)', s) + s = self.build([{"op": "delete_table", "folio": 0, "table": 3}]) + self.assertIn("qet.deleteTable(0, 3)", s) + s = self.build([{"op": "set_element_text", "folio": 1, "element": E, "index": U, + "property": "x", "value": "5"}]) + self.assertIn(f'qet.setElementTextProperty(1, "{E}", ' + f'qet.elementTextIndex(1, "{E}", "{U}"), "x", "5")', s) + # the element may be one placed earlier in the same run + s = self.build([{"op": "add_folio", "id": "f"}, + {"op": "add_element", "id": "k", "folio": "$f", "path": "p", "x": 0, "y": 0}, + {"op": "delete_element_text", "folio": "$f", "element": "$k", "index": U}]) + self.assertIn(f'qet.deleteElementText(R["f"], R["k"], qet.elementTextIndex(R["f"], R["k"], "{U}"))', s) + # the lookups are required only when a uuid is used + self.assertIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": U}])) + self.assertNotIn('"tableIndex"', self.build([{"op": "delete_table", "folio": 0, "table": 0}])) + for op in ({"op": "delete_table", "folio": 0, "table": "second"}, + {"op": "delete_element_text", "folio": 0, "element": E, "index": "label"}): + with self.subTest(op=op["op"]): + with self.assertRaisesRegex(ValueError, "index or its uuid"): + self.build([op]) + def test_every_op_generates_a_script(self): # one minimal valid instance of every op f = {"op": "add_folio", "id": "f"} @@ -2816,6 +2843,58 @@ class UuidIndexLookups(unittest.TestCase): self.assertEqual((out["bogus"], out["not_uuid"]), (-1, -1)) self.assertEqual((out["second_after"], out["first_after"]), (0, -1)) + def test_qet_edit_deletes_then_moves_tables_by_uuid(self): + """Delete one table, then move the other, both by uuid. By index the + second op would name the wrong table: deleting table 0 shifts table 1.""" + text = (Path(EXAMPLES) / "industrial.qet").read_text(encoding="utf-8") + root = ET.fromstring(text) + folio, table = next((i, d.find("tables/graphics_table")) for i, d in enumerate(root.iter("diagram")) + if d.find("tables/graphics_table") is not None) + twin = ET.fromstring(ET.tostring(table)) + twin.set("uuid", "{11111111-2222-4333-8444-555555555555}") + twin.set("x", str(float(table.get("x")) + 900)) + # Insert into the raw text: re-serialising the whole file with + # ElementTree rewrites the embedded SVG logo's namespace, which + # QElectroTech then saves without its declaration. + start = text.index(f'uuid="{table.get("uuid")}"') + end = text.index("", start) + len("") + text = text[:end] + ET.tostring(twin, encoding="unicode") + text[end:] + with tempfile.TemporaryDirectory() as tmp: + src, out = Path(tmp) / "two.qet", Path(tmp) / "out.qet" + src.write_text(text, encoding="utf-8") + r = m.tool_edit(BINARY, str(src), [ + {"op": "delete_table", "folio": folio, "table": table.get("uuid")}, + {"op": "set_table_position", "folio": folio, "table": twin.get("uuid"), "x": 120, "y": 340}], + str(out), elements_dir=ELEMENTS or None) + self.assertTrue(r["ok"], r.get("hint")) + left = list(ET.parse(out).getroot().iter("diagram"))[folio].findall("tables/graphics_table") + self.assertEqual([(t.get("uuid"), float(t.get("x")), float(t.get("y"))) for t in left], + [(twin.get("uuid"), 120.0, 340.0)]) + + def test_qet_edit_edits_one_copys_field_by_uuid(self): + """Two copies of a symbol share a field uuid; addressing it with the + symbol changes that copy's field only.""" + root = ET.parse(Path(EXAMPLES) / "2612_ats_singlephase.qet").getroot() + owners = {} + for i, d in enumerate(root.iter("diagram")): + for el in d.iter("element"): + for t in el.findall("dynamic_texts/dynamic_elmt_text"): + if t.get("uuid"): + owners.setdefault((i, t.get("uuid")), []).append(el.get("uuid")) + (folio, field), (a, b) = next((k, v[:2]) for k, v in owners.items() if len(v) >= 2) + with tempfile.TemporaryDirectory() as tmp: + src, out = Path(tmp) / "in.qet", Path(tmp) / "out.qet" + shutil.copy(Path(EXAMPLES) / "2612_ats_singlephase.qet", src) + r = m.tool_edit(BINARY, str(src), [ + {"op": "set_element_text", "folio": folio, "element": b, "index": field, + "property": "x", "value": "77"}], str(out), elements_dir=ELEMENTS or None) + self.assertTrue(r["ok"], r.get("hint")) + x_of = lambda path, el_uuid: next( + t.get("x") for el in ET.parse(path).getroot().iter("element") if el.get("uuid") == el_uuid + for t in el.findall("dynamic_texts/dynamic_elmt_text") if t.get("uuid") == field) + self.assertEqual(float(x_of(out, b)), 77.0) + self.assertEqual(x_of(out, a), x_of(src, a)) + def test_element_text_index_needs_the_element_as_well(self): """Copies of a symbol share their text fields' uuids (2612_ats_singlephase.qet): the same field uuid resolves on each copy to that copy's own field.""" From 2529b11d69f34bfadf58df7a0dccb7f46b78f93e Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 16:01:31 +1300 Subject: [PATCH 16/16] Convert examples/schema_indus.qet to the current format; its labels show again schema_indus.qet was saved by QElectroTech 0.3, where each placed symbol kept its own values for its definition's old text fields, as . Loading those was deliberately dropped in 1f53c3929 ("Remove retro compatibility of element text item prior to qet 0.7"), so since 2021 this shipped example has shown none of its per-symbol texts: 122 texts on 44 of its 48 symbols, including every device label (Q1, KM1, KM2, T1, M1, S1-S5, H1, H2, the X terminals) and ratings such as "24VAC" and "F0 am 0,5A". This commit changes the example file only, not the loader. It was converted by loading it once with a local build that had 1f53c3929 reverted (and the converted label kept when the symbol's own label was empty, as that code intended), and saving it. That build is not proposed. Checked with a build of current master: every one of the 44 symbols shows exactly the texts its held; the 48 symbols (type, position, rotation), the 69 wires (ends and numbers) and the folio are unchanged; the two free texts only gain their font written out, as any save does. Like any save, the file also gains uuids and the current version number. Co-Authored-By: Claude Opus 5.5 --- examples/schema_indus.qet | 2507 +++++++++++++++++++++++-------------- 1 file changed, 1582 insertions(+), 925 deletions(-) diff --git a/examples/schema_indus.qet b/examples/schema_indus.qet index 82abfafad..b098f2e34 100644 --- a/examples/schema_indus.qet +++ b/examples/schema_indus.qet @@ -1,671 +1,1328 @@ - - + + + 9/28/26 + 28-09-2026 + 2026-09-28 + original + /home/user/.claude/jobs/79bf63cb/tmp/f059/original.qet + 15:58 + + - - - + + + + + + + + + + + + + + - - - - - - - - - - - - + + - + - - - - - - - - - - - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + X0.1 + + + + + + + X0.1 + label + + + - + - - - - - - - - - - - - - - - - - - - - + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + X0.2 + + + + X0.2 + label + + + + + + - + - - + + + + + + + + + + + + - - - - + + + 6 + + + + 6 + + + 3 + + + 2 + + + 5 + + + 4 + + + KM2 + + + 5 + + + 1 + + + 3 + + + 4 + + + KM1 + + + 6 + label + + + 1 + + + 2 + + + - + - - - - - - + + - - - + + + X0.5 + + + + X0.5 + label + + + + + + - + - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - + + + X0.3 + + + + X0.3 + label + + + + + + - + - - + + + + + + - - - - + + + Q1 + + + + Q1 + label + + + - + - - + + - - - - + + + X0.6 + + + + + + + X0.6 + label + + + - + - - + + + + - - - - + + + M1 + + + + M1 + label + + + - + - - + + - - - - + + + X0.4 + + + + + + + X0.4 + label + + + - + - - + + - - - - + + + X0.7 + + + + X0.7 + label + + + + + + - + - - - - + + - - - - - - - + + + X0.8 + + + + X0.8 + label + + + + + + - + - - + - - - - - + + + - + - - + - - - - - + + + - + - - + + + + - - - - - + + + 3 + + + + F0 am +0,5A + + + 1 + + + 3 + label + + + 2 + + + 4 + + + - + - - + + + + + - - - - - + + + 24VAC + + + + 400VAC + + + T1 + + + 24VAC + label + + + - + - - + + - - - - - + + + X0.10 + + + + X0.10 + label + + + + + + - + - - + + + + - - - - + + + 1 + + + + N + + + N + + + F1gg +4A + + + 1 + label + + + 2 + + + - + - - + + - - - - + + + X0.9 + + + + + + + X0.9 + label + + + - + - - + + - - - - - + + + 14 + + + + 14 + label + + + 13 + + + Q1 + + + - + - - + + - - - - - + + + X1.10 + + + + X1.10 + label + + + + + + - + - - + + - - - - + + + 2 + + + + CPU + + + 2 + label + + + 1 + + + - + - - + + - - - - + + + X1.1 + + + + X1.1 + label + + + + + + - + - - + + - - - - - + + + 2 + + + + S1 + + + 1 + + + 2 + label + + + - + - - + + - - - - - + + + X1.2 + + + + + + + X1.2 + label + + + - + - - + + - - - - + + + 4 + + + + 4 + label + + + 3 + + + S2 + + + - + - - + + - - - - + + + X2.1 + + + + + + + X2.1 + label + + + - + - - + + - - - - + + + 2 + + + + 2 + label + + + 1 + + + S4 + + + - + - - + + - - - - - + + + X2.2 + + + + X2.2 + label + + + + + + - + - - + + - - - - - + + + 22 + + + + 21 + + + KM2 + + + 22 + label + + + - + - - + + - - - - - + + + A2 + + + + A2 + label + + + KM1 + + + A1 + + + + + + + + + + + + X1.3 + + + + X1.3 + label + + + + + + + + + + + + + + + 14 + + + + KM1 + + + 14 + label + + + 13 + + + + + + + + + + + + 4 + + + + S3 + + + 3 + + + 4 + label + + + + + + + + + + + + X2.3 + + + + + + + X2.3 + label + + + + + + + + + + + + 2 + + + + S5 + + + 2 + label + + + 1 + + + + + + + + + + + + X2.4 + + + + + + + X2.4 + label + + + + + + + + + + + + 22 + + + + KM1 + + + 22 + label + + + 21 + + + + + + + + + + + + A2 + + + + A2 + label + + + A1 + + + KM2 + + + + + + + + + + + + X1.4 + + + + + + + X1.4 + label + + + + + + + + + + + + 14 + + + + 14 + label + + + 13 + + + KM2 + + + + + + + + + + + + 54 + + + + KM1 + + + 54 + label + + + 53 + + + + + + + + + + + + X1.5 + + + + X1.5 + label + + + + + + + + + + + + + + + X2 + + + + X2 + label + + + X1 + + + H1 + + + + + + + + + + + + 54 + + + + 53 + + + 54 + label + + + KM2 + + + + + + + + + + + + X1.6 + + + + + + + X1.6 + label + + + + + + + + + + + + X2 + + + + H2 + + + X1 + + + X2 + label + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - + + - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + - + + - - - - + + + + + - - + + + + + - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - + + @@ -707,7 +1364,7 @@ Cívky - + ملف Spule @@ -721,17 +1378,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -750,7 +1407,7 @@ Světla - + مُؤشّر وميضي Flashing light @@ -761,15 +1418,15 @@ - - - - - - - - - + + + + + + + + + @@ -788,7 +1445,7 @@ Měniče - + محوّل أحادي الوجه Trafo Wechselstrom @@ -804,19 +1461,19 @@ Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - + + + + + + + + + + + + + @@ -835,7 +1492,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Bezpečnost - + تأريض Erde @@ -851,11 +1508,11 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - + + + + + @@ -874,7 +1531,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Stroje - + محرّك ثلاثي الأوجه Drehstrom Motor @@ -890,15 +1547,15 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - + + + + + + + + + @@ -931,7 +1588,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Několik vedení - + مصدر ثلاثي القطبية + PE + N Drehstrom + PE +N @@ -945,21 +1602,21 @@ License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -994,7 +1651,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Nomenklatura - + عنوان جدول التسمية Titelfeld Nomenklatur @@ -1005,20 +1662,20 @@ License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - + + + + + + + + - + علامة مصدر ثلاثي الأطوار Label three phase @@ -1028,11 +1685,11 @@ License: see http://qelectrotech.org/wiki/doc/elements_license EN 60617: 02-02-07 - - - - - + + + + + @@ -1051,7 +1708,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Svorky - + طرف توصيل Klemmblock @@ -1067,13 +1724,13 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - + + + + + + + @@ -1101,7 +1758,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Magnetotepelný jistič GV - + قاطع مغناطيسي-حراري GV Motorschutzschalter @@ -1112,72 +1769,72 @@ License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1209,7 +1866,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Spínače - + محدد بوضعين محمي بقفل, سحب المفتاح إلى اليسار Selector switch 2-position stay put, key withdrawal from LH @@ -1220,27 +1877,27 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - + مُحدّد دوار بوضعين ثابتين محمي بقفل, إخراج المفتاح إلى اليسار Selector switch 2-position stay put, key withdrawal from LH @@ -1251,23 +1908,23 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + @@ -1286,7 +1943,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Síla - + قفل ميكانيكي Mechanical lock @@ -1296,9 +1953,9 @@ License: see http://qelectrotech.org/wiki/doc/elements_license - - - + + + @@ -1317,7 +1974,7 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Jednoduché - + مُلامس بسيط (NO) Kontakt Einfach @@ -1333,18 +1990,18 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - + + + + + - + مُلامس بسيط (NO) Kontakt Einfach @@ -1360,18 +2017,18 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - + + + + + + + - + مُلامس بسيط (NC) Kontakt Einfach (NC) @@ -1387,19 +2044,19 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - + + + + + + + + - + زرّ ضاغط Druck-Taster @@ -1415,80 +2072,80 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - + + + + + + + + + + + - + Contacteur inverseur Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + زرّ ضاغط (NC) Druck-Taster (NC) @@ -1504,52 +2161,52 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - + + + + + + + + + + + + + - + Porte Fusible Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + مُؤشّر وميضي Flashing light @@ -1560,23 +2217,23 @@ License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - + + + + + + + + + + + + - + زرّ توقيف طارئ Push-button @@ -1587,24 +2244,24 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - + + + + + + + + + + + + + - + مفتاح نهاية مشوار (NC) Grenzschalter (NC) @@ -1620,20 +2277,20 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - + + + + + + + + + - + محوّل أحادي الوجه Trafo Wechselstrom @@ -1649,26 +2306,26 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + - + محرّك ثلاثي الأوجه Drehstrom Motor @@ -1684,28 +2341,28 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + - + مفتاح عزل ثلاثي بمصهرات Fuse-disconnector @@ -1716,24 +2373,24 @@ License: see http://qelectrotech.org/wiki/doc/elements_license Author: The QElectroTech team License: see http://qelectrotech.org/wiki/doc/elements_license - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +