Merge branch 'feat/qet-mcp-diff-uuids' into feat/qet-mcp-items

This commit is contained in:
ispyisail
2026-09-28 13:15:21 +13:00
2 changed files with 93 additions and 6 deletions
+17 -6
View File
@@ -441,6 +441,14 @@ def _extras(root: ET.Element) -> dict:
"element_text_folios": element_text_folios}
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 = []
@@ -464,7 +472,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"])
@@ -490,7 +498,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()}
@@ -531,15 +539,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])
@@ -620,7 +631,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}
+76
View File
@@ -1237,6 +1237,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: ('<dynamic_elmt_text uuid="{same}" x="' + str(x) + '" y="0" '
'text_from="UserText"><text>t</text></dynamic_elmt_text>')
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'<dynamic_elmt_text uuid="{{dup}}" x="{v}" y="0" text_from="UserText"><text>{v}</text>'
'</dynamic_elmt_text>' 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='<shapes>' + ''.join(
f'<shape uuid="{{s}}" type="Line" x1="{i}" y1="0" x2="1" y2="1"/>' for i in (0, 5)) + '</shapes>'))
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='<dynamic_elmt_text uuid="{e1}" x="0" y="0" '
'text_from="UserText"><text>t</text></dynamic_elmt_text>')
+ 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 ('<graphics_table uuid="{tb}" name="P" '
+ " ".join(f'{k}="{w}"' for k, w in a.items()) + '/>')
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: ('<graphics_table uuid="{tb}" name="Parts" x="' + str(x)
+ f'" y="0" width="100" height="50" display_n_row="{rows}"/>')