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 - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + diff --git a/misc/qet-mcp/README.md b/misc/qet-mcp/README.md index 6d29fe6dc..e5136fb88 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 | @@ -264,6 +265,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 036d0c452..7703c4c0e 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()), @@ -272,6 +279,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: @@ -312,6 +349,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. @@ -321,7 +373,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()), @@ -329,6 +381,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.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", ""), + "height": tb.get("height", ""), "rows_shown": tb.get("display_n_row", "")})) # The folio's own items only: direct children of its , # and . Symbols in older files carry their own # texts, which iter() would count as free texts @@ -337,7 +395,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.findall("shapes/shape"): pen, brush = sh.find("pen"), sh.find("brush") @@ -350,13 +408,13 @@ 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.findall("images/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 = {} + element_texts, element_text_uuids, element_text_folios = {}, {}, {} for n, d in _folios(root): for el in d.iter("element"): uuid = el.get("uuid", "") @@ -369,9 +427,11 @@ 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_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": t.get("rotation", ""), + "frame": t.get("frame", ""), "rotation": _angle(t.get("rotation", "")), "width": t.get("text_width", ""), "shows": t.findtext("text") or ""} @@ -386,8 +446,18 @@ 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, + "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: @@ -413,7 +483,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"]) @@ -429,34 +499,87 @@ 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 = _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()} + 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. + """ + # 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[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]) + 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())( @@ -476,16 +599,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"], @@ -505,15 +636,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 = _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} # 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 " @@ -530,8 +667,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 { @@ -544,11 +684,13 @@ 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), - "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, @@ -1132,10 +1274,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 @@ -1195,9 +1337,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"] @@ -1251,6 +1393,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 @@ -1304,6 +1447,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. @@ -1403,6 +1566,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") @@ -1586,6 +1750,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" @@ -1615,6 +1790,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") @@ -2137,6 +2318,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 " @@ -2166,7 +2367,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}, @@ -2182,9 +2384,14 @@ TOOLS = [ { "name": "qet_diff", "description": "Structurally diff two .qet files: which elements moved and " - "by what delta, 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.", + "by what delta, which were rotated (orientation in quarter " + "turns, 0-3), which were added, removed or relabelled, and " + "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": { @@ -2419,6 +2626,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 " @@ -2516,7 +2728,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}, }, @@ -2705,6 +2922,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 15bf8a0bd..d9310e6c2 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): @@ -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"} @@ -707,6 +734,648 @@ 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 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. + + 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"}}], + "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"], + 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 + 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, "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}) + + 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, "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"))) + 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)) + 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"))), + 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_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_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_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( + 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: ('') + 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)) + 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() @@ -1099,6 +1768,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(): @@ -1275,6 +1958,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: @@ -1305,6 +2007,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 @@ -2486,6 +3205,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 @@ -2790,8 +3518,152 @@ 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_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.""" + 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_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.""" diff --git a/sources/ElementsCollection/elementpickerpopup.cpp b/sources/ElementsCollection/elementpickerpopup.cpp index 5ebc14fd4..ccc714e4e 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 @@ -53,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 @@ -217,6 +238,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 +401,7 @@ void ElementPickerPopup::setCommands(const QStringList &ids) auto *button = new QToolButton(m_commands); button->setAutoRaise(true); button->setIconSize(QSize(24, 24)); - button->setIcon(location.icon()); + button->setIcon(elementIcon(location.icon(), QSize(24, 24), this)); button->setToolTip(location.name()); button->setFocusPolicy(Qt::NoFocus); connect(button, &QToolButton::clicked, this, [this, location]() { @@ -542,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('&')); @@ -632,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); 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); 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/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); 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());