diff --git a/misc/qet-mcp/qet_mcp.py b/misc/qet-mcp/qet_mcp.py index a33630123..a38c1e1f1 100755 --- a/misc/qet-mcp/qet_mcp.py +++ b/misc/qet-mcp/qet_mcp.py @@ -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} diff --git a/misc/qet-mcp/test_qet_mcp.py b/misc/qet-mcp/test_qet_mcp.py index 3d90e9d13..4d7559b80 100644 --- a/misc/qet-mcp/test_qet_mcp.py +++ b/misc/qet-mcp/test_qet_mcp.py @@ -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: ('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: ('')