From f5888e8b5663fc74c5b570f6524201a384b503c0 Mon Sep 17 00:00:00 2001 From: ispyisail Date: Mon, 28 Sep 2026 13:03:06 +1300 Subject: [PATCH] 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))