From f5888e8b5663fc74c5b570f6524201a384b503c0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:03:06 +1300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 d92a0781b3f7f5eec60f2b5fe18e454a39a695a8 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:31:54 +1300 Subject: [PATCH 6/6] 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(